Files
PortRelay/mac-native/PortRelayApp.swift
T
DeepFal 3272e66247 docs: README 跟上授权延长与通道组,截图和动图改为一条命令重新生成
README
- 申请延长写清规则:授权有效期间也可申请,有效的从原到期时间顺延、已结束的从批准时起算,
  被撤销的不能延长;批准延长时通道会开到授权结束(与最长开启时长的关系)
- 修正通道组「一起开关」与「可以单独开关」的矛盾:闲置按整组计算,每个地址仍可单独开关
- 时长选项用界面上的「通道开启期间」;快速开始按实际流程写(保存后自动弹出邀请设置)
- 新增同事端申请延长的三张截图;流程图补上 30 分钟、自定义与 2 小时内可申请延长

截图与动图
- 授权管理、编辑通道、邀请文案、授权成功页、网页面板(含通道组)更新为当前界面
- 离屏渲染时控件按激活状态绘制,开关不再显示成灰色
- 新增 make readme-images:App 离屏渲染、动图合成、授权页与网页面板的无头 Chrome 截图一次完成,
  写入 docs/images;授权页 HTML 由服务端测试用模拟状态生成,网页面板用本机模拟 API

界面
- 计时文字第二个单位补零(27分05秒),卡片上的徽标不再随秒数左右跳动;App 与网页面板一致
- 待审批与延长申请的时间格式统一为不带秒;App 预览的分享文案补上延长提示
2026-10-14 14:51:07 +08:00

