PortRelay:把内网服务临时、可控地开放给同事

边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上;
通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。

- portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页)
- portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计)
- mac-native:SwiftUI 菜单栏 App
This commit is contained in:
DeepFal committed 2026-10-13 16:33:17 +08:00
commit 86e159b3f0
67 files changed
+13888

No files matched your search

+241
View File
@@ -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)
}
}
}
+260
View File
@@ -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
}
+13
View File
@@ -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"