從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑: tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。 Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local), 雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。 Backend / Wails Go(AB1-AB13): - internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped) + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event - internal/auth:encrypted file token store(AES-GCM + scrypt + machineID fallback salt + 13 tests) - internal/config:YAML validation + atomic write + 11 tests - internal/log:ring buffer + ExportLog 升級 zip - visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests - 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage) - end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護 → tunnel drop failover) Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎): - AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab) - ConnectionStatusBadge 5 種狀態 - TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁 - 設定頁 4 區塊(含重新配對 AlertDialog) - agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests Phase 0.7 review-driven fix(Round 2): - A1 Session fixation 防護(RotateSessionID) - A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log - A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態) - A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test - F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL / F4 Settings draft 持久 + 未儲存 badge 驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 / agent frontend pnpm test 119 tests 全綠 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
290 lines
6.9 KiB
Go
290 lines
6.9 KiB
Go
// 一次性 icon 生成工具 — 產 visionA Agent logo 的多種解析度 PNG
|
||
// 用法:go run gen_icon.go <output-dir>
|
||
//
|
||
// 此檔案為 standalone 工具,不屬於 visiona-agent app。
|
||
// 用 build tag 避免被一般 go build 抓到(只有明確 go run 才執行)。
|
||
//go:build ignore
|
||
|
||
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
"image/png"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
)
|
||
|
||
var (
|
||
bgTop = color.RGBA{0x1A, 0x1F, 0x36, 0xFF}
|
||
bgBottom = color.RGBA{0x0E, 0x12, 0x22, 0xFF}
|
||
lensTop = color.RGBA{0x6E, 0xA8, 0xFF, 0xFF}
|
||
lensBot = color.RGBA{0x4F, 0x7E, 0xFF, 0xFF}
|
||
lensMid = color.RGBA{0x4F, 0x7E, 0xFF, 0x99}
|
||
centerGlow = color.RGBA{0x6E, 0xF3, 0xC5, 0xE0}
|
||
white = color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}
|
||
mint = color.RGBA{0x6E, 0xF3, 0xC5, 0xFF}
|
||
lightBlue = color.RGBA{0x6E, 0xA8, 0xFF, 0xFF}
|
||
)
|
||
|
||
func lerpColor(a, b color.RGBA, t float64) color.RGBA {
|
||
if t < 0 {
|
||
t = 0
|
||
}
|
||
if t > 1 {
|
||
t = 1
|
||
}
|
||
return color.RGBA{
|
||
R: uint8(float64(a.R)*(1-t) + float64(b.R)*t),
|
||
G: uint8(float64(a.G)*(1-t) + float64(b.G)*t),
|
||
B: uint8(float64(a.B)*(1-t) + float64(b.B)*t),
|
||
A: uint8(float64(a.A)*(1-t) + float64(b.A)*t),
|
||
}
|
||
}
|
||
|
||
func blendAlpha(dst, src color.RGBA) color.RGBA {
|
||
if src.A == 0 {
|
||
return dst
|
||
}
|
||
if src.A == 0xFF {
|
||
return src
|
||
}
|
||
sa := float64(src.A) / 255.0
|
||
da := float64(dst.A) / 255.0
|
||
out := 1.0 - (1.0-sa)*(1.0-da)
|
||
if out == 0 {
|
||
return dst
|
||
}
|
||
return color.RGBA{
|
||
R: uint8((float64(src.R)*sa + float64(dst.R)*da*(1-sa)) / out),
|
||
G: uint8((float64(src.G)*sa + float64(dst.G)*da*(1-sa)) / out),
|
||
B: uint8((float64(src.B)*sa + float64(dst.B)*da*(1-sa)) / out),
|
||
A: uint8(out * 255),
|
||
}
|
||
}
|
||
|
||
func rgba(c color.Color) color.RGBA {
|
||
if r, ok := c.(color.RGBA); ok {
|
||
return r
|
||
}
|
||
r, g, b, a := c.RGBA()
|
||
return color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)}
|
||
}
|
||
|
||
func drawRoundedRect(img *image.RGBA, size int, radius float64, colorAt func(y int) color.RGBA) {
|
||
cx, cy := float64(size)/2, float64(size)/2
|
||
for y := 0; y < size; y++ {
|
||
c := colorAt(y)
|
||
for x := 0; x < size; x++ {
|
||
dx := math.Max(0, math.Abs(float64(x)-cx+0.5)-(float64(size)/2-radius))
|
||
dy := math.Max(0, math.Abs(float64(y)-cy+0.5)-(float64(size)/2-radius))
|
||
d := math.Hypot(dx, dy)
|
||
if d <= radius {
|
||
a := 1.0
|
||
if d > radius-1 {
|
||
a = radius - d
|
||
}
|
||
if a < 0 {
|
||
continue
|
||
}
|
||
if a > 1 {
|
||
a = 1
|
||
}
|
||
cc := c
|
||
cc.A = uint8(float64(cc.A) * a)
|
||
img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func drawCircleRing(img *image.RGBA, cx, cy, rOuter, rInner float64, c color.RGBA) {
|
||
size := img.Bounds().Dx()
|
||
for y := int(cy-rOuter-1) - 1; y <= int(cy+rOuter+1)+1; y++ {
|
||
for x := int(cx-rOuter-1) - 1; x <= int(cx+rOuter+1)+1; x++ {
|
||
if x < 0 || y < 0 || x >= size || y >= size {
|
||
continue
|
||
}
|
||
d := math.Hypot(float64(x)-cx+0.5, float64(y)-cy+0.5)
|
||
if d <= rOuter && d >= rInner {
|
||
a := 1.0
|
||
if d > rOuter-1 {
|
||
a = rOuter - d
|
||
} else if d < rInner+1 {
|
||
a = d - rInner
|
||
}
|
||
if a < 0 {
|
||
continue
|
||
}
|
||
if a > 1 {
|
||
a = 1
|
||
}
|
||
cc := c
|
||
cc.A = uint8(float64(cc.A) * a)
|
||
img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func drawFilledCircle(img *image.RGBA, cx, cy, r float64, c color.RGBA) {
|
||
size := img.Bounds().Dx()
|
||
for y := int(cy-r-1) - 1; y <= int(cy+r+1)+1; y++ {
|
||
for x := int(cx-r-1) - 1; x <= int(cx+r+1)+1; x++ {
|
||
if x < 0 || y < 0 || x >= size || y >= size {
|
||
continue
|
||
}
|
||
d := math.Hypot(float64(x)-cx+0.5, float64(y)-cy+0.5)
|
||
if d <= r {
|
||
a := 1.0
|
||
if d > r-1 {
|
||
a = r - d
|
||
}
|
||
if a < 0 {
|
||
continue
|
||
}
|
||
cc := c
|
||
cc.A = uint8(float64(cc.A) * a)
|
||
img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func drawRadialGlow(img *image.RGBA, cx, cy, r float64, c color.RGBA) {
|
||
size := img.Bounds().Dx()
|
||
for y := int(cy-r-1) - 1; y <= int(cy+r+1)+1; y++ {
|
||
for x := int(cx-r-1) - 1; x <= int(cx+r+1)+1; x++ {
|
||
if x < 0 || y < 0 || x >= size || y >= size {
|
||
continue
|
||
}
|
||
d := math.Hypot(float64(x)-cx+0.5, float64(y)-cy+0.5)
|
||
if d <= r {
|
||
t := 1.0 - d/r
|
||
t = t * t
|
||
cc := c
|
||
cc.A = uint8(float64(cc.A) * t)
|
||
img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func drawLine(img *image.RGBA, x1, y1, x2, y2, width float64, c color.RGBA) {
|
||
size := img.Bounds().Dx()
|
||
r := width / 2
|
||
minX := int(math.Min(x1, x2) - r - 1)
|
||
maxX := int(math.Max(x1, x2) + r + 1)
|
||
minY := int(math.Min(y1, y2) - r - 1)
|
||
maxY := int(math.Max(y1, y2) + r + 1)
|
||
dx := x2 - x1
|
||
dy := y2 - y1
|
||
lenSq := dx*dx + dy*dy
|
||
for y := minY; y <= maxY; y++ {
|
||
for x := minX; x <= maxX; x++ {
|
||
if x < 0 || y < 0 || x >= size || y >= size {
|
||
continue
|
||
}
|
||
px := float64(x) + 0.5
|
||
py := float64(y) + 0.5
|
||
var t float64
|
||
if lenSq > 0 {
|
||
t = ((px-x1)*dx + (py-y1)*dy) / lenSq
|
||
if t < 0 {
|
||
t = 0
|
||
}
|
||
if t > 1 {
|
||
t = 1
|
||
}
|
||
}
|
||
cx := x1 + t*dx
|
||
cy := y1 + t*dy
|
||
d := math.Hypot(px-cx, py-cy)
|
||
if d <= r {
|
||
a := 1.0
|
||
if d > r-1 {
|
||
a = r - d
|
||
}
|
||
if a < 0 {
|
||
continue
|
||
}
|
||
cc := c
|
||
cc.A = uint8(float64(cc.A) * a)
|
||
img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func renderLogo(size int) *image.RGBA {
|
||
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
||
s := float64(size)
|
||
k := s / 1024.0
|
||
|
||
radius := 220.0 * k
|
||
drawRoundedRect(img, size, radius, func(y int) color.RGBA {
|
||
t := float64(y) / s
|
||
return lerpColor(bgTop, bgBottom, t)
|
||
})
|
||
|
||
cx := 512.0 * k
|
||
cy := 512.0 * k
|
||
outerR := 360.0 * k
|
||
outerW := 36.0 * k
|
||
drawCircleRing(img, cx, cy, outerR, outerR-outerW, lensTop)
|
||
drawCircleRing(img, cx, cy, outerR, outerR-outerW/2, lerpColor(lensTop, lensBot, 0.6))
|
||
|
||
midR := 296.0 * k
|
||
midW := 14.0 * k
|
||
drawCircleRing(img, cx, cy, midR, midR-midW, lensMid)
|
||
|
||
glowR := 240.0 * k
|
||
drawRadialGlow(img, cx, cy, glowR, centerGlow)
|
||
|
||
lineW := 44.0 * k
|
||
drawLine(img, 360*k, 360*k, 512*k, 640*k, lineW, white)
|
||
drawLine(img, 664*k, 360*k, 512*k, 640*k, lineW, white)
|
||
|
||
drawFilledCircle(img, 360*k, 360*k, 22*k, mint)
|
||
drawFilledCircle(img, 664*k, 360*k, 22*k, lightBlue)
|
||
drawFilledCircle(img, 512*k, 640*k, 26*k, white)
|
||
|
||
drawFilledCircle(img, 760*k, 264*k, 14*k, mint)
|
||
drawCircleRing(img, 760*k, 264*k, 24*k, 21*k, color.RGBA{0x6E, 0xF3, 0xC5, 0x66})
|
||
|
||
return img
|
||
}
|
||
|
||
func savePNG(img *image.RGBA, path string) error {
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
return err
|
||
}
|
||
f, err := os.Create(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer f.Close()
|
||
return png.Encode(f, img)
|
||
}
|
||
|
||
func main() {
|
||
if len(os.Args) < 2 {
|
||
fmt.Fprintln(os.Stderr, "usage: go run gen_icon.go <output-dir>")
|
||
os.Exit(1)
|
||
}
|
||
outDir := os.Args[1]
|
||
sizes := []int{16, 24, 32, 48, 64, 96, 128, 256, 512, 1024}
|
||
for _, s := range sizes {
|
||
img := renderLogo(s)
|
||
path := filepath.Join(outDir, "icon-"+strconv.Itoa(s)+".png")
|
||
if err := savePNG(img, path); err != nil {
|
||
fmt.Fprintln(os.Stderr, "save", path, err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Println("wrote", path)
|
||
}
|
||
}
|