diff --git a/go.mod b/go.mod index 17c71c1..d593303 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/verstak/verstak-desktop go 1.24.4 require ( + github.com/getlantern/systray v1.2.2 github.com/google/uuid v1.6.0 github.com/wailsapp/wails/v2 v2.12.0 golang.org/x/net v0.35.0 @@ -11,7 +12,14 @@ require ( require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/bep/debounce v1.2.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/go-ole/go-ole v1.3.0 // indirect + github.com/go-stack/stack v1.8.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect @@ -23,6 +31,7 @@ require ( github.com/leaanthony/u v1.1.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index b5aa9ee..7e18663 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,27 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE= +github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +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/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -28,6 +45,8 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/ github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= @@ -36,6 +55,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -47,6 +68,9 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= @@ -67,6 +91,7 @@ golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -79,5 +104,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/app.go b/internal/api/app.go index 5ffeac1..68b0145 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -42,6 +42,9 @@ var emitFrontendEvent = runtime.EventsEmit var initializeNativeNotifications = runtime.InitializeNotifications var cleanupNativeNotifications = runtime.CleanupNotifications var sendNativeNotification = runtime.SendNotification +var hideNativeWindow = runtime.WindowHide +var showNativeWindow = runtime.WindowShow +var quitNativeApplication = runtime.Quit type notificationService interface { Replace(pluginID string, requests []notifications.Request) error @@ -96,6 +99,7 @@ type App struct { activityEvents map[string]bool browserInboxEvents map[string]bool browserInboxEnabled atomic.Bool + allowQuit atomic.Bool } // SetNotificationService attaches the core-owned plugin notification scheduler. @@ -217,6 +221,34 @@ func (a *App) Shutdown(ctx context.Context) { cleanupNativeNotifications(ctx) } +// BeforeClose hides the primary window until the user explicitly quits from the tray. +func (a *App) BeforeClose(ctx context.Context) bool { + if a.allowQuit.Load() { + return false + } + hideNativeWindow(ctx) + return true +} + +// ShowWindow brings the primary window back from the tray. +func (a *App) ShowWindow() { + if a == nil || a.ctx == nil { + return + } + showNativeWindow(a.ctx) +} + +// Quit allows the close event and ends the application process. +func (a *App) Quit() { + if a == nil { + return + } + a.allowQuit.Store(true) + if a.ctx != nil { + quitNativeApplication(a.ctx) + } +} + // NativeNotificationSender delivers scheduler items through the Wails runtime. type NativeNotificationSender struct{} diff --git a/internal/api/app_test.go b/internal/api/app_test.go index ce6ffbe..5746784 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -306,6 +306,55 @@ func TestNativeNotificationSenderUsesStablePluginScopedID(t *testing.T) { } } +func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) { + oldHide := hideNativeWindow + defer func() { hideNativeWindow = oldHide }() + + hideCalls := 0 + hideNativeWindow = func(context.Context) { hideCalls++ } + app := &App{} + + if prevent := app.BeforeClose(context.Background()); !prevent { + t.Fatal("BeforeClose() = false, want true while tray mode is active") + } + if hideCalls != 1 { + t.Fatalf("hide calls = %d, want 1", hideCalls) + } +} + +func TestTrayQuitAllowsWindowCloseAndQuitsApplication(t *testing.T) { + oldQuit := quitNativeApplication + defer func() { quitNativeApplication = oldQuit }() + + quitCalls := 0 + quitNativeApplication = func(context.Context) { quitCalls++ } + app := &App{ctx: context.Background()} + + app.Quit() + + if quitCalls != 1 { + t.Fatalf("quit calls = %d, want 1", quitCalls) + } + if prevent := app.BeforeClose(context.Background()); prevent { + t.Fatal("BeforeClose() = true after Quit(), want false") + } +} + +func TestShowWindowUsesWailsContext(t *testing.T) { + oldShow := showNativeWindow + defer func() { showNativeWindow = oldShow }() + + showCalls := 0 + showNativeWindow = func(context.Context) { showCalls++ } + app := &App{ctx: context.Background()} + + app.ShowWindow() + + if showCalls != 1 { + t.Fatalf("show calls = %d, want 1", showCalls) + } +} + // TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that // GetPluginFrontendInfo returns correct metadata for a plugin with a frontend. func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) { diff --git a/internal/shell/tray/controller.go b/internal/shell/tray/controller.go new file mode 100644 index 0000000..ee88d91 --- /dev/null +++ b/internal/shell/tray/controller.go @@ -0,0 +1,74 @@ +// Package tray owns the native tray menu wiring for the desktop shell. +package tray + +import "sync" + +// MenuItem exposes click events from a native tray menu item. +type MenuItem interface { + Clicked() <-chan struct{} +} + +// Backend is the platform tray implementation. +type Backend interface { + Register(onReady func(), onExit func()) + SetIcon(icon []byte) + SetTooltip(tooltip string) + AddMenuItem(title, tooltip string) MenuItem + Quit() +} + +// Actions are executed from the native tray menu. +type Actions struct { + Show func() + Quit func() +} + +// Controller initializes one native tray and routes its menu actions. +type Controller struct { + backend Backend + icon []byte + start sync.Once +} + +// New creates a tray controller for one application process. +func New(backend Backend, icon []byte) *Controller { + return &Controller{ + backend: backend, + icon: append([]byte(nil), icon...), + } +} + +// Start registers the native tray without taking over the Wails event loop. +func (c *Controller) Start(actions Actions) { + if c == nil || c.backend == nil { + return + } + c.start.Do(func() { + c.backend.Register(func() { + c.backend.SetIcon(c.icon) + c.backend.SetTooltip("Verstak") + show := c.backend.AddMenuItem("Show Verstak", "Show the Verstak window") + quit := c.backend.AddMenuItem("Quit", "Quit Verstak") + if actions.Show != nil && show != nil { + go routeClicks(show.Clicked(), actions.Show) + } + if actions.Quit != nil && quit != nil { + go routeClicks(quit.Clicked(), actions.Quit) + } + }, nil) + }) +} + +// Stop releases the native tray after Wails has begun application shutdown. +func (c *Controller) Stop() { + if c == nil || c.backend == nil { + return + } + c.backend.Quit() +} + +func routeClicks(clicked <-chan struct{}, action func()) { + for range clicked { + action() + } +} diff --git a/internal/shell/tray/controller_test.go b/internal/shell/tray/controller_test.go new file mode 100644 index 0000000..ab1de58 --- /dev/null +++ b/internal/shell/tray/controller_test.go @@ -0,0 +1,91 @@ +package tray + +import ( + "testing" + "time" +) + +type fakeMenuItem struct { + clicked chan struct{} +} + +func (i *fakeMenuItem) Clicked() <-chan struct{} { + return i.clicked +} + +type fakeBackend struct { + icon []byte + tooltip string + items map[string]*fakeMenuItem + quitCalls int + registering bool +} + +func (b *fakeBackend) Register(onReady func(), _ func()) { + b.registering = true + onReady() +} + +func (b *fakeBackend) SetIcon(icon []byte) { + b.icon = append([]byte(nil), icon...) +} + +func (b *fakeBackend) SetTooltip(tooltip string) { + b.tooltip = tooltip +} + +func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem { + item := &fakeMenuItem{clicked: make(chan struct{}, 1)} + b.items[title] = item + return item +} + +func (b *fakeBackend) Quit() { + b.quitCalls++ +} + +func waitFor(t *testing.T, signal <-chan struct{}) { + t.Helper() + select { + case <-signal: + case <-time.After(time.Second): + t.Fatal("timed out waiting for tray action") + } +} + +func TestControllerInitializesTrayAndRoutesMenuActions(t *testing.T) { + backend := &fakeBackend{items: make(map[string]*fakeMenuItem)} + showCalls := make(chan struct{}, 1) + quitCalls := make(chan struct{}, 1) + controller := New(backend, []byte{1, 2, 3}) + + controller.Start(Actions{ + Show: func() { showCalls <- struct{}{} }, + Quit: func() { quitCalls <- struct{}{} }, + }) + + if !backend.registering || string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" { + t.Fatalf("tray initialization = registering:%t icon:%v tooltip:%q", backend.registering, backend.icon, backend.tooltip) + } + showItem := backend.items["Show Verstak"] + quitItem := backend.items["Quit"] + if showItem == nil || quitItem == nil { + t.Fatalf("tray menu = %#v, want Show Verstak and Quit", backend.items) + } + + showItem.clicked <- struct{}{} + waitFor(t, showCalls) + quitItem.clicked <- struct{}{} + waitFor(t, quitCalls) +} + +func TestControllerStopsNativeTrayBackend(t *testing.T) { + backend := &fakeBackend{items: make(map[string]*fakeMenuItem)} + controller := New(backend, []byte{1}) + + controller.Stop() + + if backend.quitCalls != 1 { + t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls) + } +} diff --git a/internal/shell/tray/icon.go b/internal/shell/tray/icon.go new file mode 100644 index 0000000..0a10bcc --- /dev/null +++ b/internal/shell/tray/icon.go @@ -0,0 +1,14 @@ +package tray + +import "encoding/base64" + +const iconPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAACT1BMVEUAAACOjo7///8RERHY2NghISEDAwOjo6MODg7GxsYQEBDIyMgPDw/Hx8cKCgq9vb12dnaHh4fFxcV1dXWjo6OoqKinp6eNjY25ubnr6+v29vb29vb29vbx8fHV1dXm5ub9/f3z8/OUlJQAAADu7u74+Pi5ubkAAADu7u74+Pi6urq6urrs7Oz39/ezs7PZ2dn5+fn8/Pzq6uobGxtdXV3U1NTl5eXl5eXl5eXm5ubd3d2urq7////+/v7//v7//v/s7OzLy8vLy8ru7u708/POzc3Kysrd3d39/f3Z2dnU1NT7+/u0tLQpKSkoKCcnJycsLCy+vr63t7crKyskJCRzc3NQUFAlJSUoKChvb28gICAdHR0cHBzDwsJ6enoaGhkZGRltbW3MzMwqKioeHh6pqam+vb0iIiJGRkYbGxseHR0aGhpubm6cnJw6Ojrg4ODExMQmJiYeHh36+vppaWhxcXH8/PwvLy/a2tmKiorl5eU+Pj6zs7PQ0NA1NTXR0dFSUlIbGhq/v79BQUHn5+fW1tYuLi48PDyysrItLS02NjaNjY17e3v///7b29syMjJ/f38hISEqKSlqampaWlq9vb3f398cGxs9PT1KSko3NzeioqJRUVFJSUnk5OQaGRljY2OGhobp6ek/Pz+dnZ3IyMgoKSjHx8ZDQ0PHx8fx8fHw8PBVVVX39/cdHh2RkZHz8/NPT0+Ojo7Dw8PPz8/29vZUVFTCwsL19fX4+PhYWFgWFhYXFxfAwMCqqqqFhYWHhoaEhITe3t6MjIzi4uIwLXXJAAAAPHRSTlMAAAAAAAAAAAAAAAAAAAAAAAAABRIWFQoUis3R0K82b/22DQGZ2x0Cm9wfHo/UGULi+IIEAzZudHNzUA0lLC8OAAAAAWJLR0QCZgt8ZAAAAAd0SU1FB+oHDRIyN3EuEOkAAAHzSURBVDjLvdPXXxNBEAdwPHvvFewde8WyG3NGT41KlFxMwoyKguLeCCZRUCzRgIXYUexEjR17713/MPfCB+Vy8ZXfw+3tzvdlbueyslolbfr26z8gQwYqbVP1doMGD8nOsSU7Z+gwpb2sd1CGj2CZM3JURwk6jR4j37l5wDlvejrkssjBxo7rLEGX8RPk1qmqi1UXW7JU05bx5Ss0VXOvZBNzu/4Fq1bne9asZQVej+7jrnX+gB6UYNI/UFAIqK9nGzZC0Sa2uRihZAuzgK2lAB7VtU0YtJ2VIWD5DisI+QG87lAYIhTcuYsiVFFpBVo+4O49VQKAKvbuE1C6XzbWEhwoBowePBSDEvJV+0HUHE4DR44iHquN43G9yHdCROgkP2UFp8+gqDkLdcFzdL6ewH9Bfi8LuBjFS5ev0NVrAdEQRZFwpoPr5QiB8I2b1WHw1oFIMkcauHVbGAB4565sF+De/dS9tATOB8IwMO5uNEGs4aENPEpIQI+fhHR4CvjMrFvB8xcxA8RL9kqHCMZf28Gbt2RAoJG9k4Dq39vBh4+f6HPCxb4UElFZqm4FlV+/ff9Ry9nPqmTy128L6GYCR/OwNY9eE8jtLkEPZTL/z9BOmdrTHHtl2nSeMTNmzuplgt6z58zNm2dL3vwFC/u0yo/7B9C02RGfGBOeAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTA3LTEzVDE0OjIyOjMzKzAwOjAwJHU36gAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wNy0xM1QxNDoyMjozMyswMDowMFUoj1YAAAAASUVORK5CYII=" + +// DefaultIcon returns the compact source-controlled Verstak tray icon. +func DefaultIcon() []byte { + icon, err := base64.StdEncoding.DecodeString(iconPNGBase64) + if err != nil { + return nil + } + return icon +} diff --git a/internal/shell/tray/systray_backend.go b/internal/shell/tray/systray_backend.go new file mode 100644 index 0000000..34b26db --- /dev/null +++ b/internal/shell/tray/systray_backend.go @@ -0,0 +1,41 @@ +package tray + +import "github.com/getlantern/systray" + +type systrayBackend struct{} + +type systrayMenuItem struct { + item *systray.MenuItem +} + +// NewNativeBackend creates the cross-platform native tray backend. +func NewNativeBackend() Backend { + return systrayBackend{} +} + +func (systrayBackend) Register(onReady func(), onExit func()) { + systray.Register(onReady, onExit) +} + +func (systrayBackend) SetIcon(icon []byte) { + systray.SetIcon(icon) +} + +func (systrayBackend) SetTooltip(tooltip string) { + systray.SetTooltip(tooltip) +} + +func (systrayBackend) AddMenuItem(title, tooltip string) MenuItem { + return systrayMenuItem{item: systray.AddMenuItem(title, tooltip)} +} + +func (systrayBackend) Quit() { + systray.Quit() +} + +func (item systrayMenuItem) Clicked() <-chan struct{} { + if item.item == nil { + return nil + } + return item.item.ClickedCh +} diff --git a/main.go b/main.go index 8464ba8..929fc81 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "embed" "log" "os" @@ -26,6 +27,7 @@ import ( "github.com/verstak/verstak-desktop/internal/core/vault" "github.com/verstak/verstak-desktop/internal/core/workspace" "github.com/verstak/verstak-desktop/internal/shell/debug" + "github.com/verstak/verstak-desktop/internal/shell/tray" ) //go:embed frontend/dist @@ -236,6 +238,7 @@ func main() { } app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled) app.SetNotificationService(notifications.New(vaultService, api.NewNativeNotificationSender(), time.Now)) + trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon()) if browserReceiver != nil { browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver) if err != nil { @@ -255,8 +258,21 @@ func main() { MinHeight: 600, WindowStartState: options.Normal, OnStartup: app.Startup, - OnDomReady: app.DomReady, - OnShutdown: app.Shutdown, + OnDomReady: func(ctx context.Context) { + app.DomReady(ctx) + trayController.Start(tray.Actions{Show: app.ShowWindow, Quit: app.Quit}) + }, + OnShutdown: func(ctx context.Context) { + trayController.Stop() + app.Shutdown(ctx) + }, + OnBeforeClose: app.BeforeClose, + SingleInstanceLock: &options.SingleInstanceLock{ + UniqueId: "605fba28-7cbf-4f14-9d1b-a4da0c1723f8", + OnSecondInstanceLaunch: func(options.SecondInstanceData) { + app.ShowWindow() + }, + }, AssetServer: &assetserver.Options{ Assets: assets, },