From 3c66ee3f57a6e5161fa518b45b1b3f5c64e9878e Mon Sep 17 00:00:00 2001 From: Will Norris Date: Wed, 10 Jul 2024 13:45:10 -0700 Subject: [PATCH] cmd/systray: add a basic linux systray app This adds a systray app for linux, similar to the apps for macOS and windows. There are already a number of community-developed systray apps, but most of them are either long abandoned, are built for a specific desktop environment, or simply wrap the tailscale CLI. This uses fyne.io/systray (a fork of github.com/getlantern/systray) which uses newer D-Bus specifications to render the tray icon and menu. This results in a pretty broad support for modern desktop environments. This initial commit lacks a number of features like profile switching, device listing, and exit node selection. This is really focused on the application structure, the interaction with LocalAPI, and some system integration pieces like the app icon, notifications, and the clipboard. Updates #1708 Signed-off-by: Will Norris --- cmd/systray/README.md | 11 ++ cmd/systray/logo.go | 220 +++++++++++++++++++++++++++++++++++++ cmd/systray/systray.go | 240 +++++++++++++++++++++++++++++++++++++++++ go.mod | 4 + go.sum | 8 ++ 5 files changed, 483 insertions(+) create mode 100644 cmd/systray/README.md create mode 100644 cmd/systray/logo.go create mode 100644 cmd/systray/systray.go diff --git a/cmd/systray/README.md b/cmd/systray/README.md new file mode 100644 index 000000000..786434d13 --- /dev/null +++ b/cmd/systray/README.md @@ -0,0 +1,11 @@ +# systray + +The systray command is a minimal Tailscale systray application for Linux. +It is designed to provide quick access to common operations like profile switching +and exit node selection. + +## Supported platforms + +The `fyne.io/systray` package we use supports Windows, macOS, Linux, and many BSDs, +so the systray application will likely work for the most part on those platforms. +Notifications currently only work on Linux, as that is the main target. diff --git a/cmd/systray/logo.go b/cmd/systray/logo.go new file mode 100644 index 000000000..cd79c94a0 --- /dev/null +++ b/cmd/systray/logo.go @@ -0,0 +1,220 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build cgo || !darwin + +package main + +import ( + "bytes" + "context" + "image/color" + "image/png" + "sync" + "time" + + "fyne.io/systray" + "github.com/fogleman/gg" +) + +// tsLogo represents the state of the 3x3 dot grid in the Tailscale logo. +// A 0 represents a gray dot, any other value is a white dot. +type tsLogo [9]byte + +var ( + // disconnected is all gray dots + disconnected = tsLogo{ + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + } + + // connected is the normal Tailscale logo + connected = tsLogo{ + 0, 0, 0, + 1, 1, 1, + 0, 1, 0, + } + + // loading is a special tsLogo value that is not meant to be rendered directly, + // but indicates that the loading animation should be shown. + loading = tsLogo{'l', 'o', 'a', 'd', 'i', 'n', 'g'} + + // loadingIcons are shown in sequence as an animated loading icon. + loadingLogos = []tsLogo{ + { + 0, 1, 1, + 1, 0, 1, + 0, 0, 1, + }, + { + 0, 1, 1, + 0, 0, 1, + 0, 1, 0, + }, + { + 0, 1, 1, + 0, 0, 0, + 0, 0, 1, + }, + { + 0, 0, 1, + 0, 1, 0, + 0, 0, 0, + }, + { + 0, 1, 0, + 0, 0, 0, + 0, 0, 0, + }, + { + 0, 0, 0, + 0, 0, 1, + 0, 0, 0, + }, + { + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + }, + { + 0, 0, 1, + 0, 0, 0, + 0, 0, 0, + }, + { + 0, 0, 0, + 0, 0, 0, + 1, 0, 0, + }, + { + 0, 0, 0, + 0, 0, 0, + 1, 1, 0, + }, + { + 0, 0, 0, + 1, 0, 0, + 1, 1, 0, + }, + { + 0, 0, 0, + 1, 1, 0, + 0, 1, 0, + }, + { + 0, 0, 0, + 1, 1, 0, + 0, 1, 1, + }, + { + 0, 0, 0, + 1, 1, 1, + 0, 0, 1, + }, + { + 0, 1, 0, + 0, 1, 1, + 1, 0, 1, + }, + } +) + +var ( + black = color.NRGBA{0, 0, 0, 255} + white = color.NRGBA{255, 255, 255, 255} + gray = color.NRGBA{255, 255, 255, 102} +) + +// render returns a PNG image of the logo. +func (logo tsLogo) render() *bytes.Buffer { + const radius = 25 + const borderUnits = 1 + dim := radius * (8 + borderUnits*2) + + dc := gg.NewContext(dim, dim) + dc.DrawRectangle(0, 0, float64(dim), float64(dim)) + dc.SetColor(black) + dc.Fill() + + for y := 0; y < 3; y++ { + for x := 0; x < 3; x++ { + px := (borderUnits + 1 + 3*x) * radius + py := (borderUnits + 1 + 3*y) * radius + col := white + if logo[y*3+x] == 0 { + col = gray + } + dc.DrawCircle(float64(px), float64(py), radius) + dc.SetColor(col) + dc.Fill() + } + } + + b := bytes.NewBuffer(nil) + png.Encode(b, dc.Image()) + return b +} + +// setAppIcon renders logo and sets it as the systray icon. +func setAppIcon(icon tsLogo) { + if icon == loading { + startLoadingAnimation() + } else { + stopLoadingAnimation() + systray.SetIcon(icon.render().Bytes()) + } +} + +var ( + loadingMu sync.Mutex // protects loadingCancel + + // loadingCancel stops the loading animation in the systray icon. + // This is nil if the animation is not currently active. + loadingCancel func() +) + +// startLoadingAnimation starts the animated loading icon in the system tray. +// The animation continues until [stopLoadingAnimation] is called. +// If the loading animation is already active, this func does nothing. +func startLoadingAnimation() { + loadingMu.Lock() + defer loadingMu.Unlock() + + if loadingCancel != nil { + // loading icon already displayed + return + } + + ctx := context.Background() + ctx, loadingCancel = context.WithCancel(ctx) + + go func() { + t := time.NewTicker(500 * time.Millisecond) + var i int + for { + select { + case <-ctx.Done(): + return + case <-t.C: + systray.SetIcon(loadingLogos[i].render().Bytes()) + i++ + if i >= len(loadingLogos) { + i = 0 + } + } + } + }() +} + +// stopLoadingAnimation stops the animated loading icon in the system tray. +// If the loading animation is not currently active, this func does nothing. +func stopLoadingAnimation() { + loadingMu.Lock() + defer loadingMu.Unlock() + + if loadingCancel != nil { + loadingCancel() + loadingCancel = nil + } +} diff --git a/cmd/systray/systray.go b/cmd/systray/systray.go new file mode 100644 index 000000000..623caabc4 --- /dev/null +++ b/cmd/systray/systray.go @@ -0,0 +1,240 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build cgo || !darwin + +// The systray command is a minimal Tailscale systray application for Linux. +package main + +import ( + "context" + "fmt" + "io" + "log" + "os" + "strings" + "sync" + "time" + + "fyne.io/systray" + "github.com/atotto/clipboard" + dbus "github.com/godbus/dbus/v5" + "github.com/toqueteos/webbrowser" + "tailscale.com/client/tailscale" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" +) + +var ( + localClient tailscale.LocalClient + chState chan ipn.State // tailscale state changes + + appIcon *os.File +) + +func main() { + systray.Run(onReady, onExit) +} + +// Menu represents the systray menu, its items, and the current Tailscale state. +type Menu struct { + mu sync.Mutex // protects the entire Menu + status *ipnstate.Status + + connect *systray.MenuItem + disconnect *systray.MenuItem + + self *systray.MenuItem + more *systray.MenuItem + quit *systray.MenuItem + + eventCancel func() // cancel eventLoop +} + +func onReady() { + log.Printf("starting") + ctx := context.Background() + + setAppIcon(disconnected) + + // dbus wants a file path for notification icons, so copy to a temp file. + appIcon, _ = os.CreateTemp("", "tailscale-systray.png") + io.Copy(appIcon, connected.render()) + + chState = make(chan ipn.State, 1) + + status, err := localClient.Status(ctx) + if err != nil { + log.Print(err) + } + + menu := new(Menu) + menu.rebuild(status) + + go watchIPNBus(ctx) +} + +// rebuild the systray menu based on the current Tailscale state. +// +// We currently rebuild the entire menu because it is not easy to update the existing menu. +// You cannot iterate over the items in a menu, nor can you remove some items like separators. +// So for now we rebuild the whole thing, and can optimize this later if needed. +func (menu *Menu) rebuild(status *ipnstate.Status) { + menu.mu.Lock() + defer menu.mu.Unlock() + + if menu.eventCancel != nil { + menu.eventCancel() + } + menu.status = status + systray.ResetMenu() + + menu.connect = systray.AddMenuItem("Connect", "") + menu.disconnect = systray.AddMenuItem("Disconnect", "") + menu.disconnect.Hide() + systray.AddSeparator() + + if status != nil && status.Self != nil { + title := fmt.Sprintf("This Device: %s (%s)", status.Self.HostName, status.Self.TailscaleIPs[0]) + menu.self = systray.AddMenuItem(title, "") + } + systray.AddSeparator() + + menu.more = systray.AddMenuItem("More settings", "") + menu.more.Enable() + + menu.quit = systray.AddMenuItem("Quit", "Quit the app") + menu.quit.Enable() + + ctx := context.Background() + ctx, menu.eventCancel = context.WithCancel(ctx) + go menu.eventLoop(ctx) +} + +// eventLoop is the main event loop for handling click events on menu items +// and responding to Tailscale state changes. +// This method does not return until ctx.Done is closed. +func (menu *Menu) eventLoop(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case state := <-chState: + switch state { + case ipn.Running: + setAppIcon(loading) + status, err := localClient.Status(ctx) + if err != nil { + log.Printf("error getting tailscale status: %v", err) + } + menu.rebuild(status) + setAppIcon(connected) + menu.connect.SetTitle("Connected") + menu.connect.Disable() + menu.disconnect.Show() + menu.disconnect.Enable() + case ipn.NoState, ipn.Stopped: + menu.connect.SetTitle("Connect") + menu.connect.Enable() + menu.disconnect.Hide() + setAppIcon(disconnected) + case ipn.Starting: + setAppIcon(loading) + } + case <-menu.connect.ClickedCh: + _, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ + Prefs: ipn.Prefs{ + WantRunning: true, + }, + WantRunningSet: true, + }) + if err != nil { + log.Print(err) + continue + } + + case <-menu.disconnect.ClickedCh: + _, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ + Prefs: ipn.Prefs{ + WantRunning: false, + }, + WantRunningSet: true, + }) + if err != nil { + log.Printf("disconnecting: %v", err) + continue + } + + case <-menu.self.ClickedCh: + copyTailscaleIP(menu.status.Self) + + case <-menu.more.ClickedCh: + webbrowser.Open("http://100.100.100.100/") + + case <-menu.quit.ClickedCh: + systray.Quit() + } + } +} + +// watchIPNBus subscribes to the tailscale event bus and sends state updates to chState. +// This method does not return. +func watchIPNBus(ctx context.Context) { + watcher, err := localClient.WatchIPNBus(ctx, ipn.NotifyInitialState) + if err != nil { + log.Printf("watching ipn bus: %v", err) + } + defer watcher.Close() + for { + select { + case <-ctx.Done(): + return + default: + n, err := watcher.Next() + if err != nil { + log.Printf("ipnbus error: %v", err) + } + if n.State != nil { + chState <- *n.State + log.Printf("new state: %v", n.State) + } + } + } +} + +// copyTailscaleIP copies the first Tailscale IP of the given device to the clipboard +// and sends a notification with the copied value. +func copyTailscaleIP(device *ipnstate.PeerStatus) { + if device == nil || len(device.TailscaleIPs) == 0 { + return + } + name := strings.Split(device.DNSName, ".")[0] + ip := device.TailscaleIPs[0].String() + err := clipboard.WriteAll(ip) + if err != nil { + log.Printf("clipboard error: %v", err) + } + + sendNotification(fmt.Sprintf("Copied Address for %v", name), ip) +} + +// sendNotification sends a desktop notification with the given title and content. +func sendNotification(title, content string) { + conn, err := dbus.SessionBus() + if err != nil { + log.Printf("dbus: %v", err) + return + } + timeout := 3 * time.Second + obj := conn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications") + call := obj.Call("org.freedesktop.Notifications.Notify", 0, "Tailscale", uint32(0), + appIcon.Name(), title, content, []string{}, map[string]dbus.Variant{}, int32(timeout.Milliseconds())) + if call.Err != nil { + log.Printf("dbus: %v", call.Err) + } +} + +func onExit() { + log.Printf("exiting") + os.Remove(appIcon.Name()) +} diff --git a/go.mod b/go.mod index 7cc69c58c..04ab3c414 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,12 @@ go 1.23 require ( filippo.io/mkcert v1.4.4 + fyne.io/systray v1.11.0 github.com/akutz/memconn v0.1.0 github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa github.com/andybalholm/brotli v1.1.0 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be + github.com/atotto/clipboard v0.1.4 github.com/aws/aws-sdk-go-v2 v1.24.1 github.com/aws/aws-sdk-go-v2/config v1.26.5 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.64 @@ -28,6 +30,7 @@ require ( github.com/dsnet/try v0.0.3 github.com/elastic/crd-ref-docs v0.0.12 github.com/evanw/esbuild v0.19.11 + github.com/fogleman/gg v1.3.0 github.com/frankban/quicktest v1.14.6 github.com/fxamacker/cbor/v2 v2.6.0 github.com/gaissmai/bart v0.11.1 @@ -130,6 +133,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/gobuffalo/flect v1.0.2 // indirect github.com/goccy/go-yaml v1.12.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd // indirect github.com/gorilla/securecookie v1.1.2 // indirect diff --git a/go.sum b/go.sum index 2bea01542..dfe0b0e54 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= +fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/Abirdcfly/dupword v0.0.11 h1:z6v8rMETchZXUIuHxYNmlUAuKuB21PeaSymTed16wgU= github.com/Abirdcfly/dupword v0.0.11/go.mod h1:wH8mVGuf3CP5fsBTkfWwwwKTjDnVVCxtU8d8rgeVYXA= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= @@ -112,6 +114,8 @@ github.com/ashanbrown/forbidigo v1.5.1 h1:WXhzLjOlnuDYPYQo/eFlcFMi8X/kLfvWLYu6CS github.com/ashanbrown/forbidigo v1.5.1/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU= github.com/ashanbrown/makezero v1.1.1 h1:iCQ87C0V0vSyO+M9E/FZYbu65auqH0lnsOkf5FcB28s= github.com/ashanbrown/makezero v1.1.1/go.mod h1:i1bJLCRSCHOcOa9Y6MyF2FTfMZMFdHvxKHxgO5Z1axI= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.18.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= @@ -306,6 +310,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/firefart/nonamedreturns v1.0.4 h1:abzI1p7mAEPYuR4A+VLKn4eNDOycjYo2phmY9sfv40Y= github.com/firefart/nonamedreturns v1.0.4/go.mod h1:TDhe/tjI1BXo48CmYbUduTV7BdIga8MAO/xbKdcVsGI= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -400,6 +406,8 @@ github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14j github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=