5419 lines
225 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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?
/// 可延长的设备与延长申请(通道关闭后 2 小时内仍有)
var renewals: [RenewalItem]?
}
/// 近 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
/// 授权已结束(或即将结束)的设备申请延长,批准时由管理员决定时长
var renewal: Bool?
}
/// 可延长的设备:访问结束 2 小时内(通道关闭后也保留),或提交了延长申请
struct RenewalItem: Identifiable, Codable {
var id: String { ip }
var tunnel_id: String?
var group_id: String?
var ip: String
var name: String?
var invite: String?
/// 访问结束时间;授权仍有效时为空
var ended_at: String?
/// 可以延长的截止时间
var renew_before: String?
/// 待处理的延长申请
var request: AccessRequestItem?
}
/// 通道组:组内通道共用邀请、授权与审批,闲置计时联动
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?
var renewals: [RenewalItem]?
}
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)小时" + String(format: "%02d分", mins)
} else if mins > 0 {
return "\(mins)分" + String(format: "%02d秒", 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)] = [
("30m", "30 分钟"), ("1h", "1 小时"), ("2h", "2 小时"), ("8h", "8 小时"), ("today", "当天有效"), ("tunnel", "通道开启期间")
]
/// 延长授权的常用时长(分钟)
let extendOptions: [(Int, String)] = [(30, "30 分钟"), (60, "1 小时"), (120, "2 小时"), (480, "8 小时")]
/// 自定义时长的上限:30 天
let maxCustomMinutes = 30 * 24 * 60
/// "30 分钟"、"2 小时"、"1 小时 30 分钟"、"3 天"
func describeMinutes(_ n: Int) -> String {
if n >= 1440 && n % 1440 == 0 { return "\(n / 1440) 天" }
if n >= 60 && n % 60 == 0 { return "\(n / 60) 小时" }
if n > 60 { return "\(n / 60) 小时 \(n % 60) 分钟" }
return "\(n) 分钟"
}
/// 自定义授权时长("90m")对应的分钟数
func customLeaseMinutes(_ v: String) -> Int? {
guard v.hasSuffix("m"), let n = Int(v.dropLast()) else { return nil }
return n
}
func leaseDurationTitle(_ v: String?) -> String {
let value = v ?? "2h"
if let opt = leaseDurationOptions.first(where: { $0.value == value }) { return opt.title }
if let n = customLeaseMinutes(value) { return describeMinutes(n) }
return "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)
}
}
/// 自定义时长输入:数字 + 分钟 / 小时
struct CustomMinutesForm: View {
let actionTitle: String
let onDone: (Int) -> Void
@State private var amount: String
@State private var hours: Bool
init(actionTitle: String, initialMinutes: Int? = nil, onDone: @escaping (Int) -> Void) {
self.actionTitle = actionTitle
self.onDone = onDone
let m = initialMinutes ?? 90
let whole = m >= 60 && m % 60 == 0
_hours = State(initialValue: whole)
_amount = State(initialValue: whole ? "\(m / 60)" : "\(m)")
}
var minutes: Int? {
guard let n = Int(amount.trimmingCharacters(in: .whitespaces)), n > 0 else { return nil }
let m = hours ? n * 60 : n
return m <= maxCustomMinutes ? m : nil
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
TextField("数量", text: $amount)
.textFieldStyle(.roundedBorder)
.frame(width: 64)
.onSubmit { if let m = minutes { onDone(m) } }
Picker("", selection: $hours) {
Text("分钟").tag(false)
Text("小时").tag(true)
}
.pickerStyle(.segmented)
.labelsHidden()
.fixedSize()
Button(actionTitle) { if let m = minutes { onDone(m) } }
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(minutes == nil)
}
Text(verbatim: minutes.map { "共 \(describeMinutes($0))" } ?? "请输入 1 分钟到 30 天之间的时长")
.font(.system(size: 11))
.foregroundColor(minutes == nil ? .red : .secondary)
}
.padding(12)
}
}
/// 「自定义…」时长选项:选中时显示具体时长,点击弹出输入框
struct CustomDurationChip: View {
let isCustom: Bool
let currentMinutes: Int?
let apply: (Int) -> Void
@State private var editing = false
var body: some View {
Button(action: { editing = true }) {
ChipLabel(title: isCustom ? "自定义 · \(describeMinutes(currentMinutes ?? 0))" : "自定义…", selected: isCustom)
}
.buttonStyle(.borderless)
.popover(isPresented: $editing, arrowEdge: .bottom) {
CustomMinutesForm(actionTitle: "确定", initialMinutes: isCustom ? currentMinutes : nil) { m in
apply(m)
editing = false
}
}
}
}
/// 「延长 ▾」:选一个时长立即生效,并批准该设备的延长申请;通道已关闭时会自动重新开启
struct ExtendMenu: View {
@ObservedObject var vm: PortRelayViewModel
let scope: AccessScope
let ip: String
var title: String = "延长"
var prominent: Bool = false
@State private var showingCustom = false
@State private var working = false
@State private var error: String? = nil
var body: some View {
Menu {
ForEach(extendOptions, id: \.0) { opt in
Button("延长 \(opt.1)") { extend(opt.0) }
}
Divider()
Button("自定义时长…") { showingCustom = true }
} label: {
Text(working ? "处理中…" : title)
}
.menuStyle(.button)
.modifier(ExtendMenuStyle(prominent: prominent))
.controlSize(.small)
.fixedSize()
.disabled(working)
.help(vm.scopeEnabled(scope) ? "选择延长多久,立即生效" : "选择延长多久;通道已关闭,会自动重新开启")
.popover(isPresented: $showingCustom, arrowEdge: .bottom) {
CustomMinutesForm(actionTitle: "延长") { m in
showingCustom = false
extend(m)
}
}
.alert("延长失败", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("好") {}
} message: {
Text(verbatim: error ?? "")
}
}
func extend(_ minutes: Int) {
working = true
Task {
error = await vm.extendLease(scope: scope, ip: ip, minutes: minutes)
working = false
}
}
}
struct ExtendMenuStyle: ViewModifier {
let prominent: Bool
func body(content: Content) -> some View {
if prominent {
content.buttonStyle(.borderedProminent)
} else {
content.buttonStyle(.bordered)
}
}
}
// 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)
}
/// 时刻(不带秒):今天为 "15:20",其他日期为 "10-13 15:20"
func formatClock(_ str: String?) -> String {
guard let d = parseISO8601(str) else { return "-" }
let df = DateFormatter()
df.dateFormat = Calendar.current.isDateInToday(d) ? "HH:mm" : "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
}
/// 所有待处理的延长申请(单个通道的与通道组的,含已关闭的通道)
var renewalRequests: [(title: String, scope: AccessScope, renewal: RenewalItem)] {
var out: [(title: String, scope: AccessScope, renewal: RenewalItem)] = []
for t in tunnels {
for r in t.renewals ?? [] where r.request != nil { out.append((title: t.name, scope: .tunnel(t.id), renewal: r)) }
}
for g in groups {
for r in g.renewals ?? [] where r.request != nil { out.append((title: g.name, scope: .group(g.id), renewal: r)) }
}
return out
}
/// 菜单栏上提醒的待处理数:首次访问申请 + 延长申请
var attentionCount: Int { pendingRequests.count + renewalRequests.count }
// 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 scopeRenewals(_ s: AccessScope) -> [RenewalItem] {
switch s {
case .tunnel(let id): return tunnels.first { $0.id == id }?.renewals ?? []
case .group(let id): return group(id)?.renewals ?? []
}
}
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))
}
for (title, _, rn) in renewalRequests {
guard let r = rn.request, !notifiedRequests.contains(r.id) else { continue }
notifiedRequests.insert(r.id)
let content = UNMutableNotificationContent()
content.title = "延长申请:\(title)"
content.body = "\(r.name)(\(r.ip))申请延长访问,在菜单栏或授权管理里选择延长多久即可批准"
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 requestIDs: [String] = pendingRequests.map { $0.request.id } + renewalRequests.compactMap { $0.renewal.request?.id }
let pendingSig: String = requestIDs.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 台设备使用,请勿转发。")
lines.append("授权结束后 2 小时内,可以重新打开此链接申请延长。")
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
}
/// 延长某台设备的访问(同时批准它的延长申请);通道已关闭时会自动重新开启。返回错误信息,成功为 nil
func extendLease(scope: AccessScope, ip: String, minutes: Int) async -> String? {
let requestID = scopeRenewals(scope).first { $0.ip == ip }?.request?.id
let (_, err) = await apiCall("POST", "\(scope.apiPath)/leases/extend", body: ["ip": ip, "minutes": minutes])
if err == nil, let rid = requestID {
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [rid])
}
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 asks = tunnel.renewals?.filter({ $0.request != nil }), !asks.isEmpty {
Button(action: onViewLeases) {
capsule("clock.arrow.circlepath", "申请延长 \(asks.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 groupRenewalAsks: [RenewalItem] { (group.renewals ?? []).filter { $0.request != nil } }
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 !groupRenewalAsks.isEmpty {
Button(action: { onViewClients(2) }) {
cardCapsule("clock.arrow.circlepath", "申请延长 \(groupRenewalAsks.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 renewals: [RenewalItem] { vm.scopeRenewals(scope) }
/// 申请延长的设备
var renewalAsks: [RenewalItem] { renewals.filter { $0.request != nil } }
/// 访问已结束、2 小时内还能延长的设备
var recentlyEnded: [RenewalItem] { renewals.filter { $0.request == nil && $0.ended_at != nil } }
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 + renewalAsks.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 !renewalAsks.isEmpty {
sectionHeader("申请延长", renewalAsks.count)
ForEach(renewalAsks) { rn in
renewalRow(rn)
}
}
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: "申请于 \(formatClock(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)
if lease.awaiting_connect != true {
ExtendMenu(vm: vm, scope: scope, ip: lease.ip)
}
Button("撤销", role: .destructive) { revoke(ip: lease.ip) }
.controlSize(.small)
.disabled(isRevoking)
.help("立即收回该设备的访问权限并断开连接")
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.rowCard()
}
if !recentlyEnded.isEmpty {
sectionHeader("最近结束 · 2 小时内可延长", recentlyEnded.count)
ForEach(recentlyEnded) { rn in
renewalRow(rn)
}
}
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 renewalRow(_ rn: RenewalItem) -> some View {
let asking = rn.request != nil
return HStack(spacing: 10) {
Image(systemName: asking ? "clock.arrow.circlepath" : "clock")
.foregroundColor(asking ? .orange : .secondary)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(verbatim: rn.name?.isEmpty == false ? rn.name! : "未署名")
.font(.system(size: 13, weight: .semibold))
.lineLimit(1)
Text(verbatim: rn.ip)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.accentColor)
if !vm.scopeEnabled(scope) {
TagView("通道已关闭,批准后自动开启")
}
}
Text(verbatim: renewalDetail(rn))
.font(.system(size: 11))
.foregroundColor(.secondary)
.lineLimit(1)
}
.layoutPriority(1)
Spacer(minLength: 8)
if let r = rn.request {
Button("拒绝") { decide(r, approve: false) }
.controlSize(.small)
.disabled(isRevoking)
}
ExtendMenu(vm: vm, scope: scope, ip: rn.ip, title: asking ? "批准延长" : "延长", prominent: asking)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.rowCard(tint: asking ? .orange : nil)
}
func renewalDetail(_ rn: RenewalItem) -> String {
var parts: [String] = []
if let r = rn.request {
parts.append("申请于 \(formatClock(r.at))")
}
if let end = rn.ended_at {
parts.append("访问已于 \(formatClock(end)) 结束")
if let before = rn.renew_before {
parts.append("\(formatClock(before)) 前可延长")
}
} else if let lease = leases.first(where: { $0.ip == rn.ip }) {
parts.append("当前有效至 \(formatClock(lease.expires_at)),批准后在此基础上顺延")
}
return parts.joined(separator: " · ")
}
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?
/// 组卡片「添加地址」打开时固定加入的通道组
let fixedGroupId: String?
@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.fixedGroupId = tunnel == nil ? groupId : nil
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 ?? ""
_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 effectiveChoice: GroupChoice {
if isEdit { return groupChoice }
if let gid = fixedGroupId { return .existing(gid) }
return isBatch ? .new : .none
}
/// 「添加地址」固定加入的通道组名称
var fixedGroupName: String? {
fixedGroupId.map { vm.group($0)?.name ?? "通道组" }
}
var nameTitle: String {
if fixedGroupName != nil { return "通道名称(可选)" }
return isBatch ? "通道组名称" : "通道名称"
}
var namePlaceholder: String {
if let g = fixedGroupName { return "留空则命名为「\(g) (主机)」" }
return isBatch ? "如:订单服务集群" : "如:订单数据库"
}
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 || fixedGroupId != nil
}
var saveTitle: String {
if isSaving { return "保存中…" }
if fixedGroupId != nil { return isBatch ? "添加 \(plan.count) 个地址" : "添加" }
return isBatch ? "创建 \(plan.count) 个通道" : "保存"
}
func makeForm(port: Int, whitelist: [String]) -> TunnelForm {
var groupId = "", newGroup = ""
switch effectiveChoice {
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(verbatim: isEdit ? "编辑通道" : (fixedGroupName.map { "添加地址到「\($0)」" } ?? "添加通道"))
.font(.system(size: 15, weight: .bold))
.lineLimit(1)
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)
}
}
}
if isEdit {
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(saveTitle) { save() }
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!canSave)
}
.padding(.top, 4)
}
.padding(20)
.frame(width: 520)
.task(id: "\(composedTarget)|\(trimmedName)") { await refreshPlan() }
.onChange(of: composedTarget) { _ in probes = [:] }
}
// 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("也可以直接粘贴多个地址(每行一个或逗号分隔,可带 :端口),会自动拆成多行")
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
}
if !isEdit, let hint = addGroupHint {
Label(hint, systemImage: "square.stack.3d.up")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
}
}
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 ? "IP 或域名" : "IP 或域名,支持粘贴多行", 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 addGroupHint: String? {
if let g = fixedGroupName { return "新地址加入通道组「\(g)」,自动沿用组的邀请和授权,已授权的同事无需重新授权" }
guard isBatch else { return nil }
let n = trimmedName.isEmpty ? "(以上面填写的名称命名)" : "「\(trimmedName)」"
return "\(plan.count) 个地址将放进通道组\(n):共用邀请和授权,同事授权一次即可访问全部地址"
}
var groupSection: some View {
VStack(alignment: .leading, spacing: 4) {
Text("通道组")
.font(.system(size: 12))
.foregroundColor(.secondary)
HStack(spacing: 8) {
Picker("", selection: $groupChoice) {
Text("不分组").tag(GroupChoice.none)
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 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: return "新建通道组并移入;之后可在组卡片的「编辑」里把其它通道加入"
case .existing: 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: "30 分钟", minutes: 30, 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)
CustomDurationChip(isCustom: ![0, 30, 120, 480, 1440, 10080].contains(durationMinutes),
currentMinutes: durationMinutes) { durationMinutes = $0 }
}
}
}
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("IP 或 CIDR 网段,逗号分隔,如 192.168.1.100, 10.0.0.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 = fixedGroupId != 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)
}
CustomDurationChip(isCustom: !leaseDurationOptions.contains { $0.value == leaseDuration },
currentMinutes: customLeaseMinutes(leaseDuration)) { leaseDuration = "\($0)m" }
}
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: 600)
.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.attentionCount
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())
// Renewal requests: pick how long to extend
let renewals = vm.renewalRequests
if !renewals.isEmpty {
let header = NSMenuItem(title: "申请延长 (\(renewals.count)):", action: nil, keyEquivalent: "")
header.isEnabled = false
menu.addItem(header)
for (title, scope, rn) in renewals {
guard let r = rn.request else { continue }
let item = NSMenuItem(title: "\(r.name)(\(r.ip))→ \(title)", action: nil, keyEquivalent: "")
let sub = NSMenu()
for opt in extendOptions {
let ext = NSMenuItem(title: "批准延长 \(opt.1)", action: #selector(extendAction(_:)), keyEquivalent: "")
ext.representedObject = [scope.key, r.ip, String(opt.0)]
ext.target = self
sub.addItem(ext)
}
sub.addItem(NSMenuItem.separator())
let reject = NSMenuItem(title: "拒绝", action: #selector(decideAction(_:)), keyEquivalent: "")
reject.representedObject = [scope.key, r.id, "0"]
reject.target = self
sub.addItem(reject)
item.submenu = sub
menu.addItem(item)
}
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 extendAction(_ sender: NSMenuItem) {
guard let v = sender.representedObject as? [String], v.count == 3, let minutes = Int(v[2]) else { return }
let id = String(v[0].dropFirst(2))
let scope: AccessScope = v[0].hasPrefix("g:") ? .group(id) : .tunnel(id)
Task {
if let err = await PortRelayViewModel.shared.extendLease(scope: scope, ip: v[1], minutes: minutes) {
let content = UNMutableNotificationContent()
content.title = "延长失败"
content.body = err
try? await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
}
}
}
@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)
}
}