feat(systray) : Add systray support for windows
This commit is contained in:
+3
-3
@@ -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,6 +1,6 @@
|
||||
# TODO
|
||||
|
||||
- [ ] 修复 html 渲染部分头像缺失的问题
|
||||
- [ ] (No Fix : 本地就没有这个头像导致的) 修复 html 渲染部分头像缺失的问题
|
||||
- [ ] 美化搜索界面使风格一致
|
||||
- [x] 实现统一接口管理设置的功能
|
||||
- [x] 使用 python 重新实现语音转文字功能(或使用 docker 部署 whisper)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user