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>
BIN
cmd/systray/icons/connected.png
Normal file
After Width: | Height: | Size: 645 B |
BIN
cmd/systray/icons/connecting-1.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
cmd/systray/icons/connecting-10.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
BIN
cmd/systray/icons/connecting-11.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
cmd/systray/icons/connecting-12.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
cmd/systray/icons/connecting-13.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
cmd/systray/icons/connecting-14.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
cmd/systray/icons/connecting-15.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
cmd/systray/icons/connecting-16.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
cmd/systray/icons/connecting-2.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
cmd/systray/icons/connecting-3.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
cmd/systray/icons/connecting-4.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
cmd/systray/icons/connecting-5.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
BIN
cmd/systray/icons/connecting-6.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
cmd/systray/icons/connecting-7.png
Normal file
After Width: | Height: | Size: 959 B |
BIN
cmd/systray/icons/connecting-8.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
BIN
cmd/systray/icons/connecting-9.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
BIN
cmd/systray/icons/disconnected.png
Normal file
After Width: | Height: | Size: 410 B |
502
cmd/systray/systray.go
Normal 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
@ -120,7 +120,9 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
fyne.io/systray v1.11.0 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.1 // 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/bits-and-blooms/bitset v1.13.0 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
|
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
|
||||||
github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14 // indirect
|
github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14 // indirect
|
||||||
|
4
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=
|
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 h1:jNNAVINu8up5vrLa0jrV7z7HSlyHF/6lNOrAtrXwYlI=
|
||||||
fybrik.io/crdoc v0.6.3/go.mod h1:kvZRt7VAzOyrmDpIqREtcKAVFSJYEBoAyniYebsJGtQ=
|
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 h1:z6v8rMETchZXUIuHxYNmlUAuKuB21PeaSymTed16wgU=
|
||||||
github.com/Abirdcfly/dupword v0.0.11/go.mod h1:wH8mVGuf3CP5fsBTkfWwwwKTjDnVVCxtU8d8rgeVYXA=
|
github.com/Abirdcfly/dupword v0.0.11/go.mod h1:wH8mVGuf3CP5fsBTkfWwwwKTjDnVVCxtU8d8rgeVYXA=
|
||||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
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/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 h1:iCQ87C0V0vSyO+M9E/FZYbu65auqH0lnsOkf5FcB28s=
|
||||||
github.com/ashanbrown/makezero v1.1.1/go.mod h1:i1bJLCRSCHOcOa9Y6MyF2FTfMZMFdHvxKHxgO5Z1axI=
|
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.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 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
|
github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
|
||||||
|