cmd/systray: WIP of a 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 is a work in progress. Some things are still half-built, this is
just where I stopped to take a break.

 - fast user switching works
 - connect and disconnecting work, and show correct icon (including the
   animated loading icon while connecting)
 - devices menu works, but seems to have issues with really large menus,
   either crashing or rending off-screen with no scroll option.
 - clipboard and notification integration works to copy device IPs
 - exit node menu is built, but currently non-functional (this is where I
   stopped). Exit nodes are not re-rendered when switching profiles.

The code is currently "okay", but certainly needs cleanup, docs, etc.

Signed-off-by: Will Norris <will@tailscale.com>
This commit is contained in:
Will Norris 2024-07-10 13:45:10 -07:00
parent c8f258a904
commit 8bf6bb7c24
21 changed files with 508 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

502
cmd/systray/systray.go Normal file
View File

@ -0,0 +1,502 @@
package main
import (
"context"
"embed"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
"fyne.io/systray"
"github.com/atotto/clipboard"
dbus "github.com/godbus/dbus/v5"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/set"
)
var (
localClient tailscale.LocalClient
chState chan ipn.State
menu Menu
mu sync.Mutex // mu protects status
status *ipnstate.Status
appIcon *os.File
loadingDone chan struct{}
)
//go:embed icons/*
var iconFS embed.FS
func main() {
systray.Run(onReady, onExit)
}
type Menu struct {
connect *systray.MenuItem
disconnect *systray.MenuItem
profile *systray.MenuItem
profileSub []*systray.MenuItem
profileDone chan struct{}
self *systray.MenuItem
devices *systray.MenuItem
devicesSub []*systray.MenuItem
devicesDone chan struct{}
exitNodes *systray.MenuItem
noExitNode *systray.MenuItem
tailnetExitNodes []*systray.MenuItem
mullvadExitNodes []*systray.MenuItem
runExitNode *systray.MenuItem
currentExitNode *systray.MenuItem
quit *systray.MenuItem
}
func onReady() {
log.Printf("starting")
ctx := context.Background()
disconnected, _ := fs.ReadFile(iconFS, "icons/disconnected.png")
systray.SetIcon(disconnected)
appIcon, _ = os.CreateTemp("", "tailscale-systray.png")
connected, _ := iconFS.Open("icons/connected.png")
io.Copy(appIcon, connected)
connected.Close()
var err error
mu.Lock()
status, err = localClient.Status(ctx)
mu.Unlock()
if err != nil {
log.Print(err)
}
chState = make(chan ipn.State)
menu.connect = systray.AddMenuItem("Connect", "")
menu.disconnect = systray.AddMenuItem("Disconnect", "")
menu.disconnect.Hide()
systray.AddSeparator()
menu.profile = systray.AddMenuItem("", "")
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, "")
}
menu.devices = systray.AddMenuItem("Network Devices", "")
systray.AddSeparator()
menu.exitNodes = systray.AddMenuItem("Exit Nodes", "")
menu.quit = systray.AddMenuItem("Quit", "Quit the app")
menu.quit.Enable()
go watchIPNBus(ctx)
for {
select {
case st := <-chState:
switch st {
case ipn.Running:
go loadingIcon()
mu.Lock()
status, err = localClient.Status(ctx)
mu.Unlock()
if err != nil {
log.Print(err)
}
updateProfilesMenu(ctx)
updateDevicesMenu()
updateExitNodeMenu()
if loadingDone != nil {
close(loadingDone)
}
icon, _ := fs.ReadFile(iconFS, "icons/connected.png")
systray.SetIcon(icon)
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()
icon, _ := fs.ReadFile(iconFS, "icons/disconnected.png")
systray.SetIcon(icon)
case ipn.Starting:
go loadingIcon()
}
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.Print(err)
continue
}
case <-menu.self.ClickedCh:
copyTailscaleIP(status.Self)
case <-menu.quit.ClickedCh:
systray.Quit()
}
}
}
func watchIPNBus(ctx context.Context) {
watcher, err := localClient.WatchIPNBus(ctx, ipn.NotifyWatchEngineUpdates|ipn.NotifyInitialState|
ipn.NotifyInitialPrefs|ipn.NotifyInitialNetMap)
if err != nil {
log.Printf("watching ipn bus: %v", err)
}
defer watcher.Close()
for {
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)
}
}
}
func updateProfilesMenu(ctx context.Context) {
current, all, err := localClient.ProfileStatus(ctx)
if err != nil {
log.Print(err)
return
}
// include tailnet in profile name if any two profiles have the same name
var includeTailnet bool
names := make(map[string]bool)
for _, pr := range all {
if _, ok := names[pr.Name]; ok {
includeTailnet = true
break
}
names[pr.Name] = true
}
title := current.Name
if includeTailnet {
title = fmt.Sprintf("%s\n(%s)", current.Name, current.NetworkProfile.DomainName)
}
menu.profile.SetTitle(title)
if resp, err := http.Get(current.UserProfile.ProfilePicURL); err == nil {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
menu.profile.SetIcon(b)
}
if menu.profileDone != nil {
close(menu.profileDone)
}
for _, sm := range menu.profileSub {
sm.Remove()
}
menu.profileSub = nil
menu.profileDone = make(chan struct{})
for _, pr := range all {
title := pr.Name
if includeTailnet {
title = fmt.Sprintf("%s\n(%s)", pr.Name, pr.NetworkProfile.DomainName)
}
sm := menu.profile.AddSubMenuItem(title, "")
setIcon(sm, pr.UserProfile.ProfilePicURL)
if resp, err := http.Get(pr.UserProfile.ProfilePicURL); err == nil {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
sm.SetIcon(b)
}
menu.profileSub = append(menu.profileSub, sm)
go func() {
for {
select {
case <-menu.profileDone:
return
case <-sm.ClickedCh:
localClient.SwitchProfile(ctx, pr.ID)
}
}
}()
}
}
var httpCache = map[string][]byte{}
func setIcon(menu *systray.MenuItem, urlStr string) {
if menu == nil || urlStr == "" {
return
}
b, ok := httpCache[urlStr]
if !ok {
if resp, err := http.Get(urlStr); err == nil {
b, _ = io.ReadAll(resp.Body)
httpCache[urlStr] = b
resp.Body.Close()
}
}
if len(b) > 0 {
menu.SetIcon(b)
}
}
func updateDevicesMenu() {
var ownerSet set.Set[tailcfg.UserProfile]
ownerSet.Make()
tagOwner := tailcfg.UserProfile{
ID: -1,
DisplayName: "Tagged Devices",
LoginName: "tagged-devices",
}
ownedDevices := make(map[tailcfg.UserID][]*ipnstate.PeerStatus)
for _, peer := range status.Peer {
if peer.ShareeNode {
continue
}
if peer.Tags != nil && peer.Tags.Len() > 0 {
ownerSet.Add(tagOwner)
ownedDevices[tagOwner.ID] = append(ownedDevices[tagOwner.ID], peer)
continue
}
if peer.UserID != status.Self.UserID {
ownerSet.Add(status.User[peer.UserID])
}
ownedDevices[peer.UserID] = append(ownedDevices[peer.UserID], peer)
}
owners := ownerSet.Slice()
sort.SliceStable(owners, func(i, j int) bool {
return strings.ToLower(owners[i].DisplayName) < strings.ToLower(owners[j].DisplayName)
})
myDevices := tailcfg.UserProfile{
ID: status.Self.UserID,
DisplayName: "My Devices",
LoginName: status.User[status.Self.UserID].LoginName,
}
owners = append([]tailcfg.UserProfile{myDevices}, owners...)
if menu.devicesDone != nil {
close(menu.devicesDone)
}
for _, sm := range menu.devicesSub {
sm.Remove()
}
menu.devicesSub = nil
menu.devicesDone = make(chan struct{})
var i int
for _, u := range owners {
i++
if i > 50 {
// FIXME: systray crashes on even moderately large menus
more := menu.devices.AddSubMenuItem("too many items to show", "")
more.Disable()
menu.devicesSub = append(menu.devicesSub, more)
break
}
if i == 2 {
menu.devices.AddSeparator()
}
ownerMenu := menu.devices.AddSubMenuItem(u.DisplayName, "")
loginMenu := ownerMenu.AddSubMenuItem(u.LoginName, "")
loginMenu.Disable()
menu.devicesSub = append(menu.devicesSub, ownerMenu)
menu.devicesSub = append(menu.devicesSub, loginMenu)
ownerMenu.AddSeparator()
for _, device := range ownedDevices[u.ID] {
name := strings.Split(device.DNSName, ".")[0]
if name != device.HostName {
name += " (" + device.HostName + ")"
}
sm := ownerMenu.AddSubMenuItem(name, "")
menu.devicesSub = append(menu.devicesSub, sm)
// TODO: add click handler
go func() {
for {
select {
case <-menu.devicesDone:
return
case <-sm.ClickedCh:
copyTailscaleIP(device)
}
}
}()
}
}
}
func loadingIcon() {
loadingDone = make(chan struct{})
var icons [][]byte
for i := 1; i <= 16; i++ {
b, err := fs.ReadFile(iconFS, fmt.Sprintf("icons/connecting-%d.png", i))
if err == nil {
icons = append(icons, b)
}
}
t := time.NewTicker(300 * time.Millisecond)
var i int
for {
select {
case <-loadingDone:
return
case <-t.C:
systray.SetIcon(icons[i])
i++
if i >= len(icons) {
i = 0
}
}
}
}
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)
}
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 updateExitNodeMenu() {
msg := menu.exitNodes.AddSubMenuItem("Nothing in this menu currently works", "")
msg.Disable()
menu.exitNodes.AddSeparator()
menu.noExitNode = menu.exitNodes.AddSubMenuItemCheckbox("None", "", true)
menu.exitNodes.AddSeparator()
tailnetNodes := menu.exitNodes.AddSubMenuItem("Tailnet Exit Nodes", "")
tailnetNodes.Disable()
for _, ps := range status.Peer {
if !ps.ExitNodeOption || ps.Location != nil {
continue
}
name := strings.Split(ps.DNSName, ".")[0]
if !ps.Online {
name += " (offline)"
}
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", false)
menu.tailnetExitNodes = append(menu.tailnetExitNodes, sm)
if !ps.Online {
sm.Disable()
}
}
menu.exitNodes.AddSeparator()
locationNodes := menu.exitNodes.AddSubMenuItem("Location-based Exit Nodes", "")
locationNodes.Disable()
mullvadNodes := menu.exitNodes.AddSubMenuItem("Mullvad VPN", "")
menu.exitNodes.AddSeparator()
menu.runExitNode = menu.exitNodes.AddSubMenuItemCheckbox("Run Exit Node", "", false)
for _, country := range mullvadExitNodes() {
cm := mullvadNodes.AddSubMenuItem(country.name, "")
for _, city := range country.cities {
cm.AddSubMenuItem(city.name, "")
}
}
}
type country struct {
name string
cities map[string]*city
}
type city struct {
name string
peers []*ipnstate.PeerStatus
}
func mullvadExitNodes() (nodes map[string]*country) {
for _, ps := range status.Peer {
if !ps.ExitNodeOption || ps.Location == nil {
continue
}
if nodes == nil {
nodes = make(map[string]*country)
}
loc := ps.Location
if _, ok := nodes[loc.CountryCode]; !ok {
nodes[loc.CountryCode] = &country{
name: loc.Country,
cities: make(map[string]*city),
}
}
if _, ok := nodes[loc.CountryCode].cities[loc.CityCode]; !ok {
nodes[loc.CountryCode].cities[loc.CityCode] = &city{
name: loc.City,
}
}
c := nodes[loc.CountryCode].cities[loc.CityCode]
c.peers = append(c.peers, ps)
}
return nodes
}
func onExit() {
log.Printf("exiting")
os.Remove(appIcon.Name())
}

2
go.mod
View File

@ -120,7 +120,9 @@ require (
)
require (
fyne.io/systray v1.11.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/bits-and-blooms/bitset v1.13.0 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14 // indirect

4
go.sum
View File

@ -48,6 +48,8 @@ filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
fybrik.io/crdoc v0.6.3 h1:jNNAVINu8up5vrLa0jrV7z7HSlyHF/6lNOrAtrXwYlI=
fybrik.io/crdoc v0.6.3/go.mod h1:kvZRt7VAzOyrmDpIqREtcKAVFSJYEBoAyniYebsJGtQ=
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=