From 4f4a631395b8f4fa83ea76fe5d084cc5c5b2e1c3 Mon Sep 17 00:00:00 2001 From: Symb0x76 <9667434@qq.com> Date: Thu, 16 Oct 2025 21:36:26 +0800 Subject: [PATCH] feat(systray) : Add systray support for windows --- .gitignore | 6 +- TODO.md | 2 +- go.mod | 11 ++- go.sum | 19 ++++++ internal/chatlog/manager.go | 62 ++++++++++++++++- internal/tray/tray.go | 13 ++++ internal/tray/tray_other.go | 12 ++++ internal/tray/tray_windows.go | 122 ++++++++++++++++++++++++++++++++++ 8 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 internal/tray/tray.go create mode 100644 internal/tray/tray_other.go create mode 100644 internal/tray/tray_windows.go diff --git a/.gitignore b/.gitignore index cd87336..f546c3a 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/TODO.md b/TODO.md index 0c5894a..6f34d1d 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,6 @@ # TODO -- [ ] 修复 html 渲染部分头像缺失的问题 +- [ ] (No Fix : 本地就没有这个头像导致的) 修复 html 渲染部分头像缺失的问题 - [ ] 美化搜索界面使风格一致 - [x] 实现统一接口管理设置的功能 - [x] 使用 python 重新实现语音转文字功能(或使用 docker 部署 whisper) diff --git a/go.mod b/go.mod index 610589f..eb3b171 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index d634876..8bca4cf 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/chatlog/manager.go b/internal/chatlog/manager.go index f41ef6f..dbb816f 100644 --- a/internal/chatlog/manager.go +++ b/internal/chatlog/manager.go @@ -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() diff --git a/internal/tray/tray.go b/internal/tray/tray.go new file mode 100644 index 0000000..154cd85 --- /dev/null +++ b/internal/tray/tray.go @@ -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() +} diff --git a/internal/tray/tray_other.go b/internal/tray/tray_other.go new file mode 100644 index 0000000..9f75540 --- /dev/null +++ b/internal/tray/tray_other.go @@ -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 +} diff --git a/internal/tray/tray_windows.go b/internal/tray/tray_windows.go new file mode 100644 index 0000000..c617af2 --- /dev/null +++ b/internal/tray/tray_windows.go @@ -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 + } + } + }() +}