diff --git a/go.mod b/go.mod index 0a089c1..ad0bac9 100644 --- a/go.mod +++ b/go.mod @@ -50,11 +50,13 @@ require ( github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gauss1190/systray v0.0.0-20230612161432-8d0bc4a665a2 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.1 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/godbus/dbus/v5 v5.0.4 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/uuid v1.6.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect @@ -76,6 +78,7 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.0 // indirect + github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/go.sum b/go.sum index 9cdefdf..b96c0ac 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/fy0/huma/v2 v2.0.0-20250928113553-954c3a7f416c h1:dJjWz7d7t+RAnYsdh7f github.com/fy0/huma/v2 v2.0.0-20250928113553-954c3a7f416c/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk= github.com/fy0/vt10x v0.0.0-20251129150011-c2f2317a3188 h1:6ZjTMGuiXii31azklRss1yQvD0WCjLTSi4Co3e2EI54= github.com/fy0/vt10x v0.0.0-20251129150011-c2f2317a3188/go.mod h1:kypKQ2Vd/oUOxC2dX6DFMG6FktzHsrYs+BF35RTA7p8= +github.com/gauss1190/systray v0.0.0-20230612161432-8d0bc4a665a2 h1:dbyZGnGqZvujiy+iG4zZuUfyKXUmbrJ3dqX2vw8ikco= +github.com/gauss1190/systray v0.0.0-20230612161432-8d0bc4a665a2/go.mod h1:1/wpC70S5pPBwpGv3aLb9QSuky5yQlCFoeNAnboVfvY= github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= @@ -68,6 +70,8 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -167,6 +171,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c h1:coVla7zpsycc+kA9NXpcvv2E4I7+ii6L5hZO2S6C3kw= +github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= @@ -203,6 +209,7 @@ golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/main.go b/main.go index f3cfd91..4282e12 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "code-kanban/utils/tray" "context" "embed" "fmt" @@ -110,7 +111,7 @@ func run(forceMigrate bool, bind string, port int) { }(url) } } - + tray.StartTray(cfg) ctx := utils.ContextWithLogger(context.Background(), logger) if err := api.Init(ctx, cfg, embedStatic, &api.AppInfo{ Name: APPNAME, diff --git a/utils/tray/tray.go b/utils/tray/tray.go new file mode 100644 index 0000000..0a33462 --- /dev/null +++ b/utils/tray/tray.go @@ -0,0 +1,167 @@ +//go:build windows || darwin + +package tray + +import ( + "bytes" + "encoding/binary" + "fmt" + "image" + "image/color" + "runtime" + "syscall" + "time" + + "code-kanban/utils" + + systray "github.com/gauss1190/systray" +) + +// StartTray 启动系统托盘,仅提供「打开页面」与「退出」两个菜单项,并支持双击图标打开页面。 +// 该实现基于跨平台的 systray 库,确保在 Windows/macOS/Linux 行为一致。 +func StartTray(cfg *utils.AppConfig) { + runtime.LockOSThread() + systray.Run(func() { onReady(cfg) }, onExit) +} + +// StopTray 退出托盘。 +func StopTray() { + systray.Quit() +} + +func onReady(cfg *utils.AppConfig) { + // 标题与提示信息 + title := cfg.APITitle + if title == "" { + title = "CodeKanban" + } + systray.SetTitle(title) + systray.SetTooltip("点击菜单或双击图标打开页面") + + // 尝试设置图标(可选)。当无法提供图标时,托盘仍可工作。 + if icon := defaultIconBytes(); len(icon) > 0 { + systray.SetIcon(icon) + } + + // 菜单:打开页面、退出 + mOpen := systray.AddMenuItem("打开页面", "打开代码看板") + mQuit := systray.AddMenuItem("退出", "退出应用程序") + + // 双击图标直接打开页面 + systray.SetOnDClick(func() { openProjectPage(cfg) }) + + mOpen.Click(func() { openProjectPage(cfg) }) + mQuit.Click(func() { systray.Quit() }) +} + +func onExit() { + // 不知道为啥咋程序关闭托盘自己不退出呢? + syscall.Exit(0) +} + +func openProjectPage(cfg *utils.AppConfig) { + target := utils.BuildLaunchURL(cfg) + if target == "" { + fmt.Println("无法解析页面地址:配置为空或无效") + return + } + // 稍作延时,避免部分平台上托盘事件与浏览器启动竞争导致失败 + time.Sleep(200 * time.Millisecond) + if err := utils.OpenBrowser(target); err != nil { + fmt.Printf("打开浏览器失败:%v\n", err) + } +} + +// defaultIconBytes 返回一个最小的 .ico 图标字节(Windows 需要 .ico,其他平台支持 .ico/.png)。 +// 若返回空切片,托盘仍会工作,只是使用系统默认或不显示图标。 +// defaultIconBytes 返回一个 "CB" 文字的 16x16 图标字节 +func defaultIconBytes() []byte { + // 创建 16x16 的 RGBA 图像 + img := image.NewRGBA(image.Rect(0, 0, 16, 16)) + + // 填充蓝色背景 + bgColor := color.RGBA{G: 120, B: 212, A: 255} // Windows蓝色 + for y := 0; y < 16; y++ { + for x := 0; x < 16; x++ { + img.Set(x, y, bgColor) + } + } + + // 用简单点阵绘制 "CB" + // 在 16x16 中绘制简单的 "C" + cPoints := []struct{ x, y int }{ + {3, 5}, {3, 6}, {3, 7}, {3, 8}, {3, 9}, {3, 10}, + {4, 4}, {5, 4}, {6, 4}, + {4, 11}, {5, 11}, {6, 11}, + } + + // 绘制 "B" + bPoints := []struct{ x, y int }{ + {9, 4}, {9, 5}, {9, 6}, {9, 7}, {9, 8}, {9, 9}, {9, 10}, {9, 11}, + {10, 4}, {11, 5}, {12, 6}, {12, 7}, + {10, 7}, {11, 7}, {12, 8}, {12, 9}, {11, 10}, {10, 11}, + } + + // 绘制白色文字 + textColor := color.White + for _, p := range cPoints { + if p.x < 16 && p.y < 16 { + img.Set(p.x, p.y, textColor) + } + } + for _, p := range bPoints { + if p.x < 16 && p.y < 16 { + img.Set(p.x, p.y, textColor) + } + } + + // 将图片写入缓冲区 + var buf bytes.Buffer + + // 创建简单的 ICO 文件 + // ICO 文件头 + _ = binary.Write(&buf, binary.LittleEndian, uint16(0)) // 保留字 + _ = binary.Write(&buf, binary.LittleEndian, uint16(1)) // 图标类型 + _ = binary.Write(&buf, binary.LittleEndian, uint16(1)) // 图标数量 + + // 图标目录项 + _ = binary.Write(&buf, binary.LittleEndian, byte(16)) // 宽度 + _ = binary.Write(&buf, binary.LittleEndian, byte(16)) // 高度 + _ = binary.Write(&buf, binary.LittleEndian, byte(0)) // 颜色数 + _ = binary.Write(&buf, binary.LittleEndian, byte(0)) // 保留 + _ = binary.Write(&buf, binary.LittleEndian, uint16(1)) // 颜色平面 + _ = binary.Write(&buf, binary.LittleEndian, uint16(32)) // 每像素位数 + _ = binary.Write(&buf, binary.LittleEndian, uint32(16*16*4+40)) // 图像数据大小 + _ = binary.Write(&buf, binary.LittleEndian, uint32(22)) // 图像数据偏移 + + // BITMAPINFOHEADER + _ = binary.Write(&buf, binary.LittleEndian, uint32(40)) // 头大小 + _ = binary.Write(&buf, binary.LittleEndian, int32(16)) // 宽度 + _ = binary.Write(&buf, binary.LittleEndian, int32(32)) // 高度*2(包含掩码) + _ = binary.Write(&buf, binary.LittleEndian, uint16(1)) // 平面数 + _ = binary.Write(&buf, binary.LittleEndian, uint16(32)) // 每像素位数 + _ = binary.Write(&buf, binary.LittleEndian, uint32(0)) // 压缩方式 + _ = binary.Write(&buf, binary.LittleEndian, uint32(16*16*4)) // 图像数据大小 + _ = binary.Write(&buf, binary.LittleEndian, int32(0)) // 水平分辨率 + _ = binary.Write(&buf, binary.LittleEndian, int32(0)) // 垂直分辨率 + _ = binary.Write(&buf, binary.LittleEndian, uint32(0)) // 使用颜色数 + _ = binary.Write(&buf, binary.LittleEndian, uint32(0)) // 重要颜色数 + + // 写入像素数据(BGR A) + for y := 15; y >= 0; y-- { + for x := 0; x < 16; x++ { + r, g, b, a := img.At(x, y).RGBA() + buf.WriteByte(byte(b >> 8)) // 蓝 + buf.WriteByte(byte(g >> 8)) // 绿 + buf.WriteByte(byte(r >> 8)) // 红 + buf.WriteByte(byte(a >> 8)) // 透明度 + } + } + + // 掩码数据(1位/像素,全0表示不透明) + for i := 0; i < 16*16/8; i++ { + buf.WriteByte(0) + } + + return buf.Bytes() +} diff --git a/utils/tray/tray_other.go b/utils/tray/tray_other.go new file mode 100644 index 0000000..626d874 --- /dev/null +++ b/utils/tray/tray_other.go @@ -0,0 +1,16 @@ +//go:build !windows && !darwin +// +build !windows,!darwin + +package tray + +import ( + "code-kanban/utils" +) + +func StartTray(_ *utils.AppConfig) { + // 什么也不做,Linux大部分时候也不需要托盘 +} + +// StopTray 退出托盘。 +func StopTray() { +}