feat(systray) : Add systray support for windows

This commit is contained in:
Symb0x76
2025-10-16 21:36:26 +08:00
parent 35eebcdc30
commit 4f4a631395
8 changed files with 239 additions and 8 deletions
+3 -3
View File
@@ -24,12 +24,12 @@ go.work.sum
# env file
.env
# icon file
*.ico
# syncthing files
.stfolder
chatlog.exe# Added by goreleaser init:
dist/
# Nightly python remake
tools/
+1 -1
View File
@@ -1,6 +1,6 @@
# TODO
- [ ] 修复 html 渲染部分头像缺失的问题
- [ ] (No Fix : 本地就没有这个头像导致的) 修复 html 渲染部分头像缺失的问题
- [ ] 美化搜索界面使风格一致
- [x] 实现统一接口管理设置的功能
- [x] 使用 python 重新实现语音转文字功能(或使用 docker 部署 whisper)
+10 -1
View File
@@ -8,6 +8,8 @@ require (
github.com/cespare/xxhash v1.1.0
github.com/fsnotify/fsnotify v1.9.0
github.com/gdamore/tcell/v2 v2.8.1
github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20251015072942-4979e04f5dca
github.com/getlantern/systray v1.2.1
github.com/gin-gonic/gin v1.10.1
github.com/google/uuid v1.6.0
github.com/klauspost/compress v1.18.0
@@ -15,7 +17,6 @@ require (
github.com/mattn/go-sqlite3 v1.14.32
github.com/mitchellh/mapstructure v1.5.0
github.com/openai/openai-go/v3 v3.3.0
github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20251015072942-4979e04f5dca
github.com/pierrec/lz4/v4 v4.1.22
github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb
github.com/rs/zerolog v1.34.0
@@ -59,11 +60,18 @@ require (
github.com/ebitengine/purego v0.8.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
@@ -83,6 +91,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
+19
View File
@@ -71,6 +71,20 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.2.1 h1:udsC2k98v2hN359VTFShuQW6GGprRprw6kD6539JikI=
github.com/getlantern/systray v1.2.1/go.mod h1:AecygODWIsBquJCJFop8MEQcJbWFfw/1yWbVabNgpCM=
github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20251015072942-4979e04f5dca h1:7R4qXa1dsEI8cpZyZdFt/o2TlstGI4xmew115XYronI=
github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20251015072942-4979e04f5dca/go.mod h1:qyHjS/50ORo01H0NsuEEGsQR9VCtOcEye0gUl2sx1s8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -88,6 +102,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
@@ -156,6 +172,8 @@ github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/openai/openai-go/v3 v3.3.0 h1:3Xu4+3pJW5HG5hzMflakVbp3IB0uSq70nbLVZZd8AwY=
github.com/openai/openai-go/v3 v3.3.0/go.mod h1:UOpNxkqC9OdNXNUfpNByKOtB4jAL0EssQXq5p8gO0Xs=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
@@ -274,6 +292,7 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+59 -3
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/signal"
"runtime"
"strings"
"sync"
"syscall"
@@ -16,6 +17,7 @@ import (
"github.com/sjzar/chatlog/internal/chatlog/database"
"github.com/sjzar/chatlog/internal/chatlog/http"
"github.com/sjzar/chatlog/internal/chatlog/wechat"
"github.com/sjzar/chatlog/internal/tray"
iwechat "github.com/sjzar/chatlog/internal/wechat"
"github.com/sjzar/chatlog/pkg/config"
"github.com/sjzar/chatlog/pkg/util"
@@ -49,13 +51,18 @@ type Manager struct {
wechat *wechat.Service
// Terminal UI
app *App
app *App
trayCtrl tray.Controller
options RunOptions
initialDecryptOnce sync.Once
initialDecryptMu sync.Mutex
initialDecryptLastErr string
shutdownCh chan struct{}
shutdownOnce sync.Once
shutdownReason string
}
func New() *Manager {
@@ -65,6 +72,7 @@ func New() *Manager {
AutoOpenBrowser: true,
AutoOpenBrowserSet: true,
},
shutdownCh: make(chan struct{}),
}
}
@@ -124,6 +132,25 @@ func (m *Manager) Run(configPath string) error {
}
}
if runtime.GOOS == "windows" {
ctrl, err := tray.Start(tray.Options{
Tooltip: "Chatlog",
OnOpen: func() {
if next := m.webInterfaceURL(); next != "" {
m.launchBrowser(next)
}
},
OnQuit: func() {
m.requestShutdown("tray menu exit")
},
})
if err != nil {
log.Warn().Err(err).Msg("failed to start system tray icon")
} else {
m.trayCtrl = ctrl
}
}
log.Info().Msg("Chatlog is running in headless mode. Press Ctrl+C to exit.")
m.waitForShutdown()
return nil
@@ -141,6 +168,9 @@ func (m *Manager) webInterfaceURL() string {
}
func (m *Manager) launchBrowser(url string) {
if strings.TrimSpace(url) == "" {
return
}
go func() {
time.Sleep(500 * time.Millisecond)
log.Debug().Str("url", url).Msg("launching default browser")
@@ -155,8 +185,19 @@ func (m *Manager) waitForShutdown() {
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(sigCh)
sig := <-sigCh
log.Info().Msgf("received signal %s, shutting down", sig)
var reason string
select {
case sig := <-sigCh:
reason = fmt.Sprintf("received signal %s", sig)
case <-m.shutdownCh:
reason = m.shutdownReason
if reason == "" {
reason = "shutdown requested"
}
}
log.Info().Msgf("%s, shutting down", reason)
m.stopTray()
if m.wechat != nil && m.ctx != nil && m.ctx.IsAutoDecrypt() {
if err := m.wechat.StopAutoDecrypt(); err != nil {
@@ -171,6 +212,21 @@ func (m *Manager) waitForShutdown() {
log.Info().Msg("Shutdown complete")
}
func (m *Manager) requestShutdown(reason string) {
m.shutdownOnce.Do(func() {
m.shutdownReason = reason
close(m.shutdownCh)
})
}
func (m *Manager) stopTray() {
if m.trayCtrl == nil {
return
}
m.trayCtrl.Stop()
m.trayCtrl = nil
}
func (m *Manager) startInitialDecryptWatcher() {
m.initialDecryptOnce.Do(func() {
go m.initialDecryptLoop()
+13
View File
@@ -0,0 +1,13 @@
package tray
// Options controls how the tray icon behaves.
type Options struct {
Tooltip string
OnOpen func()
OnQuit func()
}
// Controller allows callers to stop the tray icon when shutting down.
type Controller interface {
Stop()
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package tray
type noopController struct{}
func (noopController) Stop() {}
// Start is a no-op on platforms without a system tray implementation.
func Start(opts Options) (Controller, error) {
return noopController{}, nil
}
+122
View File
@@ -0,0 +1,122 @@
//go:build windows
package tray
import (
"errors"
"os"
"path/filepath"
"sync"
"github.com/getlantern/systray"
"github.com/rs/zerolog/log"
)
type controller struct {
stopOnce sync.Once
stopped chan struct{}
}
func newController() *controller {
return &controller{stopped: make(chan struct{})}
}
func (c *controller) Stop() {
c.stopOnce.Do(func() {
systray.Quit()
})
<-c.stopped
}
var (
iconInit sync.Once
iconData []byte
iconErr error
)
func trayIcon() ([]byte, error) {
iconInit.Do(func() {
iconData, iconErr = loadIcon()
})
return iconData, iconErr
}
func loadIcon() ([]byte, error) {
const iconName = "icon.ico"
paths := []string{
iconName,
}
exePath, err := os.Executable()
if err == nil {
exeDir := filepath.Dir(exePath)
paths = append(paths, filepath.Join(exeDir, iconName))
}
var errs error
for _, candidate := range paths {
if data, readErr := os.ReadFile(candidate); readErr == nil {
return data, nil
} else {
errs = errors.Join(errs, readErr)
}
}
return nil, errs
}
// Start launches the Windows notification area icon.
func Start(opts Options) (Controller, error) {
ctrl := newController()
ready := make(chan struct{})
go systray.Run(func() {
setupTray(opts, ctrl)
close(ready)
}, func() {
close(ctrl.stopped)
})
<-ready
return ctrl, nil
}
func setupTray(opts Options, ctrl *controller) {
if data, err := trayIcon(); err != nil {
log.Warn().Err(err).Msg("failed to load tray icon from icon.ico")
} else if len(data) > 0 {
systray.SetIcon(data)
} else {
log.Warn().Msg("tray icon icon.ico is empty")
}
tip := opts.Tooltip
if tip == "" {
tip = "Chatlog"
}
systray.SetTooltip(tip)
openItem := systray.AddMenuItem("Open Chatlog", "Open Chatlog web interface")
systray.AddSeparator()
quitItem := systray.AddMenuItem("Exit Chatlog", "Quit Chatlog")
go func() {
for {
select {
case <-openItem.ClickedCh:
if opts.OnOpen != nil {
opts.OnOpen()
}
case <-quitItem.ClickedCh:
if opts.OnQuit != nil {
opts.OnQuit()
}
ctrl.Stop()
return
case <-ctrl.stopped:
return
}
}
}()
}