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=