diff --git a/cmd/systray/icons/connected.png b/cmd/systray/icons/connected.png new file mode 100644 index 000000000..274c7697b Binary files /dev/null and b/cmd/systray/icons/connected.png differ diff --git a/cmd/systray/icons/connecting-1.png b/cmd/systray/icons/connecting-1.png new file mode 100644 index 000000000..cd8c2381c Binary files /dev/null and b/cmd/systray/icons/connecting-1.png differ diff --git a/cmd/systray/icons/connecting-10.png b/cmd/systray/icons/connecting-10.png new file mode 100644 index 000000000..e0d3efa3f Binary files /dev/null and b/cmd/systray/icons/connecting-10.png differ diff --git a/cmd/systray/icons/connecting-11.png b/cmd/systray/icons/connecting-11.png new file mode 100644 index 000000000..10fea163b Binary files /dev/null and b/cmd/systray/icons/connecting-11.png differ diff --git a/cmd/systray/icons/connecting-12.png b/cmd/systray/icons/connecting-12.png new file mode 100644 index 000000000..429af4982 Binary files /dev/null and b/cmd/systray/icons/connecting-12.png differ diff --git a/cmd/systray/icons/connecting-13.png b/cmd/systray/icons/connecting-13.png new file mode 100644 index 000000000..375d706de Binary files /dev/null and b/cmd/systray/icons/connecting-13.png differ diff --git a/cmd/systray/icons/connecting-14.png b/cmd/systray/icons/connecting-14.png new file mode 100644 index 000000000..ac797407f Binary files /dev/null and b/cmd/systray/icons/connecting-14.png differ diff --git a/cmd/systray/icons/connecting-15.png b/cmd/systray/icons/connecting-15.png new file mode 100644 index 000000000..0af96b3de Binary files /dev/null and b/cmd/systray/icons/connecting-15.png differ diff --git a/cmd/systray/icons/connecting-16.png b/cmd/systray/icons/connecting-16.png new file mode 100644 index 000000000..70435aa54 Binary files /dev/null and b/cmd/systray/icons/connecting-16.png differ diff --git a/cmd/systray/icons/connecting-2.png b/cmd/systray/icons/connecting-2.png new file mode 100644 index 000000000..954c60cef Binary files /dev/null and b/cmd/systray/icons/connecting-2.png differ diff --git a/cmd/systray/icons/connecting-3.png b/cmd/systray/icons/connecting-3.png new file mode 100644 index 000000000..453375998 Binary files /dev/null and b/cmd/systray/icons/connecting-3.png differ diff --git a/cmd/systray/icons/connecting-4.png b/cmd/systray/icons/connecting-4.png new file mode 100644 index 000000000..9a314d36a Binary files /dev/null and b/cmd/systray/icons/connecting-4.png differ diff --git a/cmd/systray/icons/connecting-5.png b/cmd/systray/icons/connecting-5.png new file mode 100644 index 000000000..6e216ddb3 Binary files /dev/null and b/cmd/systray/icons/connecting-5.png differ diff --git a/cmd/systray/icons/connecting-6.png b/cmd/systray/icons/connecting-6.png new file mode 100644 index 000000000..10cb1e486 Binary files /dev/null and b/cmd/systray/icons/connecting-6.png differ diff --git a/cmd/systray/icons/connecting-7.png b/cmd/systray/icons/connecting-7.png new file mode 100644 index 000000000..081d2b250 Binary files /dev/null and b/cmd/systray/icons/connecting-7.png differ diff --git a/cmd/systray/icons/connecting-8.png b/cmd/systray/icons/connecting-8.png new file mode 100644 index 000000000..90c22c849 Binary files /dev/null and b/cmd/systray/icons/connecting-8.png differ diff --git a/cmd/systray/icons/connecting-9.png b/cmd/systray/icons/connecting-9.png new file mode 100644 index 000000000..e0d3efa3f Binary files /dev/null and b/cmd/systray/icons/connecting-9.png differ diff --git a/cmd/systray/icons/disconnected.png b/cmd/systray/icons/disconnected.png new file mode 100644 index 000000000..2705dbd10 Binary files /dev/null and b/cmd/systray/icons/disconnected.png differ diff --git a/cmd/systray/systray.go b/cmd/systray/systray.go new file mode 100644 index 000000000..dfa70fae4 --- /dev/null +++ b/cmd/systray/systray.go @@ -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()) +} diff --git a/go.mod b/go.mod index 0d5a69d6c..8d0b11ee0 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 863b9db02..acf96a0c6 100644 --- a/go.sum +++ b/go.sum @@ -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=