PortRelay:把内网服务临时、可控地开放给同事
边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上; 通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。 - portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页) - portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计) - mac-native:SwiftUI 菜单栏 App
This commit is contained in:
commit
86e159b3f0
67 files changed
+13888
No files matched your search
@@ -0,0 +1,241 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "."
|
||||
let only = CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : ""
|
||||
_ = NSApplication.shared
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
|
||||
let iso: ISO8601DateFormatter = { let f = ISO8601DateFormatter(); f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]; return f }()
|
||||
func t(_ secondsFromNow: Double) -> String { iso.string(from: Date().addingTimeInterval(secondsFromNow)) }
|
||||
|
||||
func decode<T: Decodable>(_ type: T.Type, _ json: String) -> T {
|
||||
do { return try JSONDecoder().decode(T.self, from: Data(json.utf8)) }
|
||||
catch { fatalError("mock decode \(T.self): \(error)") }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func makeVM() -> PortRelayViewModel {
|
||||
let vm = PortRelayViewModel(preview: true)
|
||||
vm.isConnected = true
|
||||
vm.serverAddr = "192.168.1.10:33000"
|
||||
vm.publicHost = "192.168.1.10"
|
||||
vm.inviteBase = "http://192.168.1.10:33000/i/"
|
||||
vm.tunnels = decode([TunnelItem].self, """
|
||||
[
|
||||
{"id":"t1","name":"MySQL 数据库 (10.0.1.16)","target":"10.0.1.16:3306","listen_port":33660,"whitelist":[],"enabled":true,
|
||||
"owner":"数据组","duration_minutes":120,"started_at":"\(t(-3725))","expires_at":"\(t(3475))","historical_bytes_in":1288490188,"historical_bytes_out":53687091200,
|
||||
"idle_close_minutes":30,"effective_idle_close_minutes":30,"first_connected_at":"\(t(-3300))","connected_sec":1520,
|
||||
"activity":{"in_use":true,"connected_sec":1525},
|
||||
"max_bandwidth_mb":10,
|
||||
"metrics":{"tunnel_id":"t1","status":"online","active_conns":4,"total_conns":1287,"bytes_in":245039104,"bytes_out":8703180,"rate_in":1740,"rate_out":46285,"blocked_attempts":12,
|
||||
"dynamic_leases":[{"ip":"192.168.1.21","expires_at":"\(t(7000))","name":"张三","invite":"a1","duration":"2h","started_at":"\(t(-200))"},{"ip":"192.168.1.35","expires_at":"\(t(80000))","name":"一个名字特别长的外包同事王小明","invite":"a2","duration":"2h","awaiting_connect":true}],
|
||||
"pending":[{"id":"r1","tunnel_id":"t1","name":"李四","ip":"192.168.1.88","invite":"a3","at":"\(t(-60))","status":"pending"}],
|
||||
"invites":[{"code":"a1","note":"张三","created_at":"\(t(-3600))","expires_at":"\(t(80000))","max_uses":1,"lease_duration":"today","uses":[{"name":"张三","ip":"192.168.1.21","at":"\(t(-3500))"}]},
|
||||
{"code":"a3","created_at":"\(t(-60))","expires_at":"\(t(600000))","max_uses":0,"lease_duration":"1h","require_approval":true}]}},
|
||||
{"id":"t2","name":"SSH(10.0.8.3)测试环境 docker 后端跳板机入口","target":"10.0.8.3:22","listen_port":33661,"whitelist":["192.168.1.0/24","10.0.0.1"],"enabled":true,
|
||||
"owner":"运维组","duration_minutes":0,"started_at":"\(t(-50000))","effective_idle_close_minutes":30,"connected_sec":0,
|
||||
"activity":{"in_use":false,"idle_since":"\(t(-720))","auto_close_at":"\(t(1080))","connected_sec":0},
|
||||
"metrics":{"tunnel_id":"t2","status":"error","error":"port 33661 already in use by tunnel 3f2c9a7e-1b2d-4c5e-9f00-1234567890ab"}},
|
||||
{"id":"t3","name":"Redis 缓存","target":"10.0.1.20:6379","listen_port":33662,"whitelist":["*"],"enabled":false,"last_duration_sec":1905,"duration_minutes":0,"effective_idle_close_minutes":30,"last_connected_sec":742,"close_reason":"闲置 30 分钟自动关闭",
|
||||
"historical_bytes_in":1024,"historical_bytes_out":0}
|
||||
]
|
||||
""")
|
||||
vm.activeConns = decode([ActiveClientInfo].self, """
|
||||
[
|
||||
{"conn_id":"c1","tunnel_id":"t1","client_ip":"192.168.1.21:52889","connected_at":"\(t(-600))","bytes_in":416358,"bytes_out":4089446,"rate_in":632,"rate_out":16486,"rate_in":632},
|
||||
{"conn_id":"c2","tunnel_id":"t1","client_ip":"192.168.1.21:50395","connected_at":"\(t(-577))","bytes_in":406528,"bytes_out":3984588,"rate_in":1126,"rate_out":29798},
|
||||
{"conn_id":"c3","tunnel_id":"t1","client_ip":"192.168.1.21:53023","connected_at":"\(t(-526))","bytes_in":244213760,"bytes_out":633856,"rate_in":0,"rate_out":0},
|
||||
{"conn_id":"c4","tunnel_id":"t1","client_ip":"[fe80::1c2d:3e4f:aa:bb]:61001","connected_at":"\(t(-30))","bytes_in":120,"bytes_out":88,"rate_in":0,"rate_out":0}
|
||||
]
|
||||
""")
|
||||
vm.previewHistory = decode([ClientHistoryRecord].self, """
|
||||
[
|
||||
{"id":"h1","tunnel_id":"t1","tunnel_name":"MySQL","port":33660,"client_ip":"192.168.1.21:51000","connected_at":"\(t(-5000))","disconnected_at":"\(t(-4000))","duration_sec":1000,"bytes_in":1288490188,"bytes_out":5368709},
|
||||
{"id":"h2","tunnel_id":"t1","tunnel_name":"MySQL","port":33660,"client_ip":"192.168.1.21:51001","connected_at":"\(t(-3000))","disconnected_at":"\(t(-2990))","duration_sec":10,"bytes_in":2048,"bytes_out":4096},
|
||||
{"id":"h3","tunnel_id":"t1","tunnel_name":"MySQL","port":33660,"client_ip":"192.168.1.35:40000","connected_at":"\(t(-90000))","disconnected_at":"\(t(-86000))","duration_sec":4000,"bytes_in":99999999,"bytes_out":1}
|
||||
]
|
||||
""")
|
||||
// 模拟近 10 分钟流量:几段查询突发 + 平稳传输
|
||||
func wave(_ seed: Double, base: Double, burst: Double) -> [Int64] {
|
||||
(0..<120).map { i in
|
||||
let x = Double(i)
|
||||
var v = base * (0.6 + 0.4 * sin(x / 7 + seed))
|
||||
for c in [18.0, 47.0, 83.0, 108.0] { v += burst * exp(-pow((x - c - seed) / 3.5, 2)) }
|
||||
return Int64(max(0, v))
|
||||
}
|
||||
}
|
||||
vm.tunnels[0].rate_history = RateSeriesItem(end: Int64(Date().timeIntervalSince1970), step: 5,
|
||||
up: wave(1, base: 1800, burst: 9000), down: wave(0, base: 22000, burst: 160000))
|
||||
vm.tunnels[1].rate_history = RateSeriesItem(end: Int64(Date().timeIntervalSince1970), step: 5,
|
||||
up: Array(repeating: 0, count: 120), down: Array(repeating: 0, count: 120))
|
||||
vm.totalRateHistory = vm.tunnels[0].rate_history
|
||||
vm.totalActiveConns = 4; vm.totalHistoricalConns = 1287; vm.totalRateIn = 1740; vm.totalRateOut = 46285
|
||||
vm.totalTransferred = 60000000000; vm.totalBlockedAttempts = 12
|
||||
vm.runDiagnostics()
|
||||
return vm
|
||||
}
|
||||
|
||||
// MARK: - README 演示数据(render.sh readme / render.sh gif)
|
||||
|
||||
/// 平滑、确定性的模拟流量:k 为 5 秒桶的绝对序号,便于逐帧平移
|
||||
func demoRate(_ k: Int, seed: Double, base: Double, burst: Double) -> Int64 {
|
||||
let x = Double(k)
|
||||
var v = base * (0.55 + 0.25 * sin(x / 9 + seed) + 0.2 * sin(x / 3.7 + seed * 2))
|
||||
for c in stride(from: -60, through: 600, by: 21) {
|
||||
let h = 0.35 + 0.65 * abs(sin(Double(c) * 0.731 + seed))
|
||||
v += burst * h * exp(-pow((x - Double(c) - 7 * sin(Double(c) + seed)) / 3.2, 2))
|
||||
}
|
||||
return Int64(max(0, v))
|
||||
}
|
||||
|
||||
func demoSeries(tick: Int, seed: Double, base: Double, burst: Double, upScale: Double) -> RateSeriesItem {
|
||||
let down = (0..<120).map { demoRate($0 + tick, seed: seed, base: base, burst: burst) }
|
||||
let up = (0..<120).map { demoRate($0 + tick, seed: seed + 1.7, base: base * upScale, burst: burst * upScale) }
|
||||
return RateSeriesItem(end: Int64(Date().timeIntervalSince1970) + Int64(tick * 5), step: 5, up: up, down: down)
|
||||
}
|
||||
|
||||
/// 演示数据的时间基准:逐帧渲染时各时间点保持不变,只有计数随 tick 增长
|
||||
let demoBase = Date()
|
||||
|
||||
@MainActor
|
||||
func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
|
||||
let vm = PortRelayViewModel(preview: true)
|
||||
let dt = Double(tick * 5)
|
||||
func t(_ s: Double) -> String { iso.string(from: demoBase.addingTimeInterval(s)) }
|
||||
vm.isConnected = true
|
||||
vm.serverAddr = "192.168.1.10:33000"
|
||||
vm.publicHost = "192.168.1.10"
|
||||
vm.inviteBase = "http://192.168.1.10:33000/i/"
|
||||
vm.tunnels = decode([TunnelItem].self, """
|
||||
[
|
||||
{"id":"t1","name":"MySQL 数据库","target":"10.0.1.16:3306","listen_port":33660,"whitelist":[],"enabled":true,
|
||||
"owner":"数据组","duration_minutes":240,"started_at":"\(t(-3725))","expires_at":"\(t(10675))","historical_bytes_in":1288490188,"historical_bytes_out":53687091200,
|
||||
"idle_close_minutes":30,"effective_idle_close_minutes":30,"first_connected_at":"\(t(-3300))","connected_sec":\(1520 + Int(dt)),
|
||||
"activity":{"in_use":true,"connected_sec":\(1525 + Int(dt))},
|
||||
"metrics":{"tunnel_id":"t1","status":"online","active_conns":3,"total_conns":1287,"bytes_in":\(245039104 + tick * 900000),"bytes_out":\(8703180 + tick * 40000),"rate_in":0,"rate_out":0,"blocked_attempts":12,
|
||||
"dynamic_leases":[{"ip":"192.168.1.21","expires_at":"\(t(7000))","name":"张三","invite":"a1","duration":"2h","started_at":"\(t(-200))"},{"ip":"192.168.1.35","expires_at":"\(t(80000))","name":"王小明","invite":"a2","duration":"8h","awaiting_connect":true}],
|
||||
"pending":[{"id":"r1","tunnel_id":"t1","name":"李四","ip":"192.168.1.88","invite":"a3","at":"\(t(-60))","status":"pending"}],
|
||||
"invites":[{"code":"a1","note":"张三","created_at":"\(t(-3600))","expires_at":"\(t(80000))","max_uses":1,"lease_duration":"2h","uses":[{"name":"张三","ip":"192.168.1.21","at":"\(t(-3500))"}]},
|
||||
{"code":"a2","note":"王小明","created_at":"\(t(-1800))","expires_at":"\(t(84600))","max_uses":1,"lease_duration":"8h","uses":[{"name":"王小明","ip":"192.168.1.35","at":"\(t(-1700))"}]},
|
||||
{"code":"a3","note":"测试组","created_at":"\(t(-60))","expires_at":"\(t(600000))","max_uses":0,"lease_duration":"today","require_approval":true}]}},
|
||||
{"id":"t2","name":"Grafana 监控面板","target":"10.0.2.30:3000","listen_port":33661,"whitelist":["192.168.1.0/24"],"enabled":true,
|
||||
"owner":"运维组","duration_minutes":0,"started_at":"\(t(-50000))","effective_idle_close_minutes":0,"idle_close_minutes":0,"first_connected_at":"\(t(-49000))","connected_sec":\(36120 + Int(dt)),
|
||||
"activity":{"in_use":true,"connected_sec":\(36120 + Int(dt))},
|
||||
"metrics":{"tunnel_id":"t2","status":"online","active_conns":2,"total_conns":342,"bytes_in":\(52039104 + tick * 300000),"bytes_out":2703180,"rate_in":0,"rate_out":0,"blocked_attempts":0}},
|
||||
{"id":"t3","name":"SSH 跳板机(测试环境)","target":"10.0.8.3:22","listen_port":33662,"whitelist":[],"enabled":true,
|
||||
"owner":"运维组","duration_minutes":0,"started_at":"\(t(-2100))","effective_idle_close_minutes":30,"idle_close_minutes":30,"first_connected_at":"\(t(-2000))","connected_sec":640,
|
||||
"activity":{"in_use":false,"idle_since":"\(t(-720))","auto_close_at":"\(t(1080))","connected_sec":640},
|
||||
"metrics":{"tunnel_id":"t3","status":"online","active_conns":0,"total_conns":4,"bytes_in":1983000,"bytes_out":204000,"rate_in":0,"rate_out":0,"blocked_attempts":0}},
|
||||
{"id":"t4","name":"Redis 缓存","target":"10.0.1.20:6379","listen_port":33663,"whitelist":[],"enabled":false,"last_duration_sec":1905,"duration_minutes":0,"effective_idle_close_minutes":30,"last_connected_sec":742,"close_reason":"闲置 30 分钟自动关闭",
|
||||
"historical_bytes_in":3145728,"historical_bytes_out":524288}
|
||||
]
|
||||
""")
|
||||
vm.tunnels[0].rate_history = demoSeries(tick: tick, seed: 0.4, base: 26000, burst: 150000, upScale: 0.08)
|
||||
vm.tunnels[1].rate_history = demoSeries(tick: tick, seed: 2.1, base: 9000, burst: 30000, upScale: 0.15)
|
||||
vm.tunnels[2].rate_history = RateSeriesItem(end: Int64(Date().timeIntervalSince1970), step: 5,
|
||||
up: (0..<120).map { $0 < 30 ? demoRate($0, seed: 5, base: 300, burst: 2000) : 0 },
|
||||
down: (0..<120).map { $0 < 30 ? demoRate($0, seed: 6, base: 1500, burst: 9000) : 0 })
|
||||
// 卡片与顶部统计的实时速率取曲线最后一个点
|
||||
var totalIn: Int64 = 0, totalOut: Int64 = 0
|
||||
for i in 0..<2 {
|
||||
let h = vm.tunnels[i].rate_history!
|
||||
vm.tunnels[i].metrics?.rate_in = h.up.last ?? 0
|
||||
vm.tunnels[i].metrics?.rate_out = h.down.last ?? 0
|
||||
totalIn += h.up.last ?? 0; totalOut += h.down.last ?? 0
|
||||
}
|
||||
let a = vm.tunnels[0].rate_history!, b = vm.tunnels[1].rate_history!
|
||||
vm.totalRateHistory = RateSeriesItem(end: a.end, step: 5, up: zip(a.up, b.up).map { $0 + $1 }, down: zip(a.down, b.down).map { $0 + $1 })
|
||||
vm.activeConns = decode([ActiveClientInfo].self, """
|
||||
[
|
||||
{"conn_id":"c1","tunnel_id":"t1","client_ip":"192.168.1.21:52889","connected_at":"\(t(-600))","bytes_in":416358,"bytes_out":4089446,"rate_in":632,"rate_out":16486},
|
||||
{"conn_id":"c2","tunnel_id":"t1","client_ip":"192.168.1.21:50395","connected_at":"\(t(-577))","bytes_in":406528,"bytes_out":3984588,"rate_in":1126,"rate_out":29798},
|
||||
{"conn_id":"c3","tunnel_id":"t1","client_ip":"192.168.1.21:53023","connected_at":"\(t(-526))","bytes_in":244213760,"bytes_out":633856,"rate_in":0,"rate_out":0},
|
||||
{"conn_id":"c4","tunnel_id":"t2","client_ip":"192.168.1.52:61001","connected_at":"\(t(-3000))","bytes_in":120000,"bytes_out":8800000,"rate_in":120,"rate_out":9100},
|
||||
{"conn_id":"c5","tunnel_id":"t2","client_ip":"192.168.1.60:61201","connected_at":"\(t(-300))","bytes_in":42000,"bytes_out":1800000,"rate_in":80,"rate_out":3100}
|
||||
]
|
||||
""")
|
||||
vm.previewHistory = decode([ClientHistoryRecord].self, """
|
||||
[
|
||||
{"id":"h1","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.21:51000","connected_at":"\(t(-5000))","disconnected_at":"\(t(-4000))","duration_sec":1000,"bytes_in":1288490188,"bytes_out":5368709},
|
||||
{"id":"h2","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.21:51001","connected_at":"\(t(-3000))","disconnected_at":"\(t(-2990))","duration_sec":10,"bytes_in":2048,"bytes_out":4096},
|
||||
{"id":"h3","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.35:40000","connected_at":"\(t(-90000))","disconnected_at":"\(t(-86000))","duration_sec":4000,"bytes_in":99999999,"bytes_out":1}
|
||||
]
|
||||
""")
|
||||
vm.totalActiveConns = 5; vm.totalHistoricalConns = 1633; vm.totalRateIn = totalIn; vm.totalRateOut = totalOut
|
||||
vm.totalTransferred = 60000000000; vm.totalBlockedAttempts = 12
|
||||
vm.runDiagnostics()
|
||||
return vm
|
||||
}
|
||||
|
||||
/// 让离屏窗口按"前台激活"状态绘制控件(开关、分段控件显示强调色)
|
||||
final class KeyWindow: NSWindow {
|
||||
override var isKeyWindow: Bool { true }
|
||||
override var isMainWindow: Bool { true }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func render<V: View>(_ name: String, _ size: CGSize, dark: Bool = false, _ view: V) {
|
||||
if !only.isEmpty && !name.hasPrefix(only) { return }
|
||||
let root = view.frame(width: size.width, height: size.height, alignment: .topLeading)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
let hosting = NSHostingView(rootView: root)
|
||||
hosting.frame = NSRect(origin: .zero, size: size)
|
||||
let window = KeyWindow(contentRect: hosting.frame, styleMask: [.titled], backing: .buffered, defer: false)
|
||||
window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua)
|
||||
hosting.appearance = window.appearance
|
||||
window.contentView = hosting
|
||||
for _ in 0..<6 {
|
||||
hosting.layoutSubtreeIfNeeded()
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.15))
|
||||
}
|
||||
guard let rep = hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds) else { return }
|
||||
hosting.cacheDisplay(in: hosting.bounds, to: rep)
|
||||
try! rep.representation(using: .png, properties: [:])!.write(to: URL(fileURLWithPath: "\(outDir)/\(name).png"))
|
||||
print("rendered", name)
|
||||
}
|
||||
|
||||
MainActor.assumeIsolated {
|
||||
let vm = makeVM()
|
||||
let t1 = vm.tunnels[0]
|
||||
let yes = Binding.constant(true)
|
||||
render("01-main-light", CGSize(width: 980, height: 720), ContentView(vm: vm))
|
||||
render("01c-search-hit", CGSize(width: 980, height: 560), ContentView(vm: vm, initialSearch: "sjk"))
|
||||
render("01d-search-none", CGSize(width: 980, height: 560), ContentView(vm: vm, initialSearch: "oracle", initialFilter: .running))
|
||||
render("02-main-dark", CGSize(width: 980, height: 720), dark: true, ContentView(vm: vm))
|
||||
render("03-clients-online", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 0, isPresented: yes))
|
||||
let vmSingle = makeVM()
|
||||
vmSingle.activeConns = vmSingle.activeConns.filter { $0.client_ip.hasPrefix("192.168.1.21:") }
|
||||
render("03b-clients-online-single", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vmSingle, tunnel: vmSingle.tunnels[0], initialTab: 0, isPresented: yes))
|
||||
render("03c-clients-online-min", CGSize(width: 720, height: 540), ClientDetailsSheet(vm: vmSingle, tunnel: vmSingle.tunnels[0], initialTab: 0, isPresented: yes))
|
||||
render("01b-main-min", CGSize(width: 920, height: 640), ContentView(vm: vm))
|
||||
render("04-clients-history", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 1, isPresented: yes))
|
||||
render("05-clients-auth", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 2, isPresented: yes))
|
||||
render("06-edit-new", CGSize(width: 490, height: 760), TunnelEditSheet(vm: vm, tunnel: nil, isPresented: yes))
|
||||
render("07-edit-existing", CGSize(width: 490, height: 760), TunnelEditSheet(vm: vm, tunnel: vm.tunnels[1], isPresented: yes))
|
||||
render("08-invite-form", CGSize(width: 560, height: 400), InviteSheet(vm: vm, tunnel: t1, autoGenerate: false, isPresented: yes))
|
||||
render("09-invite-result", CGSize(width: 560, height: 400), InviteSheet(vm: vm, tunnel: t1, autoGenerate: true, isPresented: yes))
|
||||
render("10-settings", CGSize(width: 400, height: 320), SettingsSheet(vm: vm, isPresented: yes))
|
||||
render("11-diagnostics", CGSize(width: 580, height: 460), DiagnosticsSheet(vm: vm, isPresented: yes))
|
||||
render("12-clients-auth-dark", CGSize(width: 760, height: 600), dark: true, ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 2, isPresented: yes))
|
||||
|
||||
// README 截图
|
||||
let demo = makeDemoVM()
|
||||
render("readme-main-light", CGSize(width: 980, height: 880), ContentView(vm: demo))
|
||||
render("readme-main-dark", CGSize(width: 980, height: 880), dark: true, ContentView(vm: demo))
|
||||
render("readme-invite", CGSize(width: 560, height: 250), InviteSheet(vm: demo, tunnel: demo.tunnels[0], autoGenerate: true, isPresented: yes))
|
||||
render("readme-clients", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 0, isPresented: yes))
|
||||
render("readme-access", CGSize(width: 760, height: 490), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 2, isPresented: yes))
|
||||
render("readme-edit", CGSize(width: 490, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
|
||||
|
||||
// README 动图逐帧:卡片随时间推移(每帧 = 5 秒)
|
||||
if only.hasPrefix("gif") {
|
||||
let frames = Int(ProcessInfo.processInfo.environment["FRAMES"] ?? "") ?? 40
|
||||
for i in 0..<frames {
|
||||
let fvm = makeDemoVM(tick: i)
|
||||
let card = TunnelCardView(tunnel: fvm.tunnels[0], vm: fvm, onEdit: {}, onViewClients: {}, onViewLeases: {}, onInvite: {})
|
||||
.padding(.horizontal, 20).padding(.vertical, 12)
|
||||
render(String(format: "gif-%03d", i), CGSize(width: 980, height: 192), card)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// mkgif 把逐帧 PNG 合成为循环播放的 GIF,用于 README 动图。
|
||||
//
|
||||
// go run ./tools/ui-snapshots/mkgif -o docs/images/live-traffic.gif tools/ui-snapshots/out/gif-*.png
|
||||
//
|
||||
// 所有帧共用一套中位切分调色板;第二帧起只编码与上一帧不同的区域,
|
||||
// 区域内未变化的像素写成透明色,界面类动图体积因此很小。
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/png"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("o", "out.gif", "output file")
|
||||
delay := flag.Int("delay", 40, "frame delay in 1/100 s")
|
||||
hold := flag.Int("hold", 120, "extra delay on the last frame in 1/100 s")
|
||||
shrink := flag.Int("shrink", 1, "downscale factor (box filter)")
|
||||
flag.Parse()
|
||||
if flag.NArg() == 0 {
|
||||
log.Fatal("no input frames")
|
||||
}
|
||||
|
||||
var frames []*image.RGBA
|
||||
for _, path := range flag.Args() {
|
||||
frames = append(frames, downscale(load(path), *shrink))
|
||||
}
|
||||
bounds := frames[0].Bounds()
|
||||
|
||||
pal := medianCut(histogram(frames), 255)
|
||||
transparent := uint8(len(pal))
|
||||
pal = append(pal, color.RGBA{})
|
||||
|
||||
idx := newMapper(pal[:transparent])
|
||||
anim := &gif.GIF{LoopCount: 0, Config: image.Config{ColorModel: pal, Width: bounds.Dx(), Height: bounds.Dy()}}
|
||||
var prev *image.Paletted
|
||||
for i, f := range frames {
|
||||
cur := image.NewPaletted(bounds, pal)
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
cur.SetColorIndex(x, y, idx.index(f.RGBAAt(x, y)))
|
||||
}
|
||||
}
|
||||
frame := cur
|
||||
if prev != nil {
|
||||
frame = diff(prev, cur, transparent)
|
||||
}
|
||||
d := *delay
|
||||
if i == len(frames)-1 {
|
||||
d += *hold
|
||||
}
|
||||
anim.Image = append(anim.Image, frame)
|
||||
anim.Delay = append(anim.Delay, d)
|
||||
anim.Disposal = append(anim.Disposal, gif.DisposalNone)
|
||||
prev = cur
|
||||
}
|
||||
|
||||
w, err := os.Create(*out)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := gif.EncodeAll(w, anim); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
st, _ := os.Stat(*out)
|
||||
log.Printf("%s: %d frames, %dx%d, %d KB", *out, len(frames), bounds.Dx(), bounds.Dy(), st.Size()/1024)
|
||||
}
|
||||
|
||||
func load(path string) *image.RGBA {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
img, err := png.Decode(f)
|
||||
if err != nil {
|
||||
log.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
b := img.Bounds()
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
|
||||
for y := 0; y < b.Dy(); y++ {
|
||||
for x := 0; x < b.Dx(); x++ {
|
||||
rgba.Set(x, y, img.At(b.Min.X+x, b.Min.Y+y))
|
||||
}
|
||||
}
|
||||
return rgba
|
||||
}
|
||||
|
||||
func downscale(src *image.RGBA, k int) *image.RGBA {
|
||||
if k <= 1 {
|
||||
return src
|
||||
}
|
||||
b := src.Bounds()
|
||||
dst := image.NewRGBA(image.Rect(0, 0, b.Dx()/k, b.Dy()/k))
|
||||
for y := 0; y < dst.Bounds().Dy(); y++ {
|
||||
for x := 0; x < dst.Bounds().Dx(); x++ {
|
||||
var r, g, bl, a int
|
||||
for dy := 0; dy < k; dy++ {
|
||||
for dx := 0; dx < k; dx++ {
|
||||
c := src.RGBAAt(x*k+dx, y*k+dy)
|
||||
r, g, bl, a = r+int(c.R), g+int(c.G), bl+int(c.B), a+int(c.A)
|
||||
}
|
||||
}
|
||||
n := k * k
|
||||
dst.SetRGBA(x, y, color.RGBA{uint8(r / n), uint8(g / n), uint8(bl / n), uint8(a / n)})
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
c [3]uint8
|
||||
count int
|
||||
}
|
||||
|
||||
func histogram(frames []*image.RGBA) []bucket {
|
||||
counts := map[[3]uint8]int{}
|
||||
for _, f := range frames {
|
||||
for i := 0; i+3 < len(f.Pix); i += 4 {
|
||||
counts[[3]uint8{f.Pix[i], f.Pix[i+1], f.Pix[i+2]}]++
|
||||
}
|
||||
}
|
||||
out := make([]bucket, 0, len(counts))
|
||||
for c, n := range counts {
|
||||
out = append(out, bucket{c, n})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// medianCut 反复把"像素数 × 色彩跨度"最大的盒子沿最长轴按像素中位数切开。
|
||||
func medianCut(colors []bucket, n int) color.Palette {
|
||||
boxes := [][]bucket{colors}
|
||||
for len(boxes) < n {
|
||||
best, bestScore, bestAxis := -1, 0, 0
|
||||
for i, b := range boxes {
|
||||
if len(b) < 2 {
|
||||
continue
|
||||
}
|
||||
axis, span := longestAxis(b)
|
||||
total := 0
|
||||
for _, c := range b {
|
||||
total += c.count
|
||||
}
|
||||
if score := span * total; score > bestScore {
|
||||
best, bestScore, bestAxis = i, score, axis
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
break
|
||||
}
|
||||
b := boxes[best]
|
||||
sort.Slice(b, func(i, j int) bool { return b[i].c[bestAxis] < b[j].c[bestAxis] })
|
||||
total := 0
|
||||
for _, c := range b {
|
||||
total += c.count
|
||||
}
|
||||
cut, acc := 1, 0
|
||||
for i, c := range b {
|
||||
acc += c.count
|
||||
if acc*2 >= total {
|
||||
cut = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if cut >= len(b) {
|
||||
cut = len(b) - 1
|
||||
}
|
||||
boxes[best] = b[:cut]
|
||||
boxes = append(boxes, b[cut:])
|
||||
}
|
||||
pal := make(color.Palette, 0, len(boxes))
|
||||
for _, b := range boxes {
|
||||
var r, g, bl, total int
|
||||
for _, c := range b {
|
||||
r += int(c.c[0]) * c.count
|
||||
g += int(c.c[1]) * c.count
|
||||
bl += int(c.c[2]) * c.count
|
||||
total += c.count
|
||||
}
|
||||
pal = append(pal, color.RGBA{uint8(r / total), uint8(g / total), uint8(bl / total), 255})
|
||||
}
|
||||
return pal
|
||||
}
|
||||
|
||||
func longestAxis(b []bucket) (axis, span int) {
|
||||
for a := 0; a < 3; a++ {
|
||||
lo, hi := 255, 0
|
||||
for _, c := range b {
|
||||
v := int(c.c[a])
|
||||
lo, hi = min(lo, v), max(hi, v)
|
||||
}
|
||||
if hi-lo > span {
|
||||
axis, span = a, hi-lo
|
||||
}
|
||||
}
|
||||
return axis, span
|
||||
}
|
||||
|
||||
type mapper struct {
|
||||
pal color.Palette
|
||||
cache map[color.RGBA]uint8
|
||||
}
|
||||
|
||||
func newMapper(pal color.Palette) *mapper {
|
||||
return &mapper{pal: pal, cache: map[color.RGBA]uint8{}}
|
||||
}
|
||||
|
||||
func (m *mapper) index(c color.RGBA) uint8 {
|
||||
c.A = 255
|
||||
if i, ok := m.cache[c]; ok {
|
||||
return i
|
||||
}
|
||||
best, bestD := 0, int(^uint(0)>>1)
|
||||
for i, p := range m.pal {
|
||||
q := p.(color.RGBA)
|
||||
dr, dg, db := int(c.R)-int(q.R), int(c.G)-int(q.G), int(c.B)-int(q.B)
|
||||
if d := 3*dr*dr + 4*dg*dg + 2*db*db; d < bestD {
|
||||
best, bestD = i, d
|
||||
}
|
||||
}
|
||||
m.cache[c] = uint8(best)
|
||||
return uint8(best)
|
||||
}
|
||||
|
||||
// diff 返回只覆盖变化区域的帧,区域内未变化的像素为透明色。
|
||||
func diff(prev, cur *image.Paletted, transparent uint8) *image.Paletted {
|
||||
b := cur.Bounds()
|
||||
minX, minY, maxX, maxY := b.Max.X, b.Max.Y, b.Min.X-1, b.Min.Y-1
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
if prev.ColorIndexAt(x, y) != cur.ColorIndexAt(x, y) {
|
||||
minX, minY, maxX, maxY = min(minX, x), min(minY, y), max(maxX, x), max(maxY, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
if maxX < minX {
|
||||
minX, minY, maxX, maxY = 0, 0, 0, 0
|
||||
}
|
||||
r := image.Rect(minX, minY, maxX+1, maxY+1)
|
||||
out := image.NewPaletted(r, cur.Palette)
|
||||
for y := r.Min.Y; y < r.Max.Y; y++ {
|
||||
for x := r.Min.X; x < r.Max.X; x++ {
|
||||
if v := cur.ColorIndexAt(x, y); v != prev.ColorIndexAt(x, y) {
|
||||
out.SetColorIndex(x, y, v)
|
||||
} else {
|
||||
out.SetColorIndex(x, y, transparent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# 离屏渲染 App 各界面(模拟数据、浅色/深色),用于检查布局。
|
||||
# 用法: tools/ui-snapshots/render.sh [只渲染的名称前缀] 输出: tools/ui-snapshots/out/*.png
|
||||
set -e
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$(cd "$DIR/../.." && pwd)"
|
||||
BUILD="$DIR/.build"
|
||||
mkdir -p "$BUILD" "$DIR/out"
|
||||
# 去掉 @main,与渲染入口一起编译
|
||||
sed 's/^@main$//' "$ROOT/mac-native/PortRelayApp.swift" > "$BUILD/app.swift"
|
||||
xcrun swiftc -O -target "$(uname -m)-apple-macos13.0" "$BUILD/app.swift" "$DIR/main.swift" -o "$BUILD/render"
|
||||
rm -f "$DIR/out/${1:-}"*.png
|
||||
"$BUILD/render" "$DIR/out" "$1"
|
||||
Reference in new issue
Block a user