Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
3 changes: 2 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"code-kanban/utils/tray"
"context"
"embed"
"fmt"
Expand Down Expand Up @@ -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,
Expand Down
167 changes: 167 additions & 0 deletions utils/tray/tray.go
Original file line number Diff line number Diff line change
@@ -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()
}
16 changes: 16 additions & 0 deletions utils/tray/tray_other.go
Original file line number Diff line number Diff line change
@@ -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() {
}
Loading