添加通道 - 目标服务改成列表:每行一台主机,多个端口用逗号隔开(也支持 8080-8082、主机范围); 「添加主机」沿用上一行的端口,粘贴一串 IP / 主机:端口会自动拆成多行 - 新建时不再手动指定对外端口,统一自动分配;预览里列出每个地址分到的端口, 连通性测试并发进行,结果标在每个地址后面 - 超过一个地址自动放进以名称命名的新通道组(也可选加入已有组),只有一个地址时为不分组; 编辑通道仍保留端口栏和「新建通道组」 - 网页面板同步:同样的目标列表、自动端口与通道组规则 菜单栏 - 同一个通道组合并为一行「组名(N 个地址)」,子菜单里整组开关、逐个地址开关 连接与授权(通道组) - 当前在线、历史接入台账增加「按设备 / 按地址」切换:按地址列出组内每个地址 有几台设备在用、谁访问过;按设备的汇总行显示访问了组内哪些地址,并带上授权姓名 - 网页面板的在线连接同样可以按地址查看
5039 lines
209 KiB
Swift
5039 lines
209 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?
|
||
/// 所属通道组
|
||
var group_id: String?
|
||
}
|
||
|
||
/// 近 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 group_id: String?
|
||
var name: String
|
||
var ip: String
|
||
var invite: String?
|
||
var at: String
|
||
var status: String
|
||
}
|
||
|
||
/// 通道组:组内通道共用邀请、授权与审批,闲置计时联动
|
||
struct GroupItem: Identifiable, Codable {
|
||
var id: String
|
||
var name: String
|
||
var owner: String?
|
||
var created_at: String?
|
||
/// 成员通道,按监听端口排序
|
||
var tunnel_ids: [String]
|
||
var metrics: GroupMetricsItem?
|
||
var rate_history: RateSeriesItem?
|
||
}
|
||
|
||
struct GroupMetricsItem: Codable {
|
||
var group_id: String?
|
||
var dynamic_leases: [DynamicLeaseItem]?
|
||
var invites: [InviteItem]?
|
||
var pending: [AccessRequestItem]?
|
||
}
|
||
|
||
/// 添加通道表单将创建(或已创建)的通道
|
||
struct PlannedTunnel: Codable, Identifiable {
|
||
var id: String
|
||
var name: String
|
||
var target: String
|
||
var listen_port: Int
|
||
}
|
||
|
||
struct GroupRef: Codable {
|
||
var id: String
|
||
var name: String
|
||
}
|
||
|
||
/// /api/tunnels/batch 的返回:加入的通道组(未分组为 nil)与创建的通道
|
||
struct BatchResp: Codable {
|
||
var group: GroupRef?
|
||
var tunnels: [PlannedTunnel]
|
||
}
|
||
|
||
/// 添加 / 编辑通道表单的内容;新建时 target 可以写多个目标
|
||
struct TunnelForm {
|
||
var name: String
|
||
var owner: String
|
||
var target: String
|
||
/// 0 表示自动分配;多个目标时总是自动分配一段连续端口
|
||
var port: Int
|
||
var whitelist: [String]
|
||
var durationMinutes: Int
|
||
var idleCloseMinutes: Int
|
||
var maxBandwidthMB: Double
|
||
/// 加入已有通道组
|
||
var groupId: String
|
||
/// 新建通道组的名称
|
||
var newGroup: String
|
||
|
||
var body: [String: Any] {
|
||
[
|
||
"name": name, "owner": owner, "target": target, "listen_port": port,
|
||
// 空数组表示“仅授权链接”
|
||
"whitelist": whitelist,
|
||
"duration_minutes": durationMinutes, "idle_close_minutes": idleCloseMinutes,
|
||
"max_bandwidth_mb": maxBandwidthMB, "group_id": groupId, "new_group": newGroup
|
||
]
|
||
}
|
||
}
|
||
|
||
/// 授权的作用范围:单个通道,或整个通道组
|
||
enum AccessScope: Hashable {
|
||
case tunnel(String)
|
||
case group(String)
|
||
|
||
var key: String {
|
||
switch self {
|
||
case .tunnel(let id): return "t:\(id)"
|
||
case .group(let id): return "g:\(id)"
|
||
}
|
||
}
|
||
|
||
/// 授权相关接口的前缀(/invites、/requests、/leases 挂在其下)
|
||
var apiPath: String {
|
||
switch self {
|
||
case .tunnel(let id): return "/api/tunnels/\(id)"
|
||
case .group(let id): return "/api/groups/\(id)"
|
||
}
|
||
}
|
||
|
||
var historyQuery: String {
|
||
switch self {
|
||
case .tunnel(let id): return "tunnel_id=\(id)"
|
||
case .group(let id): return "group_id=\(id)"
|
||
}
|
||
}
|
||
|
||
var isGroup: Bool {
|
||
if case .group = self { return true }
|
||
return false
|
||
}
|
||
}
|
||
|
||
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 ProbeOutcome {
|
||
let ok: Bool
|
||
/// 延迟,或失败原因
|
||
let detail: String
|
||
}
|
||
|
||
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) }
|
||
}
|
||
}
|
||
}
|
||
|
||
extension TunnelSearch {
|
||
/// 任意文本(如通道组名称、责任人)是否命中全部关键字
|
||
static func matches(texts: [String], query: String) -> Bool {
|
||
let tokens = query.lowercased().split(whereSeparator: { $0.isWhitespace }).map(String.init)
|
||
if tokens.isEmpty { return true }
|
||
let plain = texts.joined(separator: " ").lowercased()
|
||
let py = texts.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: [(title: String, scope: AccessScope, request: AccessRequestItem)] {
|
||
var out: [(title: String, scope: AccessScope, request: AccessRequestItem)] = []
|
||
for t in tunnels {
|
||
for r in t.metrics?.pending ?? [] { out.append((title: t.name, scope: .tunnel(t.id), request: r)) }
|
||
}
|
||
for g in groups {
|
||
for r in g.metrics?.pending ?? [] { out.append((title: g.name, scope: .group(g.id), request: r)) }
|
||
}
|
||
return out
|
||
}
|
||
|
||
// MARK: 通道组与授权范围
|
||
|
||
func members(of g: GroupItem) -> [TunnelItem] {
|
||
g.tunnel_ids.compactMap { id in tunnels.first { $0.id == id } }
|
||
}
|
||
|
||
func group(_ id: String?) -> GroupItem? {
|
||
guard let id = id, !id.isEmpty else { return nil }
|
||
return groups.first { $0.id == id }
|
||
}
|
||
|
||
func scopeTunnels(_ s: AccessScope) -> [TunnelItem] {
|
||
switch s {
|
||
case .tunnel(let id): return tunnels.filter { $0.id == id }
|
||
case .group(let id): return group(id).map { members(of: $0) } ?? []
|
||
}
|
||
}
|
||
|
||
func scopeTitle(_ s: AccessScope) -> String {
|
||
switch s {
|
||
case .tunnel(let id): return tunnels.first { $0.id == id }?.name ?? ""
|
||
case .group(let id): return group(id)?.name ?? "通道组"
|
||
}
|
||
}
|
||
|
||
/// 通道已开启,或通道组内至少有一个通道开启
|
||
func scopeEnabled(_ s: AccessScope) -> Bool {
|
||
scopeTunnels(s).contains { $0.enabled }
|
||
}
|
||
|
||
func scopeLeases(_ s: AccessScope) -> [DynamicLeaseItem] {
|
||
switch s {
|
||
case .tunnel(let id): return tunnels.first { $0.id == id }?.metrics?.dynamic_leases ?? []
|
||
case .group(let id): return group(id)?.metrics?.dynamic_leases ?? []
|
||
}
|
||
}
|
||
|
||
func scopeInvites(_ s: AccessScope) -> [InviteItem] {
|
||
switch s {
|
||
case .tunnel(let id): return tunnels.first { $0.id == id }?.metrics?.invites ?? []
|
||
case .group(let id): return group(id)?.metrics?.invites ?? []
|
||
}
|
||
}
|
||
|
||
func scopePending(_ s: AccessScope) -> [AccessRequestItem] {
|
||
switch s {
|
||
case .tunnel(let id): return tunnels.first { $0.id == id }?.metrics?.pending ?? []
|
||
case .group(let id): return group(id)?.metrics?.pending ?? []
|
||
}
|
||
}
|
||
|
||
/// 服务器控制端口(网页授权链接使用),取自控制地址
|
||
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 groups: [GroupItem] = []
|
||
@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 fetchGroups()
|
||
await fetchConnections()
|
||
let onlineCount = tunnels.filter { $0.enabled }.count
|
||
appDelegate?.updateStatusItemTitle(activeCount: onlineCount)
|
||
}
|
||
|
||
/// 新的审批申请弹出系统通知
|
||
private func notifyNewRequests() {
|
||
for (title, _, r) in pendingRequests where !notifiedRequests.contains(r.id) {
|
||
notifiedRequests.insert(r.id)
|
||
let content = UNMutableNotificationContent()
|
||
content.title = "访问申请:\(title)"
|
||
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 tunnelSig: String = tunnels.map { "\($0.id):\($0.name):\($0.listen_port):\($0.enabled):\($0.group_id ?? "")" }.joined(separator: ",")
|
||
let groupSig: String = groups.map { "\($0.id):\($0.name)" }.joined(separator: ",")
|
||
let pendingSig: String = pendingRequests.map { $0.request.id }.joined(separator: ",")
|
||
let sig = [String(isConnected), tunnelSig, groupSig, pendingSig].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
|
||
}
|
||
}
|
||
|
||
func fetchGroups() async {
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/groups") else { return }
|
||
if let (data, _) = try? await URLSession.shared.data(from: url),
|
||
let items = try? JSONDecoder().decode([GroupItem].self, from: data) {
|
||
self.groups = items
|
||
notifyNewRequests()
|
||
}
|
||
}
|
||
|
||
/// 修改通道组名称 / 责任人,并把已有通道加入该组(新地址在添加通道表单里创建)
|
||
func saveGroup(id: String, name: String, owner: String, tunnelIds: [String]) async -> String? {
|
||
let (_, err) = await apiCall("PUT", "/api/groups/\(id)", body: ["name": name, "owner": owner, "tunnel_ids": tunnelIds])
|
||
await refreshData()
|
||
return err
|
||
}
|
||
|
||
/// 预览添加通道表单将创建的通道(多个目标时),不保存
|
||
func previewTunnels(_ form: TunnelForm) async -> ([PlannedTunnel], String?) {
|
||
if isPreview {
|
||
// 预览模式只模拟常见写法:逗号分隔,以及末段范围 "10.0.3.11-13:22"
|
||
var out: [PlannedTunnel] = []
|
||
for item in form.target.split(whereSeparator: { ", ,\n".contains($0) }) {
|
||
let hp = item.split(separator: ":")
|
||
guard hp.count == 2 else { continue }
|
||
let parts = hp[0].split(separator: ".")
|
||
var hosts = [String(hp[0])]
|
||
if parts.count == 4, let dash = parts[3].firstIndex(of: "-"),
|
||
let lo = Int(parts[3][..<dash]), let hi = Int(parts[3][parts[3].index(after: dash)...]), lo <= hi {
|
||
hosts = (lo...hi).map { parts[0..<3].joined(separator: ".") + ".\($0)" }
|
||
}
|
||
for h in hosts {
|
||
out.append(PlannedTunnel(id: "\(h):\(hp[1])", name: "\(form.name) (\(h))", target: "\(h):\(hp[1])", listen_port: 33673 + out.count))
|
||
}
|
||
}
|
||
return (out, nil)
|
||
}
|
||
var body = form.body
|
||
body["dry_run"] = true
|
||
let (data, err) = await apiCall("POST", "/api/tunnels/batch", body: body)
|
||
guard let data = data else { return ([], err) }
|
||
return ((try? JSONDecoder().decode(BatchResp.self, from: data))?.tunnels ?? [], nil)
|
||
}
|
||
|
||
func toggleGroup(id: String, enabled: Bool) async {
|
||
_ = await apiCall("POST", "/api/groups/\(id)/toggle", body: ["enabled": enabled])
|
||
await refreshData()
|
||
}
|
||
|
||
/// 解散通道组;deleteMembers 为 true 时连同组内通道一起删除
|
||
func deleteGroup(id: String, deleteMembers: Bool) async {
|
||
_ = await apiCall("DELETE", "/api/groups/\(id)" + (deleteMembers ? "?delete_members=1" : ""))
|
||
await refreshData()
|
||
}
|
||
|
||
/// 把通道移入(groupId 非空)或移出通道组
|
||
func moveTunnel(id: String, toGroup groupId: String) async -> String? {
|
||
let (_, err) = await apiCall("PUT", "/api/tunnels/\(id)", body: ["group_id": groupId])
|
||
await refreshData()
|
||
return err
|
||
}
|
||
|
||
/// 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?, form: TunnelForm) async -> (Bool, BatchResp?, String?) {
|
||
if let id = id, !id.isEmpty {
|
||
let (_, err) = await apiCall("PUT", "/api/tunnels/\(id)", body: form.body)
|
||
if let err = err { return (false, nil, err) }
|
||
await refreshData()
|
||
return (true, nil, nil)
|
||
}
|
||
let (data, err) = await apiCall("POST", "/api/tunnels/batch", body: form.body)
|
||
guard let data = data else { return (false, nil, err) }
|
||
await refreshData()
|
||
return (true, try? JSONDecoder().decode(BatchResp.self, from: data), nil)
|
||
}
|
||
|
||
/// 测试一个目标的连通性;detail 为延迟或失败原因
|
||
func probe(_ target: String) async -> ProbeOutcome {
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/probe") else { return ProbeOutcome(ok: false, detail: "无效地址") }
|
||
var req = URLRequest(url: url)
|
||
req.httpMethod = "POST"
|
||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
req.httpBody = try? JSONSerialization.data(withJSONObject: ["target": target])
|
||
do {
|
||
let (data, _) = try await URLSession.shared.data(for: req)
|
||
guard let res = try? JSONDecoder().decode(ProbeResp.self, from: data) else {
|
||
return ProbeOutcome(ok: false, detail: "解析失败")
|
||
}
|
||
return res.reachable ? ProbeOutcome(ok: true, detail: res.rtt) : ProbeOutcome(ok: false, detail: res.error ?? "超时")
|
||
} catch {
|
||
return ProbeOutcome(ok: false, detail: error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 同时测试多个目标
|
||
func probeAll(_ targets: [String]) async -> [String: ProbeOutcome] {
|
||
var out: [String: ProbeOutcome] = [:]
|
||
await withTaskGroup(of: (String, ProbeOutcome).self) { group in
|
||
for t in targets {
|
||
group.addTask { (t, await self.probe(t)) }
|
||
}
|
||
for await (t, r) in group {
|
||
out[t] = r
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
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(scope: AccessScope) async -> [ClientHistoryRecord] {
|
||
if isPreview {
|
||
let ids = Set(scopeTunnels(scope).map { $0.id })
|
||
return previewHistory.filter { ids.contains($0.tunnel_id) }
|
||
}
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/history?\(scope.historyQuery)") 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(scope: AccessScope, 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 ts = scopeTunnels(scope)
|
||
let host = publicHost.isEmpty ? "192.168.1.10" : publicHost
|
||
let deadline = Date().addingTimeInterval(Double(validMinutes) * 60)
|
||
let fmt = DateFormatter(); fmt.dateFormat = "MM-dd HH:mm"
|
||
var lines: [String] = ["【PortRelay 访问邀请】" + (note.isEmpty ? "" : "(给 \(note))")]
|
||
if scope.isGroup {
|
||
lines.append("通道组:\(scopeTitle(scope))(\(ts.count) 个地址)")
|
||
lines.append("访问地址:")
|
||
for t in ts { lines.append(" \(host):\(t.listen_port) → \(t.target)") }
|
||
} else {
|
||
lines.append("通道:\(ts.first?.name ?? "MySQL 数据库")")
|
||
lines.append("访问地址:\(host):\(ts.first?.listen_port ?? 33660)")
|
||
}
|
||
let action = requireApproval ? "填写姓名提交申请,管理员批准后即可访问" : "填写姓名后即可访问"
|
||
lines.append("请在 \(fmt.string(from: deadline)) 前用浏览器打开下面的链接,\(action)(访问有效期:\(leaseDurationTitle(leaseDuration))):")
|
||
lines.append(link)
|
||
lines.append("此链接仅限 1 台设备使用,请勿转发。")
|
||
let text = lines.joined(separator: "\n")
|
||
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", "\(scope.apiPath)/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(scope: AccessScope, code: String) async -> (InviteShareResp?, String?) {
|
||
let (data, err) = await apiCall("GET", "\(scope.apiPath)/invites/\(code)/share")
|
||
guard let data = data else { return (nil, err) }
|
||
return (try? JSONDecoder().decode(InviteShareResp.self, from: data), nil)
|
||
}
|
||
|
||
func revokeInvite(scope: AccessScope, code: String) async -> String? {
|
||
let (_, err) = await apiCall("DELETE", "\(scope.apiPath)/invites/\(code)")
|
||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||
await refreshData()
|
||
return err
|
||
}
|
||
|
||
func decideRequest(scope: AccessScope, requestId: String, approve: Bool) async -> String? {
|
||
let (_, err) = await apiCall("POST", "\(scope.apiPath)/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(scope: AccessScope, ip: String?) async -> String? {
|
||
guard let url = URL(string: "http://127.0.0.1:33080\(scope.apiPath)/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 addSheet: AddSheetTarget?
|
||
@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 groupSheet: GroupSheetTarget?
|
||
// 通道搜索与状态筛选
|
||
@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)
|
||
}
|
||
|
||
/// 卡片列表:独立通道与通道组按配置顺序混排(组出现在它第一个成员的位置)
|
||
enum CardEntry: Identifiable {
|
||
case tunnel(TunnelItem)
|
||
case group(GroupItem, [TunnelItem])
|
||
|
||
var id: String {
|
||
switch self {
|
||
case .tunnel(let t): return "t:" + t.id
|
||
case .group(let g, _): return "g:" + g.id
|
||
}
|
||
}
|
||
}
|
||
|
||
var entries: [CardEntry] {
|
||
var out: [CardEntry] = []
|
||
var seen = Set<String>()
|
||
let query = searchText
|
||
for t in vm.tunnels {
|
||
if let g = vm.group(t.group_id) {
|
||
guard !seen.contains(g.id) else { continue }
|
||
seen.insert(g.id)
|
||
// 组名或责任人命中时显示整组成员,否则只显示命中的成员
|
||
let groupHit = !query.trimmingCharacters(in: .whitespaces).isEmpty && TunnelSearch.matches(texts: [g.name, g.owner ?? ""], query: query)
|
||
let shown = vm.members(of: g).filter {
|
||
statusFilter.accepts($0) && (groupHit || TunnelSearch.matches($0, query: query, publicHost: vm.publicHost))
|
||
}
|
||
if !shown.isEmpty { out.append(.group(g, shown)) }
|
||
} else if statusFilter.accepts(t) && TunnelSearch.matches(t, query: query, publicHost: vm.publicHost) {
|
||
out.append(.tunnel(t))
|
||
}
|
||
}
|
||
// 还没有成员的组
|
||
for g in vm.groups where !seen.contains(g.id) && g.tunnel_ids.isEmpty {
|
||
if statusFilter == .all && TunnelSearch.matches(texts: [g.name, g.owner ?? ""], query: query) {
|
||
out.append(.group(g, []))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
var matchedTunnelCount: Int {
|
||
entries.reduce(0) { n, e in
|
||
switch e {
|
||
case .tunnel: return n + 1
|
||
case .group(_, let shown): return n + shown.count
|
||
}
|
||
}
|
||
}
|
||
|
||
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 ? "匹配 \(matchedTunnelCount) / 共 \(vm.tunnels.count) 个"
|
||
: "\(vm.tunnels.count) 个通道" + (vm.groups.isEmpty ? "" : " · \(vm.groups.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 && vm.groups.isEmpty {
|
||
emptyStateView
|
||
} else if entries.isEmpty {
|
||
noMatchView
|
||
} else {
|
||
VStack(spacing: 10) {
|
||
ForEach(entries) { entry in
|
||
switch entry {
|
||
case .group(let g, let shown):
|
||
GroupCardView(
|
||
group: g,
|
||
members: vm.members(of: g),
|
||
visible: shown,
|
||
vm: vm,
|
||
onEdit: { groupSheet = GroupSheetTarget(group: g) },
|
||
onAddMember: { addSheet = AddSheetTarget(groupId: g.id) },
|
||
onInvite: { inviteTarget = InviteSheetTarget(scope: .group(g.id), autoGenerate: false) },
|
||
onViewClients: { tab in clientsTarget = ClientsSheetTarget(scope: .group(g.id), tab: tab) },
|
||
onEditMember: { t in editingTunnel = t },
|
||
onInviteMember: { t in inviteTarget = InviteSheetTarget(tunnel: t, autoGenerate: false) },
|
||
onMemberClients: { t in clientsTarget = ClientsSheetTarget(tunnel: t, tab: 0) }
|
||
)
|
||
case .tunnel(let t):
|
||
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(item: $addSheet) { target in
|
||
TunnelEditSheet(vm: vm, tunnel: nil, isPresented: Binding(
|
||
get: { addSheet != nil },
|
||
set: { if !$0 { addSheet = nil } }
|
||
), groupId: target.groupId, onCreated: { scope in
|
||
// 新建完成后打开邀请设置(时长、审批在邀请中选择);新建的通道组直接邀请整组
|
||
Task { @MainActor in
|
||
try? await Task.sleep(nanoseconds: 600_000_000)
|
||
if vm.scopeEnabled(scope) {
|
||
inviteTarget = InviteSheetTarget(scope: scope, autoGenerate: false)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
.sheet(item: $inviteTarget) { target in
|
||
InviteSheet(vm: vm, scope: target.scope, 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(item: $groupSheet) { target in
|
||
GroupEditSheet(vm: vm, group: target.group, isPresented: Binding(
|
||
get: { groupSheet != nil },
|
||
set: { if !$0 { groupSheet = 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, scope: target.scope, 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: { addSheet = AddSheetTarget(groupId: nil) }) {
|
||
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 {
|
||
cardCapsule(icon, text, color)
|
||
}
|
||
|
||
@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 {
|
||
cardFooterStat(label, value)
|
||
}
|
||
|
||
func lifecycleItem(_ icon: String, _ text: String, color: Color = .secondary, weight: Font.Weight = .regular, iconSize: CGFloat = 11) -> some View {
|
||
cardLifecycleItem(icon, text, color: color, weight: weight, iconSize: iconSize)
|
||
}
|
||
|
||
func quickConnectSnippets() -> [(title: String, cmd: String, icon: String)] {
|
||
connectSnippets(tunnel, host: vm.publicHost)
|
||
}
|
||
}
|
||
|
||
/// 一键复制菜单:按目标端口 / 名称猜测客户端命令,并总是提供访问地址
|
||
func connectSnippets(_ tunnel: TunnelItem, host: String) -> [(title: String, cmd: String, icon: String)] {
|
||
let p = tunnel.listen_port
|
||
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: - Tunnel Group Card
|
||
|
||
/// 通道组卡片:上半部分是整组的状态与流量,下面逐行列出成员(每个成员一个访问地址)
|
||
struct GroupCardView: View {
|
||
let group: GroupItem
|
||
/// 组内全部通道(统计用)
|
||
let members: [TunnelItem]
|
||
/// 当前搜索 / 筛选下显示的成员
|
||
let visible: [TunnelItem]
|
||
@ObservedObject var vm: PortRelayViewModel
|
||
let onEdit: () -> Void
|
||
/// 在该组里开放新的地址(打开添加通道表单)
|
||
let onAddMember: () -> Void
|
||
let onInvite: () -> Void
|
||
let onViewClients: (Int) -> Void
|
||
let onEditMember: (TunnelItem) -> Void
|
||
let onInviteMember: (TunnelItem) -> Void
|
||
let onMemberClients: (TunnelItem) -> Void
|
||
|
||
@State private var showDeleteDialog = false
|
||
@State private var memberToDelete: TunnelItem?
|
||
@State private var cardWidth: CGFloat = 0
|
||
|
||
var enabledMembers: [TunnelItem] { members.filter { $0.enabled } }
|
||
var anyEnabled: Bool { !enabledMembers.isEmpty }
|
||
var showsTraffic: Bool { cardWidth == 0 || cardWidth >= 700 }
|
||
var rateIn: Int64 { enabledMembers.reduce(0) { $0 + ($1.metrics?.rate_in ?? 0) } }
|
||
var rateOut: Int64 { enabledMembers.reduce(0) { $0 + ($1.metrics?.rate_out ?? 0) } }
|
||
var groupLeases: [DynamicLeaseItem] { group.metrics?.dynamic_leases ?? [] }
|
||
var groupPending: [AccessRequestItem] { group.metrics?.pending ?? [] }
|
||
|
||
var statusBadge: (text: String, color: Color) {
|
||
if members.isEmpty { return ("暂无通道", .secondary) }
|
||
let on = enabledMembers.count
|
||
if on == 0 { return ("已暂停", .secondary) }
|
||
if enabledMembers.contains(where: { $0.metrics?.status == "error" }) { return ("部分异常", .red) }
|
||
if enabledMembers.contains(where: { $0.metrics?.status == "offline" }) { return ("等待连接服务器", .orange) }
|
||
if on < members.count { return ("\(on)/\(members.count) 运行中", .orange) }
|
||
return ("运行中", .green)
|
||
}
|
||
|
||
var portRange: String? {
|
||
let ports = members.map { $0.listen_port }.sorted()
|
||
guard let lo = ports.first, let hi = ports.last else { return nil }
|
||
let contiguous = hi - lo + 1 == ports.count
|
||
if lo == hi { return "\(lo)" }
|
||
return contiguous ? "\(lo)–\(hi)" : ports.map(String.init).joined(separator: ", ")
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack(alignment: .top, spacing: 20) {
|
||
VStack(alignment: .leading, spacing: 9) {
|
||
HStack(alignment: .center, spacing: 8) {
|
||
Image(systemName: "square.stack.3d.up.fill")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.accentColor)
|
||
Text(verbatim: group.name)
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.layoutPriority(1)
|
||
if let owner = group.owner, !owner.isEmpty {
|
||
ownerChip(owner)
|
||
}
|
||
cardCapsule(nil, statusBadge.text, statusBadge.color)
|
||
accessTag
|
||
}
|
||
|
||
HStack(spacing: 8) {
|
||
Text(verbatim: "\(members.count) 个地址")
|
||
.font(.system(size: 12, weight: .medium))
|
||
.foregroundColor(.secondary)
|
||
if let range = portRange {
|
||
Text(verbatim: "\(vm.publicHost):\(range)")
|
||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||
.foregroundColor(.accentColor)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
if !members.isEmpty {
|
||
Menu {
|
||
Button(action: { copyToPasteboard(mappingText) }) {
|
||
Label("复制全部访问地址(含目标)", systemImage: "list.bullet")
|
||
}
|
||
Button(action: { copyToPasteboard(members.map { "\(vm.publicHost):\($0.listen_port)" }.joined(separator: "\n")) }) {
|
||
Label("仅复制访问地址", systemImage: "network")
|
||
}
|
||
} label: {
|
||
HStack(spacing: 3) {
|
||
Image(systemName: "doc.on.doc")
|
||
.font(.system(size: 10))
|
||
Text("一键复制")
|
||
.font(.system(size: 11))
|
||
}
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.fixedSize()
|
||
}
|
||
}
|
||
|
||
FlowLayout(spacing: 14, lineSpacing: 7) {
|
||
lifecycleItems
|
||
badgeItems
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
if anyEnabled && showsTraffic {
|
||
VStack(alignment: .trailing, spacing: 6) {
|
||
HStack(spacing: 10) {
|
||
RateLegend(down: rateOut, up: rateIn)
|
||
Spacer(minLength: 0)
|
||
groupToggle
|
||
}
|
||
InlineTrafficChart(series: group.rate_history)
|
||
}
|
||
.frame(width: 250)
|
||
} else {
|
||
groupToggle
|
||
.fixedSize()
|
||
}
|
||
}
|
||
|
||
if !visible.isEmpty {
|
||
VStack(spacing: 0) {
|
||
ForEach(visible) { t in
|
||
Divider()
|
||
memberRow(t)
|
||
}
|
||
}
|
||
if visible.count < members.count {
|
||
Text(verbatim: "已按搜索 / 筛选隐藏 \(members.count - visible.count) 个地址")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
} else if members.isEmpty {
|
||
Text("组内暂无通道")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
Button(action: onAddMember) {
|
||
Label("添加地址", systemImage: "plus")
|
||
.font(.system(size: 11))
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.help("在该组里开放新的主机或端口,组的邀请和授权自动覆盖新地址")
|
||
|
||
Divider()
|
||
|
||
HStack(spacing: 12) {
|
||
let session = enabledMembers.reduce(Int64(0)) { $0 + ($1.metrics?.bytes_in ?? 0) + ($1.metrics?.bytes_out ?? 0) }
|
||
let history = members.reduce(Int64(0)) { $0 + ($1.historical_bytes_in ?? 0) + ($1.historical_bytes_out ?? 0) } + session
|
||
HStack(spacing: 16) {
|
||
cardFooterStat("连接", "\(enabledMembers.reduce(0) { $0 + ($1.metrics?.active_conns ?? 0) })")
|
||
cardFooterStat("本次", formatBytes(session))
|
||
cardFooterStat("历史", formatBytes(history))
|
||
if !showsTraffic && anyEnabled {
|
||
cardFooterStat("速率", "↑\(formatRate(rateIn)) ↓\(formatRate(rateOut))")
|
||
}
|
||
}
|
||
.lineLimit(1)
|
||
|
||
Spacer(minLength: 8)
|
||
|
||
HStack(spacing: 6) {
|
||
Button(action: onInvite) {
|
||
Label("邀请", systemImage: "square.and.arrow.up")
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.small)
|
||
.disabled(!anyEnabled)
|
||
.help(anyEnabled ? "生成整组的邀请链接:同事授权一次即可访问组内全部地址" : "请先开启通道组")
|
||
|
||
Button("连接与授权") { onViewClients(0) }
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
|
||
Button("编辑", action: onEdit)
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
|
||
Button("删除", role: .destructive) { showDeleteDialog = 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(anyEnabled || members.isEmpty ? 1.0 : 0.6)
|
||
.confirmationDialog(Text(verbatim: "删除通道组「\(group.name)」?"), isPresented: $showDeleteDialog, titleVisibility: .visible) {
|
||
Button("解散组,保留 \(members.count) 个通道") {
|
||
Task { await vm.deleteGroup(id: group.id, deleteMembers: false) }
|
||
}
|
||
Button("删除组和组内全部通道", role: .destructive) {
|
||
Task { await vm.deleteGroup(id: group.id, deleteMembers: true) }
|
||
}
|
||
Button("取消", role: .cancel) {}
|
||
} message: {
|
||
Text("组的邀请链接与授权会立即失效。解散后组内通道变回独立通道;删除则一并注销所有端口并断开连接。")
|
||
}
|
||
.alert(Text(verbatim: "删除「\(memberToDelete?.name ?? "")」?"), isPresented: Binding(
|
||
get: { memberToDelete != nil },
|
||
set: { if !$0 { memberToDelete = nil } }
|
||
)) {
|
||
Button("取消", role: .cancel) {}
|
||
Button("确认删除", role: .destructive) {
|
||
if let t = memberToDelete { Task { await vm.deleteTunnel(id: t.id) } }
|
||
}
|
||
} message: {
|
||
Text(verbatim: "将注销 \(vm.publicHost):\(memberToDelete?.listen_port ?? 0) 并断开它的连接,组内其它地址不受影响。")
|
||
}
|
||
}
|
||
|
||
// MARK: Parts
|
||
|
||
var mappingText: String {
|
||
members.map { "\(vm.publicHost):\($0.listen_port) → \($0.target)" }.joined(separator: "\n")
|
||
}
|
||
|
||
var groupToggle: some View {
|
||
Toggle("", isOn: Binding(
|
||
get: { anyEnabled },
|
||
set: { on in Task { await vm.toggleGroup(id: group.id, enabled: on) } }
|
||
))
|
||
.toggleStyle(.switch)
|
||
.controlSize(.mini)
|
||
.labelsHidden()
|
||
.disabled(members.isEmpty)
|
||
.help(anyEnabled ? "关闭整组" : "开启整组")
|
||
}
|
||
|
||
var accessTag: some View {
|
||
let lists = Set(members.map { $0.whitelist.sorted().joined(separator: ",") })
|
||
return Group {
|
||
if members.isEmpty {
|
||
EmptyView()
|
||
} else if members.contains(where: { $0.whitelist.contains("*") }) {
|
||
cardCapsule("exclamationmark.triangle.fill", "含全部放通 *", .red)
|
||
} else if lists.count > 1 {
|
||
cardCapsule("slider.horizontal.3", "成员访问方式不同", .secondary)
|
||
} else if members[0].whitelist.isEmpty {
|
||
cardCapsule("lock.fill", "仅授权链接", .green)
|
||
} else {
|
||
cardCapsule("network", "白名单 + 授权链接", .secondary)
|
||
.help("固定白名单:" + members[0].whitelist.joined(separator: ", "))
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
var lifecycleItems: some View {
|
||
if anyEnabled {
|
||
let started = enabledMembers.compactMap { $0.started_at }.min()
|
||
cardLifecycleItem("play.circle", "开启 \(formatDateTimeShort(started))")
|
||
if let first = enabledMembers.compactMap({ $0.first_connected_at }).min() {
|
||
cardLifecycleItem("person.wave.2", "首次连接 \(formatDateTimeShort(first))")
|
||
} else {
|
||
cardLifecycleItem("person.wave.2", "尚无连接")
|
||
}
|
||
let connected = enabledMembers.map { $0.activity?.connected_sec ?? $0.connected_sec ?? 0 }.max() ?? 0
|
||
cardLifecycleItem("stopwatch", "实际连接 \(formatSecondsToDuration(connected))", color: .primary, weight: .medium)
|
||
.help("组内连接在线时间最长的一个地址的累计时长")
|
||
let acts = enabledMembers.compactMap { $0.activity }
|
||
if acts.contains(where: { $0.in_use }) {
|
||
cardLifecycleItem("circle.fill", "使用中", color: .green, weight: .medium, iconSize: 7)
|
||
.help("组内有在线连接、有效授权、待审批申请或可用的邀请链接")
|
||
} else if let act = acts.min(by: { ($0.auto_close_at ?? "~") < ($1.auto_close_at ?? "~") }) {
|
||
if let closeAt = act.auto_close_at {
|
||
let left = max(0, -secondsSince(closeAt))
|
||
cardLifecycleItem("moon.zzz", "已闲置 \(formatSecondsToDuration(secondsSince(act.idle_since))),\(formatSecondsToDuration(left)) 后整组自动关闭",
|
||
color: left < 300 ? .orange : .secondary)
|
||
} else {
|
||
cardLifecycleItem("moon.zzz", "已闲置 \(formatSecondsToDuration(secondsSince(act.idle_since)))(不自动关闭)")
|
||
}
|
||
}
|
||
if let exp = enabledMembers.compactMap({ $0.expires_at }).min() {
|
||
let remaining = formatRemainingTime(expiresAt: exp)
|
||
cardLifecycleItem("hourglass", "最长开启至 \(formatDateTimeShort(exp))", color: remaining.isExpiringSoon ? .orange : .secondary)
|
||
}
|
||
} else if let m = members.first {
|
||
if let reason = m.close_reason, !reason.isEmpty {
|
||
cardLifecycleItem("power", "关闭原因:\(reason)")
|
||
}
|
||
let idle = m.effective_idle_close_minutes ?? 30
|
||
cardLifecycleItem("moon.zzz", "闲置自动关闭:" + (idle > 0 ? idleCloseTitle(idle) : "关"))
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
var badgeItems: some View {
|
||
if !groupPending.isEmpty {
|
||
Button(action: { onViewClients(2) }) {
|
||
cardCapsule("bell.badge.fill", "待审批 \(groupPending.count)", .orange)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("有同事申请访问整组,点击审批")
|
||
}
|
||
if !groupLeases.isEmpty {
|
||
Button(action: { onViewClients(2) }) {
|
||
cardCapsule("person.crop.circle.badge.checkmark", "已授权 \(groupLeases.count) 台", .green)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("查看与撤销整组的授权")
|
||
}
|
||
}
|
||
|
||
func memberRow(_ t: TunnelItem) -> some View {
|
||
let m = t.metrics
|
||
let dot: Color = !t.enabled ? .secondary.opacity(0.4)
|
||
: (m?.status == "online" ? .green : (m?.status == "error" ? .red : .orange))
|
||
let own = (m?.dynamic_leases?.count ?? 0) + (m?.pending?.count ?? 0)
|
||
return HStack(spacing: 8) {
|
||
Circle()
|
||
.fill(dot)
|
||
.frame(width: 7, height: 7)
|
||
.help(t.enabled ? (m?.error ?? "运行中") : "已关闭")
|
||
Text(verbatim: "\(vm.publicHost):\(t.listen_port)")
|
||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||
.foregroundColor(t.enabled ? .accentColor : .secondary)
|
||
Image(systemName: "arrow.right")
|
||
.font(.system(size: 9))
|
||
.foregroundColor(.secondary)
|
||
Text(verbatim: t.target)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
Menu {
|
||
ForEach(connectSnippets(t, host: vm.publicHost), id: \.cmd) { item in
|
||
Button(action: { copyToPasteboard(item.cmd) }) {
|
||
Label(item.title, systemImage: item.icon)
|
||
}
|
||
}
|
||
} label: {
|
||
Image(systemName: "doc.on.doc")
|
||
.font(.system(size: 10))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.menuIndicator(.hidden)
|
||
.fixedSize()
|
||
.help("复制连接命令或访问地址")
|
||
// 批量创建的成员按“组名 (主机)”自动命名,不重复显示;加入组的已有通道保留原名
|
||
if !t.name.hasPrefix(group.name + " (") {
|
||
Text(verbatim: t.name)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
if own > 0 {
|
||
Button(action: { onMemberClients(t) }) {
|
||
cardCapsule("person.crop.circle.badge.checkmark", "单独授权 \(own)", .green)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("该地址另有单独的授权或申请")
|
||
}
|
||
Spacer(minLength: 8)
|
||
if let n = m?.active_conns, n > 0 {
|
||
Text(verbatim: "\(n) 连接")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.monospacedDigit()
|
||
}
|
||
if t.enabled {
|
||
RateText(up: m?.rate_in ?? 0, down: m?.rate_out ?? 0, size: 10)
|
||
}
|
||
Toggle("", isOn: Binding(
|
||
get: { t.enabled },
|
||
set: { on in Task { await vm.toggleTunnel(id: t.id, enabled: on) } }
|
||
))
|
||
.toggleStyle(.switch)
|
||
.controlSize(.mini)
|
||
.labelsHidden()
|
||
.help(t.enabled ? "单独关闭这个地址" : "单独开启这个地址")
|
||
Menu {
|
||
Button("单独邀请这个地址…") { onInviteMember(t) }
|
||
.disabled(!t.enabled)
|
||
Button("连接与授权") { onMemberClients(t) }
|
||
Button("编辑…") { onEditMember(t) }
|
||
Button("移出通道组") { Task { _ = await vm.moveTunnel(id: t.id, toGroup: "") } }
|
||
Divider()
|
||
Button("删除…", role: .destructive) { memberToDelete = t }
|
||
} label: {
|
||
Image(systemName: "ellipsis.circle")
|
||
.font(.system(size: 13))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.menuIndicator(.hidden)
|
||
.fixedSize()
|
||
}
|
||
.padding(.vertical, 6)
|
||
.opacity(t.enabled ? 1 : 0.65)
|
||
}
|
||
|
||
func ownerChip(_ owner: String) -> some View {
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 卡片通用小部件(通道卡片与通道组卡片共用)
|
||
|
||
func cardCapsule(_ icon: String?, _ text: String, _ color: Color) -> some View {
|
||
HStack(spacing: 3) {
|
||
if let icon = icon {
|
||
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)
|
||
}
|
||
|
||
func cardFooterStat(_ 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 cardLifecycleItem(_ 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)
|
||
}
|
||
|
||
// MARK: - Client Details & Historical Records Sheet
|
||
|
||
/// 客户明细弹窗的目标(通道或通道组 + 初始页签),整体作为 sheet(item:) 的参数
|
||
struct ClientsSheetTarget: Identifiable {
|
||
let scope: AccessScope
|
||
let tab: Int
|
||
var id: String { "\(scope.key)#\(tab)" }
|
||
|
||
init(scope: AccessScope, tab: Int) {
|
||
self.scope = scope
|
||
self.tab = tab
|
||
}
|
||
|
||
init(tunnel: TunnelItem, tab: Int) {
|
||
self.init(scope: .tunnel(tunnel.id), tab: tab)
|
||
}
|
||
}
|
||
|
||
struct ClientDetailsSheet: View {
|
||
@ObservedObject var vm: PortRelayViewModel
|
||
let scope: AccessScope
|
||
@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
|
||
/// 通道组:在线连接与接入记录按设备(来源 IP)或按组内地址查看
|
||
@State private var byAddress: Bool = false
|
||
@State private var collapsedOnlineAddrs: Set<String> = []
|
||
@State private var expandedHistoryAddrs: Set<String> = []
|
||
|
||
init(vm: PortRelayViewModel, scope: AccessScope, initialTab: Int = 0, byAddress: Bool = false, isPresented: Binding<Bool>) {
|
||
_vm = ObservedObject(wrappedValue: vm)
|
||
self.scope = scope
|
||
_isPresented = isPresented
|
||
_selectedTab = State(initialValue: initialTab)
|
||
_byAddress = State(initialValue: byAddress)
|
||
}
|
||
|
||
init(vm: PortRelayViewModel, tunnel: TunnelItem, initialTab: Int = 0, isPresented: Binding<Bool>) {
|
||
self.init(vm: vm, scope: .tunnel(tunnel.id), initialTab: initialTab, isPresented: isPresented)
|
||
}
|
||
|
||
// 弹窗打开期间始终读取最新数据(授权列表每秒刷新)
|
||
var members: [TunnelItem] { vm.scopeTunnels(scope) }
|
||
var memberIDs: Set<String> { Set(members.map { $0.id }) }
|
||
var leases: [DynamicLeaseItem] { vm.scopeLeases(scope) }
|
||
var invites: [InviteItem] { vm.scopeInvites(scope) }
|
||
var pendingRequests: [AccessRequestItem] { vm.scopePending(scope) }
|
||
|
||
var subtitle: String {
|
||
if scope.isGroup {
|
||
let ports = members.map { $0.listen_port }
|
||
guard let lo = ports.min(), let hi = ports.max() else { return "组内暂无通道" }
|
||
return "\(members.count) 个地址 · \(vm.publicHost):\(lo)" + (hi > lo ? "–\(hi)" : "")
|
||
}
|
||
guard let t = members.first else { return "" }
|
||
return "\(vm.publicHost):\(t.listen_port) → \(t.target)"
|
||
}
|
||
|
||
/// 通道组:设备访问了组内哪些地址(" · 访问 10.0.1.11、10.0.1.12");组内端口都相同时只写主机
|
||
func addressesSummary(_ tunnelIDs: Set<String>) -> String {
|
||
guard scope.isGroup else { return "" }
|
||
let used = members.filter { tunnelIDs.contains($0.id) }
|
||
guard !used.isEmpty else { return "" }
|
||
let samePort = Set(members.map { portOf($0.target) }).count == 1
|
||
let labels: [String] = used.map { samePort ? hostOf($0.target) : $0.target }
|
||
if labels.count > 3 { return " · 访问 \(labels.count) 个地址" }
|
||
return " · 访问 " + labels.joined(separator: "、")
|
||
}
|
||
|
||
/// 有在线连接 / 接入记录的组内地址数
|
||
var addressesOnline: Int { Set(activeClientsForTunnel.map { $0.tunnel_id }).intersection(memberIDs).count }
|
||
var addressesWithHistory: Int { Set(historyRecords.map { $0.tunnel_id }).intersection(memberIDs).count }
|
||
|
||
var dimensionSummary: String {
|
||
if selectedTab == 0 {
|
||
return byAddress ? "\(addressesOnline)/\(members.count) 个地址正在使用"
|
||
: "\(clientGroups.count) 台设备 · \(activeClientsForTunnel.count) 个连接"
|
||
}
|
||
return byAddress ? "\(addressesWithHistory)/\(members.count) 个地址有接入记录"
|
||
: "\(historyGroups.count) 台设备 · \(historyRecords.count) 次连接"
|
||
}
|
||
|
||
/// 通道组的在线 / 接入记录可以按设备或按地址看
|
||
var dimensionBar: some View {
|
||
HStack(spacing: 8) {
|
||
Text(verbatim: dimensionSummary)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
Spacer()
|
||
Picker("", selection: $byAddress) {
|
||
Text("按设备").tag(false)
|
||
Text("按地址").tag(true)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.labelsHidden()
|
||
.fixedSize()
|
||
}
|
||
}
|
||
|
||
/// 通道组里每个连接经过的目标("→ 10.0.1.11:22")
|
||
func targetLabel(_ tunnelID: String) -> String? {
|
||
guard scope.isGroup, let t = members.first(where: { $0.id == tunnelID }) else { return nil }
|
||
return "→ \(t.target)"
|
||
}
|
||
|
||
@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] {
|
||
let ids = memberIDs
|
||
return vm.activeConns.filter { ids.contains($0.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: "\(vm.scopeTitle(scope)) · 连接与授权")
|
||
.font(.system(size: 15, weight: .bold))
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
Text(verbatim: subtitle)
|
||
.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)
|
||
|
||
if scope.isGroup && selectedTab != 2 {
|
||
dimensionBar
|
||
}
|
||
|
||
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) {
|
||
if scope.isGroup && byAddress {
|
||
ForEach(members) { t in
|
||
onlineAddressRow(t)
|
||
}
|
||
} else {
|
||
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) {
|
||
if scope.isGroup && byAddress {
|
||
ForEach(members) { t in
|
||
historyAddressRow(t)
|
||
}
|
||
} else {
|
||
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) 次连接")
|
||
if let n = leaseName(g.ip) {
|
||
TagView("已授权 · \(n)", color: .green)
|
||
}
|
||
}
|
||
Text(verbatim: "最近 \(formatDateTimeShort(g.lastSeen)) · 首次 \(formatDateTimeShort(g.firstSeen)) · 累计 \(formatSecondsToDuration(g.totalDuration))"
|
||
+ addressesSummary(Set(g.records.map { $0.tunnel_id })))
|
||
.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)
|
||
if let to = targetLabel(rec.tunnel_id) {
|
||
Text(verbatim: to)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
.truncationMode(.middle)
|
||
}
|
||
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()
|
||
}
|
||
|
||
// MARK: 按地址(通道组)
|
||
|
||
func addressLabel(_ t: TunnelItem) -> some View {
|
||
HStack(spacing: 6) {
|
||
Text(verbatim: "\(vm.publicHost):\(t.listen_port)")
|
||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||
.foregroundColor(.accentColor)
|
||
Image(systemName: "arrow.right")
|
||
.font(.system(size: 9))
|
||
.foregroundColor(.secondary)
|
||
Text(verbatim: t.target)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.lineLimit(1)
|
||
}
|
||
|
||
/// 设备 IP + 授权时填写的姓名
|
||
func deviceLabel(_ ip: String) -> some View {
|
||
HStack(spacing: 6) {
|
||
Text(verbatim: ip)
|
||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
if let n = leaseName(ip) {
|
||
TagView(n, color: .green)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 组内一个地址:此刻哪些设备连着它
|
||
@ViewBuilder
|
||
func onlineAddressRow(_ t: TunnelItem) -> some View {
|
||
let conns: [ActiveClientInfo] = activeClientsForTunnel.filter { $0.tunnel_id == t.id }
|
||
let devices: [ClientIPGroup] = Dictionary(grouping: conns) { hostOf($0.client_ip) }
|
||
.map { ClientIPGroup(ip: $0.key, conns: $0.value) }
|
||
.sorted { $0.ip < $1.ip }
|
||
let all = ClientIPGroup(ip: t.id, conns: conns)
|
||
let expanded = !conns.isEmpty && !collapsedOnlineAddrs.contains(t.id)
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
HStack(spacing: 8) {
|
||
chevron(expanded, visible: !conns.isEmpty)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(spacing: 6) {
|
||
addressLabel(t)
|
||
TagView(conns.isEmpty ? "无连接" : "\(devices.count) 台设备 · \(conns.count) 个连接")
|
||
}
|
||
Text(verbatim: conns.isEmpty ? (t.enabled ? "暂无设备连接" : "已关闭")
|
||
: "最早接入 \(formatDateTimeShort(all.firstConnectedAt)) · ↑ \(formatBytes(all.bytesIn)) ↓ \(formatBytes(all.bytesOut))")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
.layoutPriority(1)
|
||
Spacer(minLength: 8)
|
||
if !conns.isEmpty {
|
||
RateText(up: all.rateIn, down: all.rateOut)
|
||
Button("全部断开", role: .destructive) {
|
||
Task { await vm.killClients(connIds: conns.map { $0.conn_id }) }
|
||
}
|
||
.controlSize(.small)
|
||
.frame(width: 76, alignment: .trailing)
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 10)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { if !conns.isEmpty { toggle(t.id, in: &collapsedOnlineAddrs) } }
|
||
|
||
if expanded {
|
||
Divider().padding(.leading, 32).padding(.trailing, 12)
|
||
VStack(spacing: 0) {
|
||
ForEach(devices) { d in
|
||
HStack(spacing: 8) {
|
||
Color.clear.frame(width: 12)
|
||
deviceLabel(d.ip)
|
||
.frame(width: 200, alignment: .leading)
|
||
Text(verbatim: "\(d.conns.count) 个连接 · 接入 \(formatTimeHHmm(d.firstConnectedAt))")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
Spacer(minLength: 8)
|
||
RateText(up: d.rateIn, down: d.rateOut, size: 10)
|
||
Button("断开") {
|
||
Task { await vm.killClients(connIds: d.conns.map { $0.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()
|
||
.opacity(conns.isEmpty ? 0.6 : 1)
|
||
}
|
||
|
||
/// 组内一个地址:谁、什么时候访问过它
|
||
@ViewBuilder
|
||
func historyAddressRow(_ t: TunnelItem) -> some View {
|
||
let records: [ClientHistoryRecord] = historyRecords.filter { $0.tunnel_id == t.id }.sorted { $0.connected_at > $1.connected_at }
|
||
let summary = HistoryIPGroup(ip: t.id, records: records)
|
||
let devices = Set(records.map { hostOf($0.client_ip) }).count
|
||
let expanded = !records.isEmpty && isExpanded(t.id, in: expandedHistoryAddrs, groupCount: addressesWithHistory)
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
HStack(spacing: 8) {
|
||
chevron(expanded, visible: !records.isEmpty)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(spacing: 6) {
|
||
addressLabel(t)
|
||
TagView(records.isEmpty ? "暂无记录" : "\(devices) 台设备 · \(records.count) 次连接")
|
||
}
|
||
if !records.isEmpty {
|
||
Text(verbatim: "最近 \(formatDateTimeShort(summary.lastSeen)) · 首次 \(formatDateTimeShort(summary.firstSeen)) · 累计 \(formatSecondsToDuration(summary.totalDuration))")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.layoutPriority(1)
|
||
Spacer(minLength: 8)
|
||
if !records.isEmpty {
|
||
VStack(alignment: .trailing, spacing: 3) {
|
||
Text(verbatim: formatBytes(summary.bytesIn + summary.bytesOut))
|
||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||
Text(verbatim: "↑ \(formatBytes(summary.bytesIn)) ↓ \(formatBytes(summary.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 { if !records.isEmpty { toggle(t.id, in: &expandedHistoryAddrs) } }
|
||
|
||
if expanded {
|
||
Divider().padding(.leading, 32).padding(.trailing, 12)
|
||
VStack(spacing: 0) {
|
||
ForEach(records) { rec in
|
||
HStack(spacing: 8) {
|
||
Color.clear.frame(width: 12)
|
||
deviceLabel(hostOf(rec.client_ip))
|
||
.frame(width: 200, 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)
|
||
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()
|
||
.opacity(records.isEmpty ? 0.6 : 1)
|
||
}
|
||
|
||
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))"
|
||
+ addressesSummary(Set(g.conns.map { $0.tunnel_id })))
|
||
.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)
|
||
// 通道组里更关心连到了哪个目标,来源端口让位给目标地址
|
||
if !scope.isGroup {
|
||
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)
|
||
.fixedSize()
|
||
if let to = targetLabel(c.tunnel_id) {
|
||
Text(verbatim: to)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
.truncationMode(.middle)
|
||
}
|
||
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: scope.isGroup
|
||
? "组内所有地址共用邀请与授权:同事授权一次即可访问整组,计时从首次连上任一地址开始"
|
||
: "同事打开邀请链接并填写姓名后获得访问权限;可访问时长与是否需要审批在每个邀请中设置")
|
||
} 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(!vm.scopeEnabled(scope))
|
||
.help(vm.scopeEnabled(scope) ? "生成新的邀请链接" : "请先开启通道")
|
||
}
|
||
|
||
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 { memberIDs.contains($0.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(scope: scope, 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(scope: scope, 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, scope: scope, autoGenerate: false, isPresented: $showingInviteSheet)
|
||
}
|
||
}
|
||
|
||
func decide(_ r: AccessRequestItem, approve: Bool) {
|
||
isRevoking = true
|
||
Task {
|
||
revokeError = await vm.decideRequest(scope: scope, requestId: r.id, approve: approve)
|
||
isRevoking = false
|
||
}
|
||
}
|
||
|
||
func revoke(ip: String?) {
|
||
isRevoking = true
|
||
revokeError = nil
|
||
Task {
|
||
revokeError = await vm.revokeLease(scope: scope, ip: ip)
|
||
isRevoking = false
|
||
}
|
||
}
|
||
|
||
func loadHistory() {
|
||
isLoadingHistory = true
|
||
Task {
|
||
let list = await vm.fetchHistory(scope: scope)
|
||
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)
|
||
|
||
/// 添加通道表单里的一行目标:一台主机 + 一个或多个端口("22, 3306"、"8080-8082")。
|
||
/// 主机格也接受逗号分隔的几台、末段范围(10.0.1.11-13)或自带端口的 主机:端口。
|
||
struct TargetRow: Identifiable, Equatable {
|
||
let id = UUID()
|
||
var host: String = ""
|
||
var ports: String = ""
|
||
|
||
static let separators: Set<Character> = [" ", ",", ",", "、", ";", ";", "\n", "\r", "\t"]
|
||
|
||
static func items(_ s: String) -> [String] {
|
||
s.split(whereSeparator: { separators.contains($0) }).map(String.init)
|
||
}
|
||
|
||
/// "10.0.1.11:22" / "[fd00::1]:22" → (主机, 端口);不带端口(含裸 IPv6)返回 nil
|
||
static func splitHostPort(_ item: String) -> (String, String)? {
|
||
if item.hasPrefix("["), let close = item.firstIndex(of: "]") {
|
||
let rest = item[item.index(after: close)...]
|
||
guard rest.hasPrefix(":"), rest.count > 1 else { return nil }
|
||
return (String(item[item.index(after: item.startIndex)..<close]), String(rest.dropFirst()))
|
||
}
|
||
let parts = item.split(separator: ":", omittingEmptySubsequences: false)
|
||
guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { return nil }
|
||
return (String(parts[0]), String(parts[1]))
|
||
}
|
||
|
||
/// 粘贴进来的一串目标拆成多行:每项是 "主机" 或 "主机:端口",同一主机的端口并到一行
|
||
static func parse(_ text: String, defaultPorts: String) -> [TargetRow] {
|
||
var out: [TargetRow] = []
|
||
for item in items(text) {
|
||
let (host, port) = splitHostPort(item) ?? (item, "")
|
||
if let i = out.firstIndex(where: { $0.host == host }) {
|
||
if !port.isEmpty && !items(out[i].ports).contains(port) {
|
||
out[i].ports = out[i].ports.isEmpty ? port : out[i].ports + ", " + port
|
||
}
|
||
} else {
|
||
out.append(TargetRow(host: host, ports: port.isEmpty ? defaultPorts : port))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
/// 拼成 "主机:端口, …" 交给服务端展开;还没填完整的行先跳过
|
||
static func compose(_ rows: [TargetRow]) -> String {
|
||
var out: [String] = []
|
||
for r in rows {
|
||
let ports = items(r.ports)
|
||
for h in items(r.host) {
|
||
if splitHostPort(h) != nil {
|
||
out.append(h)
|
||
continue
|
||
}
|
||
guard !ports.isEmpty else { continue }
|
||
let host = h.contains(":") && !h.hasPrefix("[") ? "[\(h)]" : h
|
||
for p in ports {
|
||
out.append("\(host):\(p)")
|
||
}
|
||
}
|
||
}
|
||
return out.joined(separator: ", ")
|
||
}
|
||
}
|
||
|
||
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 rows: [TargetRow]
|
||
@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 groupChoice: GroupChoice
|
||
@State private var newGroupName: String = ""
|
||
/// 将创建的通道(服务端预览,含自动分配的访问端口)
|
||
@State private var plan: [PlannedTunnel] = []
|
||
@State private var planError: String? = nil
|
||
/// 连通性测试结果,按目标
|
||
@State private var probes: [String: ProbeOutcome] = [:]
|
||
@State private var isProbing: Bool = false
|
||
@State private var isSaving: Bool = false
|
||
@State private var errorMessage: String? = nil
|
||
@FocusState private var focusedRow: UUID?
|
||
|
||
/// 新建完成后打开邀请:独立通道,或新建的通道组。加入已有组时不需要——组的授权自动覆盖新地址
|
||
var onCreated: ((AccessScope) -> Void)? = nil
|
||
|
||
enum AccessMode: String, CaseIterable {
|
||
case linkOnly, specific, all
|
||
}
|
||
|
||
/// 通道组选项。新建时多个地址自动新建组(以名称为组名);编辑时可以新建组并移入
|
||
enum GroupChoice: Hashable {
|
||
case none
|
||
case new
|
||
case existing(String)
|
||
}
|
||
|
||
var isEdit: Bool { tunnel != nil }
|
||
|
||
/// 表单字段在创建时就用通道数据填好,不依赖 onAppear(避免第一帧显示空表单)。
|
||
/// groupId:新建时默认加入的通道组(通道组卡片上的「添加地址」)
|
||
init(vm: PortRelayViewModel, tunnel: TunnelItem?, isPresented: Binding<Bool>, groupId: String? = nil,
|
||
draftName: String = "", draftRows: [(String, String)] = [], onCreated: ((AccessScope) -> 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 ?? draftName)
|
||
_owner = State(initialValue: tunnel.map { $0.owner ?? "" } ?? NSUserName())
|
||
var initialRows = draftRows.map { TargetRow(host: $0.0, ports: $0.1) }
|
||
if let t = tunnel {
|
||
let (h, p) = TargetRow.splitHostPort(t.target) ?? (t.target, "")
|
||
initialRows = [TargetRow(host: h, ports: p)]
|
||
}
|
||
_rows = State(initialValue: initialRows.isEmpty ? [TargetRow()] : initialRows)
|
||
_portString = State(initialValue: tunnel.map { "\($0.listen_port)" } ?? "")
|
||
// 最长开启时长:新建默认不限制(主要依靠闲置自动关闭);duration_minutes 为 0 时 JSON 中会省略
|
||
_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: ", ") : "")
|
||
let gid = tunnel?.group_id ?? groupId ?? ""
|
||
_groupChoice = State(initialValue: gid.isEmpty ? .none : .existing(gid))
|
||
}
|
||
|
||
// MARK: Derived state
|
||
|
||
var composedTarget: String { TargetRow.compose(rows) }
|
||
|
||
/// 会创建多个通道(自动放进通道组)
|
||
var isBatch: Bool { !isEdit && plan.count > 1 }
|
||
|
||
var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
|
||
|
||
/// 新建通道组的名称:新建时就是名称;编辑时可另填,没填时与通道名称相同
|
||
var resolvedGroupName: String {
|
||
let n = newGroupName.trimmingCharacters(in: .whitespaces)
|
||
return isEdit && !n.isEmpty ? n : trimmedName
|
||
}
|
||
|
||
var existingGroupName: String? {
|
||
if case .existing(let id) = groupChoice { return vm.group(id)?.name }
|
||
return nil
|
||
}
|
||
|
||
var nameTitle: String {
|
||
isBatch && groupChoice == .new ? "通道组名称" : "通道名称"
|
||
}
|
||
|
||
var namePlaceholder: String {
|
||
if !isEdit, let g = existingGroupName {
|
||
return "可不填,默认「\(g) (主机)」"
|
||
}
|
||
return isBatch ? "例如:订单服务" : "例如:MySQL 实习库"
|
||
}
|
||
|
||
var canSave: Bool {
|
||
if isSaving { return false }
|
||
if isEdit {
|
||
return !trimmedName.isEmpty && !composedTarget.isEmpty && (groupChoice != .new || !resolvedGroupName.isEmpty)
|
||
}
|
||
if plan.isEmpty || planError != nil { return false }
|
||
return !trimmedName.isEmpty || existingGroupName != nil
|
||
}
|
||
|
||
func makeForm(port: Int, whitelist: [String]) -> TunnelForm {
|
||
var groupId = "", newGroup = ""
|
||
switch groupChoice {
|
||
case .none: break
|
||
case .new: newGroup = resolvedGroupName
|
||
case .existing(let id): groupId = id
|
||
}
|
||
return TunnelForm(
|
||
name: trimmedName, owner: owner.trimmingCharacters(in: .whitespaces),
|
||
target: composedTarget, port: port, whitelist: whitelist,
|
||
durationMinutes: durationMinutes, idleCloseMinutes: idleCloseMinutes, maxBandwidthMB: maxBandwidthMB,
|
||
groupId: groupId, newGroup: newGroup)
|
||
}
|
||
|
||
// MARK: Body
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
Text(isEdit ? "编辑通道" : "添加通道")
|
||
.font(.system(size: 15, weight: .bold))
|
||
|
||
HStack(spacing: 12) {
|
||
field(nameTitle) {
|
||
TextField(namePlaceholder, text: $name)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
field("关联责任人") {
|
||
TextField("例如:张三 / 数据组", text: $owner)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
}
|
||
|
||
targetSection
|
||
|
||
if isEdit {
|
||
field("对外监听端口 (范围 33001-34000)") {
|
||
HStack {
|
||
TextField("33660", text: $portString)
|
||
.textFieldStyle(.roundedBorder)
|
||
Button("自动分配") {
|
||
Task { portString = "\(await vm.recommendPort())" }
|
||
}
|
||
.controlSize(.small)
|
||
}
|
||
}
|
||
}
|
||
|
||
groupSection
|
||
closeSection
|
||
bandwidthSection
|
||
accessSection
|
||
|
||
if let err = errorMessage {
|
||
Text(verbatim: err)
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.red)
|
||
}
|
||
|
||
HStack {
|
||
Spacer()
|
||
Button("取消") { isPresented = false }
|
||
.controlSize(.small)
|
||
Button(isSaving ? "保存中…" : (isBatch ? "创建 \(plan.count) 个通道" : "保存")) { save() }
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.small)
|
||
.disabled(!canSave)
|
||
}
|
||
.padding(.top, 4)
|
||
}
|
||
.padding(20)
|
||
.frame(width: 520)
|
||
.task(id: "\(composedTarget)|\(trimmedName)|\(groupChoice)") { await refreshPlan() }
|
||
.onChange(of: composedTarget) { _ in probes = [:] }
|
||
.onChange(of: isBatch) { batch in
|
||
// 多个地址放进同一个通道组;只剩一个地址时回到不分组
|
||
if batch && groupChoice == .none { groupChoice = .new }
|
||
if !batch && !isEdit && groupChoice == .new { groupChoice = .none }
|
||
}
|
||
}
|
||
|
||
// MARK: Target list
|
||
|
||
var targetSection: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text(isEdit ? "目标服务 (内网主机与端口)" : "目标服务(每行一台主机,多个端口用逗号隔开)")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
if rows.count > 1 || !isEdit {
|
||
HStack(spacing: 8) {
|
||
Text("主机").frame(maxWidth: .infinity, alignment: .leading)
|
||
Text("端口").frame(width: 150, alignment: .leading)
|
||
Color.clear.frame(width: 18, height: 1)
|
||
}
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
if isEdit, let row = rows.first {
|
||
HStack(spacing: 8) {
|
||
targetRow(row)
|
||
probeButton
|
||
}
|
||
} else {
|
||
ForEach(rows) { row in
|
||
targetRow(row)
|
||
}
|
||
HStack(spacing: 10) {
|
||
Button {
|
||
addRow()
|
||
} label: {
|
||
Label("添加主机", systemImage: "plus")
|
||
.font(.system(size: 12))
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.help("可以一次粘贴多个 IP(每行一个,或用逗号分隔),会自动拆成多行")
|
||
Spacer()
|
||
probeButton
|
||
}
|
||
}
|
||
if isEdit {
|
||
if let r = probes[composedTarget] {
|
||
Text(verbatim: r.ok ? "连接成功 (\(r.detail))" : "连接失败: \(r.detail)")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(r.ok ? .green : .red)
|
||
}
|
||
} else if let err = planError {
|
||
Text(verbatim: err)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.red)
|
||
} else if !plan.isEmpty {
|
||
planList
|
||
}
|
||
}
|
||
}
|
||
|
||
var probeButton: some View {
|
||
Button(isProbing ? "测试中" : "连通性测试") { probe() }
|
||
.disabled(composedTarget.isEmpty || isProbing || (!isEdit && (plan.isEmpty || planError != nil)))
|
||
.controlSize(.small)
|
||
}
|
||
|
||
func targetRow(_ row: TargetRow) -> some View {
|
||
HStack(spacing: 8) {
|
||
TextField(isEdit ? "例如 10.0.0.10" : "例如 10.0.1.11,可粘贴多个", text: hostBinding(row.id))
|
||
.textFieldStyle(.roundedBorder)
|
||
.focused($focusedRow, equals: row.id)
|
||
TextField(isEdit ? "3306" : "22, 3306", text: portsBinding(row.id))
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 150)
|
||
if !isEdit {
|
||
Button {
|
||
removeRow(row.id)
|
||
} label: {
|
||
Image(systemName: "minus.circle")
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.frame(width: 18)
|
||
.disabled(rows.count == 1)
|
||
.opacity(rows.count == 1 ? 0.3 : 1)
|
||
.help("删除这一行")
|
||
}
|
||
}
|
||
}
|
||
|
||
func hostBinding(_ id: UUID) -> Binding<String> {
|
||
Binding(
|
||
get: { rows.first(where: { $0.id == id })?.host ?? "" },
|
||
set: { setHost(id, $0) }
|
||
)
|
||
}
|
||
|
||
func portsBinding(_ id: UUID) -> Binding<String> {
|
||
Binding(
|
||
get: { rows.first(where: { $0.id == id })?.ports ?? "" },
|
||
set: { v in
|
||
if let i = rows.firstIndex(where: { $0.id == id }) { rows[i].ports = v }
|
||
}
|
||
)
|
||
}
|
||
|
||
/// 一次粘贴进多个主机(或 主机:端口)时拆成多行,其余情况照常输入
|
||
func setHost(_ id: UUID, _ value: String) {
|
||
guard let i = rows.firstIndex(where: { $0.id == id }) else { return }
|
||
let pasted = value.count - rows[i].host.count > 1
|
||
if !isEdit && pasted && (TargetRow.items(value).count > 1 || TargetRow.splitHostPort(value) != nil) {
|
||
let parsed = TargetRow.parse(value, defaultPorts: rows[i].ports)
|
||
if !parsed.isEmpty {
|
||
rows.replaceSubrange(i...i, with: parsed)
|
||
return
|
||
}
|
||
}
|
||
rows[i].host = value
|
||
}
|
||
|
||
/// 新的一行沿用上一行的端口(同一组机器通常开同样的端口)
|
||
func addRow() {
|
||
let row = TargetRow(host: "", ports: rows.last?.ports ?? "")
|
||
rows.append(row)
|
||
focusedRow = row.id
|
||
}
|
||
|
||
func removeRow(_ id: UUID) {
|
||
guard rows.count > 1 else { return }
|
||
rows.removeAll { $0.id == id }
|
||
}
|
||
|
||
/// 每个地址分到的访问端口,以及连通性测试结果
|
||
var planList: some View {
|
||
let shown = plan.prefix(8)
|
||
let failed = plan.filter { probes[$0.target]?.ok == false }.count
|
||
let tested = plan.filter { probes[$0.target] != nil }.count
|
||
return VStack(alignment: .leading, spacing: 3) {
|
||
ForEach(Array(shown)) { m in
|
||
HStack(spacing: 6) {
|
||
Text(verbatim: "\(vm.publicHost):\(m.listen_port)")
|
||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||
.foregroundColor(.accentColor)
|
||
Image(systemName: "arrow.right")
|
||
.font(.system(size: 8))
|
||
.foregroundColor(.secondary)
|
||
Text(verbatim: m.target)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
Spacer(minLength: 6)
|
||
if let r = probes[m.target] {
|
||
Label(r.ok ? r.detail : "不通", systemImage: r.ok ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||
.font(.system(size: 10))
|
||
.foregroundColor(r.ok ? .green : .red)
|
||
.help(r.ok ? "连接成功" : "连接失败:\(r.detail)")
|
||
}
|
||
}
|
||
}
|
||
Text(verbatim: planCaption(failed: failed, tested: tested, hidden: plan.count - shown.count))
|
||
.font(.system(size: 11))
|
||
.foregroundColor(failed > 0 ? .red : .secondary)
|
||
}
|
||
.padding(8)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(RoundedRectangle(cornerRadius: 6).fill(Color.primary.opacity(0.04)))
|
||
}
|
||
|
||
func planCaption(failed: Int, tested: Int, hidden: Int) -> String {
|
||
var parts: [String] = []
|
||
if hidden > 0 { parts.append("… 共 \(plan.count) 个地址") }
|
||
if tested == plan.count && failed == 0 {
|
||
parts.append(plan.count == 1 ? "目标连接成功" : "全部 \(plan.count) 个目标连接成功")
|
||
} else if failed > 0 {
|
||
parts.append("\(failed) 个目标连不上,鼠标停在 ✗ 上可查看原因")
|
||
} else {
|
||
parts.append(plan.count == 1 ? "访问端口自动分配" : "每个地址一个通道,访问端口自动连续分配")
|
||
}
|
||
return parts.joined(separator: ",")
|
||
}
|
||
|
||
// MARK: Sections
|
||
|
||
var groupSection: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("通道组")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
HStack(spacing: 8) {
|
||
Picker("", selection: $groupChoice) {
|
||
if !isBatch {
|
||
Text("不分组").tag(GroupChoice.none)
|
||
}
|
||
if isBatch || isEdit {
|
||
Text("新建通道组").tag(GroupChoice.new)
|
||
}
|
||
if !vm.groups.isEmpty {
|
||
Divider()
|
||
ForEach(vm.groups) { g in
|
||
Text(verbatim: g.name).tag(GroupChoice.existing(g.id))
|
||
}
|
||
}
|
||
}
|
||
.labelsHidden()
|
||
.fixedSize()
|
||
if isEdit && groupChoice == .new {
|
||
TextField(trimmedName.isEmpty ? "通道组名称" : "组名,默认「\(trimmedName)」", text: $newGroupName)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
}
|
||
if let hint = groupHint {
|
||
Text(verbatim: hint)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
var groupHint: String? {
|
||
switch groupChoice {
|
||
case .none:
|
||
return nil
|
||
case .new where isBatch:
|
||
let n = trimmedName.isEmpty ? "(填写上面的名称)" : "「\(trimmedName)」"
|
||
return "\(plan.count) 个地址会放进通道组\(n):共用邀请和授权,同事授权一次即可访问全部地址"
|
||
default:
|
||
return "组内通道共用邀请和授权,同事授权一次即可访问组内全部地址"
|
||
}
|
||
}
|
||
|
||
/// 自动关闭:闲置关闭(主要)+ 可选的最长开启时长
|
||
var closeSection: some View {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var bandwidthSection: some View {
|
||
field("单通道带宽限速 (QoS 防挤占)") {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
var accessSection: some View {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
func field<Content: View>(_ title: String, @ViewBuilder _ content: () -> Content) -> some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(title)
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
content()
|
||
}
|
||
}
|
||
|
||
// MARK: Actions
|
||
|
||
func refreshPlan() async {
|
||
guard !isEdit, !composedTarget.isEmpty else {
|
||
plan = []
|
||
planError = nil
|
||
return
|
||
}
|
||
// 输入停顿后再预览,避免每敲一个字就请求一次
|
||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||
guard !Task.isCancelled else { return }
|
||
let (items, err) = await vm.previewTunnels(makeForm(port: 0, whitelist: []))
|
||
guard !Task.isCancelled else { return }
|
||
plan = items
|
||
planError = err
|
||
}
|
||
|
||
func probe() {
|
||
let targets = isEdit ? [composedTarget] : plan.map { $0.target }
|
||
isProbing = true
|
||
Task {
|
||
probes = await vm.probeAll(targets)
|
||
isProbing = false
|
||
}
|
||
}
|
||
|
||
/// 访问控制对应的白名单;填写有误时提示并返回 nil
|
||
func whitelist() -> [String]? {
|
||
switch accessMode {
|
||
case .linkOnly:
|
||
return []
|
||
case .all:
|
||
return ["*"]
|
||
case .specific:
|
||
let wl = whitelistString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||
if wl.isEmpty {
|
||
errorMessage = "请填写允许访问的 IP 或网段,或选择“仅授权链接”"
|
||
return nil
|
||
}
|
||
if wl.contains("*") {
|
||
errorMessage = "如需放通全部,请选择“全部放通 *”"
|
||
return nil
|
||
}
|
||
return wl
|
||
}
|
||
}
|
||
|
||
func save() {
|
||
var port = 0
|
||
if isEdit {
|
||
guard let p = Int(portString), p > 33000 && p <= 34000 else {
|
||
errorMessage = "端口须在 33001-34000 范围内"
|
||
return
|
||
}
|
||
guard !composedTarget.contains(",") else {
|
||
errorMessage = "一个通道只对应一个目标端口;要开放更多端口,请用「添加通道」"
|
||
return
|
||
}
|
||
port = p
|
||
}
|
||
guard let wl = whitelist() else { return }
|
||
let form = makeForm(port: port, whitelist: wl)
|
||
let joinsExisting = existingGroupName != nil
|
||
isSaving = true
|
||
errorMessage = nil
|
||
Task {
|
||
let (ok, created, err) = await vm.saveTunnel(id: tunnel?.id, form: form)
|
||
isSaving = false
|
||
guard ok else {
|
||
errorMessage = err ?? "保存失败"
|
||
return
|
||
}
|
||
isPresented = false
|
||
guard !isEdit, !joinsExisting, accessMode != .all, let created = created else { return }
|
||
if let g = created.group {
|
||
onCreated?(.group(g.id))
|
||
} else if let t = created.tunnels.first {
|
||
onCreated?(.tunnel(t.id))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Tunnel Group Edit Sheet
|
||
|
||
/// 添加通道弹窗;groupId 非空时默认加入该通道组
|
||
struct AddSheetTarget: Identifiable {
|
||
let groupId: String?
|
||
var id: String { groupId ?? "new" }
|
||
}
|
||
|
||
struct GroupSheetTarget: Identifiable {
|
||
let group: GroupItem
|
||
var id: String { group.id }
|
||
}
|
||
|
||
/// 编辑通道组:名称、责任人,以及把已有通道加入该组。新地址在「添加通道」里创建
|
||
struct GroupEditSheet: View {
|
||
@ObservedObject var vm: PortRelayViewModel
|
||
let group: GroupItem
|
||
@Binding var isPresented: Bool
|
||
|
||
@State private var name: String
|
||
@State private var owner: String
|
||
@State private var moveIDs: Set<String> = []
|
||
@State private var errorMessage: String? = nil
|
||
@State private var isSaving = false
|
||
|
||
init(vm: PortRelayViewModel, group: GroupItem, isPresented: Binding<Bool>) {
|
||
_vm = ObservedObject(wrappedValue: vm)
|
||
self.group = group
|
||
_isPresented = isPresented
|
||
_name = State(initialValue: group.name)
|
||
_owner = State(initialValue: group.owner ?? "")
|
||
}
|
||
|
||
/// 可以加入该组的已有通道(不属于任何组)
|
||
var candidates: [TunnelItem] {
|
||
vm.tunnels.filter { ($0.group_id ?? "").isEmpty }
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("编辑通道组")
|
||
.font(.system(size: 15, weight: .bold))
|
||
Text("组内通道共用邀请与授权:同事授权一次即可访问整组,组内任一地址在用时整组都不会闲置关闭")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
HStack(spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("通道组名称")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
TextField("例如:订单服务集群", text: $name)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("关联责任人")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
TextField("例如:张三 / 运维组", text: $owner)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
}
|
||
|
||
if !candidates.isEmpty {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("把已有通道加入该组(可选)")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
ForEach(candidates) { t in
|
||
Toggle(isOn: Binding(
|
||
get: { moveIDs.contains(t.id) },
|
||
set: { on in if on { moveIDs.insert(t.id) } else { moveIDs.remove(t.id) } }
|
||
)) {
|
||
HStack(spacing: 6) {
|
||
Text(verbatim: t.name)
|
||
.font(.system(size: 12))
|
||
.lineLimit(1)
|
||
Text(verbatim: ":\(t.listen_port) → \(t.target)")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.toggleStyle(.checkbox)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.frame(maxHeight: min(CGFloat(candidates.count) * 22 + 4, 132))
|
||
}
|
||
}
|
||
|
||
Text("要开放新的地址,请在通道组卡片上点「添加地址」")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
|
||
if let err = errorMessage {
|
||
Text(verbatim: err)
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.red)
|
||
}
|
||
|
||
HStack {
|
||
Spacer()
|
||
Button("取消") { isPresented = false }
|
||
.controlSize(.small)
|
||
Button(isSaving ? "保存中…" : "保存") { save() }
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.small)
|
||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
|
||
}
|
||
.padding(.top, 2)
|
||
}
|
||
.padding(20)
|
||
.frame(width: 520)
|
||
}
|
||
|
||
func save() {
|
||
isSaving = true
|
||
errorMessage = nil
|
||
Task {
|
||
let err = await vm.saveGroup(id: group.id, name: name.trimmingCharacters(in: .whitespaces),
|
||
owner: owner.trimmingCharacters(in: .whitespaces), tunnelIds: Array(moveIDs))
|
||
isSaving = false
|
||
if let err = err {
|
||
errorMessage = err
|
||
return
|
||
}
|
||
isPresented = false
|
||
}
|
||
}
|
||
}
|
||
|
||
struct InviteSheetTarget: Identifiable {
|
||
let scope: AccessScope
|
||
let autoGenerate: Bool
|
||
var id: String { scope.key }
|
||
|
||
init(scope: AccessScope, autoGenerate: Bool) {
|
||
self.scope = scope
|
||
self.autoGenerate = autoGenerate
|
||
}
|
||
|
||
init(tunnel: TunnelItem, autoGenerate: Bool) {
|
||
self.init(scope: .tunnel(tunnel.id), autoGenerate: autoGenerate)
|
||
}
|
||
}
|
||
|
||
/// 生成邀请链接并展示分享卡片(二维码 + 可直接发送的文案)
|
||
struct InviteSheet: View {
|
||
@ObservedObject var vm: PortRelayViewModel
|
||
let scope: AccessScope
|
||
let autoGenerate: Bool
|
||
@Binding var isPresented: Bool
|
||
|
||
init(vm: PortRelayViewModel, scope: AccessScope, autoGenerate: Bool, isPresented: Binding<Bool>) {
|
||
_vm = ObservedObject(wrappedValue: vm)
|
||
self.scope = scope
|
||
self.autoGenerate = autoGenerate
|
||
_isPresented = isPresented
|
||
}
|
||
|
||
init(vm: PortRelayViewModel, tunnel: TunnelItem, autoGenerate: Bool, isPresented: Binding<Bool>) {
|
||
self.init(vm: vm, scope: .tunnel(tunnel.id), autoGenerate: autoGenerate, isPresented: isPresented)
|
||
}
|
||
|
||
var title: String {
|
||
let name = vm.scopeTitle(scope)
|
||
return scope.isGroup ? "邀请访问「\(name)」(\(vm.scopeTunnels(scope).filter { $0.enabled }.count) 个地址)" : "邀请访问「\(name)」"
|
||
}
|
||
|
||
@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: title)
|
||
.font(.system(size: 15, weight: .bold))
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
Text(verbatim: result == nil
|
||
? (scope.isGroup ? "同事授权一次即可访问组内全部地址" : "同事打开链接并填写姓名后获得访问权限")
|
||
: "授权后可访问 \(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: scope.isGroup ? 170 : 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(scope: scope, 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 (title, scope, r) in pending {
|
||
let item = NSMenuItem(title: "\(r.name)(\(r.ip))→ \(title)", action: nil, keyEquivalent: "")
|
||
let sub = NSMenu()
|
||
let approve = NSMenuItem(title: "批准", action: #selector(decideAction(_:)), keyEquivalent: "")
|
||
approve.representedObject = [scope.key, r.id, "1"]
|
||
approve.target = self
|
||
let reject = NSMenuItem(title: "拒绝", action: #selector(decideAction(_:)), keyEquivalent: "")
|
||
reject.representedObject = [scope.key, 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)
|
||
|
||
// 通道组占一行(出现在它第一个成员的位置),子菜单里整组开关、逐个地址开关
|
||
var seenGroups = Set<String>()
|
||
for t in vm.tunnels {
|
||
if let g = vm.group(t.group_id) {
|
||
if seenGroups.insert(g.id).inserted {
|
||
menu.addItem(groupMenuItem(g, members: vm.members(of: g)))
|
||
}
|
||
} else {
|
||
menu.addItem(tunnelMenuItem(t, title: "\(t.name) (:\(t.listen_port))"))
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func tunnelMenuItem(_ t: TunnelItem, title: String) -> NSMenuItem {
|
||
let item = NSMenuItem(title: (t.enabled ? "✓ " : " ") + title, action: #selector(toggleTunnelAction(_:)), keyEquivalent: "")
|
||
item.representedObject = t.id
|
||
item.target = self
|
||
return item
|
||
}
|
||
|
||
func groupMenuItem(_ g: GroupItem, members: [TunnelItem]) -> NSMenuItem {
|
||
let on = members.filter { $0.enabled }.count
|
||
let state = on > 0 && on < members.count ? ",\(on) 个开启" : ""
|
||
let item = NSMenuItem(title: (on > 0 ? "✓ " : " ") + "\(g.name)(\(members.count) 个地址\(state))", action: nil, keyEquivalent: "")
|
||
let sub = NSMenu()
|
||
let toggle = NSMenuItem(title: on > 0 ? "关闭整组" : "开启整组", action: #selector(toggleGroupAction(_:)), keyEquivalent: "")
|
||
toggle.representedObject = [g.id, on > 0 ? "off" : "on"]
|
||
toggle.target = self
|
||
sub.addItem(toggle)
|
||
sub.addItem(NSMenuItem.separator())
|
||
for t in members {
|
||
sub.addItem(tunnelMenuItem(t, title: ":\(t.listen_port) → \(t.target)"))
|
||
}
|
||
item.submenu = sub
|
||
return item
|
||
}
|
||
|
||
@objc func toggleGroupAction(_ sender: NSMenuItem) {
|
||
guard let v = sender.representedObject as? [String], v.count == 2 else { return }
|
||
Task {
|
||
await PortRelayViewModel.shared.toggleGroup(id: v[0], enabled: v[1] == "on")
|
||
}
|
||
}
|
||
|
||
@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 }
|
||
let id = String(v[0].dropFirst(2))
|
||
let scope: AccessScope = v[0].hasPrefix("g:") ? .group(id) : .tunnel(id)
|
||
Task {
|
||
_ = await PortRelayViewModel.shared.decideRequest(scope: scope, 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)
|
||
}
|
||
}
|
||
|