mirror of
https://github.com/tailscale/tailscale.git
synced 2025-01-10 10:03:43 +00:00
c43c5ca003
On Linux, systray.SetTitle actually seems to set the tooltip on all desktops I've tested on. But on macOS, it actually does set a title that is always displayed in the systray area next to the icon. This change should properly set the tooltip across platforms. Updates #1708 Change-Id: Ia101a4a3005adb9118051b3416f5a64a4a45987d Signed-off-by: Will Norris <will@tailscale.com>
712 lines
19 KiB
Go
712 lines
19 KiB
Go
// 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"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"maps"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"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"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/util/stringsx"
|
|
)
|
|
|
|
var (
|
|
// newMenuDelay is the amount of time to sleep after creating a new menu,
|
|
// but before adding items to it. This works around a bug in some dbus implementations.
|
|
newMenuDelay time.Duration
|
|
|
|
// if true, treat all mullvad exit node countries as single-city.
|
|
// Instead of rendering a submenu with cities, just select the highest-priority peer.
|
|
hideMullvadCities bool
|
|
)
|
|
|
|
func main() {
|
|
menu := new(Menu)
|
|
menu.updateState()
|
|
|
|
// exit cleanly on SIGINT and SIGTERM
|
|
go func() {
|
|
interrupt := make(chan os.Signal, 1)
|
|
signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM)
|
|
select {
|
|
case <-interrupt:
|
|
menu.onExit()
|
|
case <-menu.bgCtx.Done():
|
|
}
|
|
}()
|
|
|
|
systray.Run(menu.onReady, menu.onExit)
|
|
}
|
|
|
|
// Menu represents the systray menu, its items, and the current Tailscale state.
|
|
type Menu struct {
|
|
mu sync.Mutex // protects the entire Menu
|
|
|
|
lc tailscale.LocalClient
|
|
status *ipnstate.Status
|
|
curProfile ipn.LoginProfile
|
|
allProfiles []ipn.LoginProfile
|
|
|
|
bgCtx context.Context // ctx for background tasks not involving menu item clicks
|
|
bgCancel context.CancelFunc
|
|
|
|
// Top-level menu items
|
|
connect *systray.MenuItem
|
|
disconnect *systray.MenuItem
|
|
self *systray.MenuItem
|
|
exitNodes *systray.MenuItem
|
|
more *systray.MenuItem
|
|
quit *systray.MenuItem
|
|
|
|
rebuildCh chan struct{} // triggers a menu rebuild
|
|
accountsCh chan ipn.ProfileID
|
|
exitNodeCh chan tailcfg.StableNodeID // ID of selected exit node
|
|
|
|
eventCancel context.CancelFunc // cancel eventLoop
|
|
|
|
notificationIcon *os.File // icon used for desktop notifications
|
|
}
|
|
|
|
func (menu *Menu) init() {
|
|
if menu.bgCtx != nil {
|
|
// already initialized
|
|
return
|
|
}
|
|
|
|
menu.rebuildCh = make(chan struct{}, 1)
|
|
menu.accountsCh = make(chan ipn.ProfileID)
|
|
menu.exitNodeCh = make(chan tailcfg.StableNodeID)
|
|
|
|
// dbus wants a file path for notification icons, so copy to a temp file.
|
|
menu.notificationIcon, _ = os.CreateTemp("", "tailscale-systray.png")
|
|
io.Copy(menu.notificationIcon, connected.renderWithBorder(3))
|
|
|
|
menu.bgCtx, menu.bgCancel = context.WithCancel(context.Background())
|
|
go menu.watchIPNBus()
|
|
}
|
|
|
|
func init() {
|
|
if runtime.GOOS != "linux" {
|
|
// so far, these tweaks are only needed on Linux
|
|
return
|
|
}
|
|
|
|
desktop := strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP"))
|
|
switch desktop {
|
|
case "gnome":
|
|
// GNOME expands submenus downward in the main menu, rather than flyouts to the side.
|
|
// Either as a result of that or another limitation, there seems to be a maximum depth of submenus.
|
|
// Mullvad countries that have a city submenu are not being rendered, and so can't be selected.
|
|
// Handle this by simply treating all mullvad countries as single-city and select the best peer.
|
|
hideMullvadCities = true
|
|
case "kde":
|
|
// KDE doesn't need a delay, and actually won't render submenus
|
|
// if we delay for more than about 400µs.
|
|
newMenuDelay = 0
|
|
default:
|
|
// Add a slight delay to ensure the menu is created before adding items.
|
|
//
|
|
// Systray implementations that use libdbusmenu sometimes process messages out of order,
|
|
// resulting in errors such as:
|
|
// (waybar:153009): LIBDBUSMENU-GTK-WARNING **: 18:07:11.551: Children but no menu, someone's been naughty with their 'children-display' property: 'submenu'
|
|
//
|
|
// See also: https://github.com/fyne-io/systray/issues/12
|
|
newMenuDelay = 10 * time.Millisecond
|
|
}
|
|
}
|
|
|
|
// onReady is called by the systray package when the menu is ready to be built.
|
|
func (menu *Menu) onReady() {
|
|
log.Printf("starting")
|
|
setAppIcon(disconnected)
|
|
menu.rebuild()
|
|
}
|
|
|
|
// updateState updates the Menu state from the Tailscale local client.
|
|
func (menu *Menu) updateState() {
|
|
menu.mu.Lock()
|
|
defer menu.mu.Unlock()
|
|
menu.init()
|
|
|
|
var err error
|
|
menu.status, err = menu.lc.Status(menu.bgCtx)
|
|
if err != nil {
|
|
log.Print(err)
|
|
}
|
|
menu.curProfile, menu.allProfiles, err = menu.lc.ProfileStatus(menu.bgCtx)
|
|
if err != nil {
|
|
log.Print(err)
|
|
}
|
|
}
|
|
|
|
// 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() {
|
|
menu.mu.Lock()
|
|
defer menu.mu.Unlock()
|
|
menu.init()
|
|
|
|
if menu.eventCancel != nil {
|
|
menu.eventCancel()
|
|
}
|
|
ctx := context.Background()
|
|
ctx, menu.eventCancel = context.WithCancel(ctx)
|
|
|
|
systray.ResetMenu()
|
|
|
|
menu.connect = systray.AddMenuItem("Connect", "")
|
|
menu.disconnect = systray.AddMenuItem("Disconnect", "")
|
|
menu.disconnect.Hide()
|
|
systray.AddSeparator()
|
|
|
|
// delay to prevent race setting icon on first start
|
|
time.Sleep(newMenuDelay)
|
|
|
|
// Set systray menu icon and title.
|
|
// Also adjust connect/disconnect menu items if needed.
|
|
var backendState string
|
|
if menu.status != nil {
|
|
backendState = menu.status.BackendState
|
|
}
|
|
switch backendState {
|
|
case ipn.Running.String():
|
|
if menu.status.ExitNodeStatus != nil && !menu.status.ExitNodeStatus.ID.IsZero() {
|
|
if menu.status.ExitNodeStatus.Online {
|
|
setTooltip("Using exit node")
|
|
setAppIcon(exitNodeOnline)
|
|
} else {
|
|
setTooltip("Exit node offline")
|
|
setAppIcon(exitNodeOffline)
|
|
}
|
|
} else {
|
|
setTooltip(fmt.Sprintf("Connected to %s", menu.status.CurrentTailnet.Name))
|
|
setAppIcon(connected)
|
|
}
|
|
menu.connect.SetTitle("Connected")
|
|
menu.connect.Disable()
|
|
menu.disconnect.Show()
|
|
menu.disconnect.Enable()
|
|
case ipn.Starting.String():
|
|
setTooltip("Connecting")
|
|
setAppIcon(loading)
|
|
default:
|
|
setTooltip("Disconnected")
|
|
setAppIcon(disconnected)
|
|
}
|
|
|
|
account := "Account"
|
|
if pt := profileTitle(menu.curProfile); pt != "" {
|
|
account = pt
|
|
}
|
|
accounts := systray.AddMenuItem(account, "")
|
|
setRemoteIcon(accounts, menu.curProfile.UserProfile.ProfilePicURL)
|
|
time.Sleep(newMenuDelay)
|
|
for _, profile := range menu.allProfiles {
|
|
title := profileTitle(profile)
|
|
var item *systray.MenuItem
|
|
if profile.ID == menu.curProfile.ID {
|
|
item = accounts.AddSubMenuItemCheckbox(title, "", true)
|
|
} else {
|
|
item = accounts.AddSubMenuItem(title, "")
|
|
}
|
|
setRemoteIcon(item, profile.UserProfile.ProfilePicURL)
|
|
onClick(ctx, item, func(ctx context.Context) {
|
|
select {
|
|
case <-ctx.Done():
|
|
case menu.accountsCh <- profile.ID:
|
|
}
|
|
})
|
|
}
|
|
|
|
if menu.status != nil && menu.status.Self != nil && len(menu.status.Self.TailscaleIPs) > 0 {
|
|
title := fmt.Sprintf("This Device: %s (%s)", menu.status.Self.HostName, menu.status.Self.TailscaleIPs[0])
|
|
menu.self = systray.AddMenuItem(title, "")
|
|
} else {
|
|
menu.self = systray.AddMenuItem("This Device: not connected", "")
|
|
menu.self.Disable()
|
|
}
|
|
systray.AddSeparator()
|
|
|
|
menu.rebuildExitNodeMenu(ctx)
|
|
|
|
if menu.status != nil {
|
|
menu.more = systray.AddMenuItem("More settings", "")
|
|
onClick(ctx, menu.more, func(_ context.Context) {
|
|
webbrowser.Open("http://100.100.100.100/")
|
|
})
|
|
}
|
|
|
|
menu.quit = systray.AddMenuItem("Quit", "Quit the app")
|
|
menu.quit.Enable()
|
|
|
|
go menu.eventLoop(ctx)
|
|
}
|
|
|
|
// profileTitle returns the title string for a profile menu item.
|
|
func profileTitle(profile ipn.LoginProfile) string {
|
|
title := profile.Name
|
|
if profile.NetworkProfile.DomainName != "" {
|
|
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
|
// windows and mac don't support multi-line menu
|
|
title += " (" + profile.NetworkProfile.DomainName + ")"
|
|
} else {
|
|
title += "\n" + profile.NetworkProfile.DomainName
|
|
}
|
|
}
|
|
return title
|
|
}
|
|
|
|
var (
|
|
cacheMu sync.Mutex
|
|
httpCache = map[string][]byte{} // URL => response body
|
|
)
|
|
|
|
// setRemoteIcon sets the icon for menu to the specified remote image.
|
|
// Remote images are fetched as needed and cached.
|
|
func setRemoteIcon(menu *systray.MenuItem, urlStr string) {
|
|
if menu == nil || urlStr == "" {
|
|
return
|
|
}
|
|
|
|
cacheMu.Lock()
|
|
b, ok := httpCache[urlStr]
|
|
if !ok {
|
|
resp, err := http.Get(urlStr)
|
|
if err == nil && resp.StatusCode == http.StatusOK {
|
|
b, _ = io.ReadAll(resp.Body)
|
|
httpCache[urlStr] = b
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
cacheMu.Unlock()
|
|
|
|
if len(b) > 0 {
|
|
menu.SetIcon(b)
|
|
}
|
|
}
|
|
|
|
// setTooltip sets the tooltip text for the systray icon.
|
|
func setTooltip(text string) {
|
|
if runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
|
|
systray.SetTooltip(text)
|
|
} else {
|
|
// on Linux, SetTitle actually sets the tooltip
|
|
systray.SetTitle(text)
|
|
}
|
|
}
|
|
|
|
// 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 <-menu.rebuildCh:
|
|
menu.updateState()
|
|
menu.rebuild()
|
|
case <-menu.connect.ClickedCh:
|
|
_, err := menu.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
|
Prefs: ipn.Prefs{
|
|
WantRunning: true,
|
|
},
|
|
WantRunningSet: true,
|
|
})
|
|
if err != nil {
|
|
log.Printf("error connecting: %v", err)
|
|
}
|
|
|
|
case <-menu.disconnect.ClickedCh:
|
|
_, err := menu.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
|
Prefs: ipn.Prefs{
|
|
WantRunning: false,
|
|
},
|
|
WantRunningSet: true,
|
|
})
|
|
if err != nil {
|
|
log.Printf("error disconnecting: %v", err)
|
|
}
|
|
|
|
case <-menu.self.ClickedCh:
|
|
menu.copyTailscaleIP(menu.status.Self)
|
|
|
|
case id := <-menu.accountsCh:
|
|
if err := menu.lc.SwitchProfile(ctx, id); err != nil {
|
|
log.Printf("error switching to profile ID %v: %v", id, err)
|
|
}
|
|
|
|
case exitNode := <-menu.exitNodeCh:
|
|
if exitNode.IsZero() {
|
|
log.Print("disable exit node")
|
|
if err := menu.lc.SetUseExitNode(ctx, false); err != nil {
|
|
log.Printf("error disabling exit node: %v", err)
|
|
}
|
|
} else {
|
|
log.Printf("enable exit node: %v", exitNode)
|
|
mp := &ipn.MaskedPrefs{
|
|
Prefs: ipn.Prefs{
|
|
ExitNodeID: exitNode,
|
|
},
|
|
ExitNodeIDSet: true,
|
|
}
|
|
if _, err := menu.lc.EditPrefs(ctx, mp); err != nil {
|
|
log.Printf("error setting exit node: %v", err)
|
|
}
|
|
}
|
|
|
|
case <-menu.quit.ClickedCh:
|
|
systray.Quit()
|
|
}
|
|
}
|
|
}
|
|
|
|
// onClick registers a click handler for a menu item.
|
|
func onClick(ctx context.Context, item *systray.MenuItem, fn func(ctx context.Context)) {
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-item.ClickedCh:
|
|
fn(ctx)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// watchIPNBus subscribes to the tailscale event bus and sends state updates to chState.
|
|
// This method does not return.
|
|
func (menu *Menu) watchIPNBus() {
|
|
for {
|
|
if err := menu.watchIPNBusInner(); err != nil {
|
|
log.Println(err)
|
|
if errors.Is(err, context.Canceled) {
|
|
// If the context got canceled, we will never be able to
|
|
// reconnect to IPN bus, so exit the process.
|
|
log.Fatalf("watchIPNBus: %v", err)
|
|
}
|
|
}
|
|
// If our watch connection breaks, wait a bit before reconnecting. No
|
|
// reason to spam the logs if e.g. tailscaled is restarting or goes
|
|
// down.
|
|
time.Sleep(3 * time.Second)
|
|
}
|
|
}
|
|
|
|
func (menu *Menu) watchIPNBusInner() error {
|
|
watcher, err := menu.lc.WatchIPNBus(menu.bgCtx, ipn.NotifyNoPrivateKeys)
|
|
if err != nil {
|
|
return fmt.Errorf("watching ipn bus: %w", err)
|
|
}
|
|
defer watcher.Close()
|
|
for {
|
|
select {
|
|
case <-menu.bgCtx.Done():
|
|
return nil
|
|
default:
|
|
n, err := watcher.Next()
|
|
if err != nil {
|
|
return fmt.Errorf("ipnbus error: %w", err)
|
|
}
|
|
var rebuild bool
|
|
if n.State != nil {
|
|
log.Printf("new state: %v", n.State)
|
|
rebuild = true
|
|
}
|
|
if n.Prefs != nil {
|
|
rebuild = true
|
|
}
|
|
if rebuild {
|
|
menu.rebuildCh <- struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// copyTailscaleIP copies the first Tailscale IP of the given device to the clipboard
|
|
// and sends a notification with the copied value.
|
|
func (menu *Menu) 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)
|
|
}
|
|
|
|
menu.sendNotification(fmt.Sprintf("Copied Address for %v", name), ip)
|
|
}
|
|
|
|
// sendNotification sends a desktop notification with the given title and content.
|
|
func (menu *Menu) 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),
|
|
menu.notificationIcon.Name(), title, content, []string{}, map[string]dbus.Variant{}, int32(timeout.Milliseconds()))
|
|
if call.Err != nil {
|
|
log.Printf("dbus: %v", call.Err)
|
|
}
|
|
}
|
|
|
|
func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
|
if menu.status == nil {
|
|
return
|
|
}
|
|
|
|
status := menu.status
|
|
menu.exitNodes = systray.AddMenuItem("Exit Nodes", "")
|
|
time.Sleep(newMenuDelay)
|
|
|
|
// register a click handler for a menu item to set nodeID as the exit node.
|
|
setExitNodeOnClick := func(item *systray.MenuItem, nodeID tailcfg.StableNodeID) {
|
|
onClick(ctx, item, func(ctx context.Context) {
|
|
select {
|
|
case <-ctx.Done():
|
|
case menu.exitNodeCh <- nodeID:
|
|
}
|
|
})
|
|
}
|
|
|
|
noExitNodeMenu := menu.exitNodes.AddSubMenuItemCheckbox("None", "", status.ExitNodeStatus == nil)
|
|
setExitNodeOnClick(noExitNodeMenu, "")
|
|
|
|
// Show recommended exit node if available.
|
|
if status.Self.CapMap.Contains(tailcfg.NodeAttrSuggestExitNodeUI) {
|
|
sugg, err := menu.lc.SuggestExitNode(ctx)
|
|
if err == nil {
|
|
title := "Recommended: "
|
|
if loc := sugg.Location; loc.Valid() && loc.Country() != "" {
|
|
flag := countryFlag(loc.CountryCode())
|
|
title += fmt.Sprintf("%s %s: %s", flag, loc.Country(), loc.City())
|
|
} else {
|
|
title += strings.Split(sugg.Name, ".")[0]
|
|
}
|
|
menu.exitNodes.AddSeparator()
|
|
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", false)
|
|
setExitNodeOnClick(rm, sugg.ID)
|
|
if status.ExitNodeStatus != nil && sugg.ID == status.ExitNodeStatus.ID {
|
|
rm.Check()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add tailnet exit nodes if present.
|
|
var tailnetExitNodes []*ipnstate.PeerStatus
|
|
for _, ps := range status.Peer {
|
|
if ps.ExitNodeOption && ps.Location == nil {
|
|
tailnetExitNodes = append(tailnetExitNodes, ps)
|
|
}
|
|
}
|
|
if len(tailnetExitNodes) > 0 {
|
|
menu.exitNodes.AddSeparator()
|
|
menu.exitNodes.AddSubMenuItem("Tailnet Exit Nodes", "").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)
|
|
if !ps.Online {
|
|
sm.Disable()
|
|
}
|
|
if status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID {
|
|
sm.Check()
|
|
}
|
|
setExitNodeOnClick(sm, ps.ID)
|
|
}
|
|
}
|
|
|
|
// Add mullvad exit nodes if present.
|
|
var mullvadExitNodes mullvadPeers
|
|
if status.Self.CapMap.Contains("mullvad") {
|
|
mullvadExitNodes = newMullvadPeers(status)
|
|
}
|
|
if len(mullvadExitNodes.countries) > 0 {
|
|
menu.exitNodes.AddSeparator()
|
|
menu.exitNodes.AddSubMenuItem("Location-based Exit Nodes", "").Disable()
|
|
mullvadMenu := menu.exitNodes.AddSubMenuItemCheckbox("Mullvad VPN", "", false)
|
|
|
|
for _, country := range mullvadExitNodes.sortedCountries() {
|
|
flag := countryFlag(country.code)
|
|
countryMenu := mullvadMenu.AddSubMenuItemCheckbox(flag+" "+country.name, "", false)
|
|
|
|
// single-city country, no submenu
|
|
if len(country.cities) == 1 || hideMullvadCities {
|
|
setExitNodeOnClick(countryMenu, country.best.ID)
|
|
if status.ExitNodeStatus != nil {
|
|
for _, city := range country.cities {
|
|
for _, ps := range city.peers {
|
|
if status.ExitNodeStatus.ID == ps.ID {
|
|
mullvadMenu.Check()
|
|
countryMenu.Check()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// multi-city country, build submenu with "best available" option and cities.
|
|
time.Sleep(newMenuDelay)
|
|
bm := countryMenu.AddSubMenuItemCheckbox("Best Available", "", false)
|
|
setExitNodeOnClick(bm, country.best.ID)
|
|
countryMenu.AddSeparator()
|
|
|
|
for _, city := range country.sortedCities() {
|
|
cityMenu := countryMenu.AddSubMenuItemCheckbox(city.name, "", false)
|
|
setExitNodeOnClick(cityMenu, city.best.ID)
|
|
if status.ExitNodeStatus != nil {
|
|
for _, ps := range city.peers {
|
|
if status.ExitNodeStatus.ID == ps.ID {
|
|
mullvadMenu.Check()
|
|
countryMenu.Check()
|
|
cityMenu.Check()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: "Allow Local Network Access" and "Run Exit Node" menu items
|
|
}
|
|
|
|
// mullvadPeers contains all mullvad peer nodes, sorted by country and city.
|
|
type mullvadPeers struct {
|
|
countries map[string]*mvCountry // country code (uppercase) => country
|
|
}
|
|
|
|
// sortedCountries returns countries containing mullvad nodes, sorted by name.
|
|
func (mp mullvadPeers) sortedCountries() []*mvCountry {
|
|
countries := slices.Collect(maps.Values(mp.countries))
|
|
slices.SortFunc(countries, func(a, b *mvCountry) int {
|
|
return stringsx.CompareFold(a.name, b.name)
|
|
})
|
|
return countries
|
|
}
|
|
|
|
type mvCountry struct {
|
|
code string
|
|
name string
|
|
best *ipnstate.PeerStatus // highest priority peer in the country
|
|
cities map[string]*mvCity // city code => city
|
|
}
|
|
|
|
// sortedCities returns cities containing mullvad nodes, sorted by name.
|
|
func (mc *mvCountry) sortedCities() []*mvCity {
|
|
cities := slices.Collect(maps.Values(mc.cities))
|
|
slices.SortFunc(cities, func(a, b *mvCity) int {
|
|
return stringsx.CompareFold(a.name, b.name)
|
|
})
|
|
return cities
|
|
}
|
|
|
|
// countryFlag takes a 2-character ASCII string and returns the corresponding emoji flag.
|
|
// It returns the empty string on error.
|
|
func countryFlag(code string) string {
|
|
if len(code) != 2 {
|
|
return ""
|
|
}
|
|
runes := make([]rune, 0, 2)
|
|
for i := range 2 {
|
|
b := code[i] | 32 // lowercase
|
|
if b < 'a' || b > 'z' {
|
|
return ""
|
|
}
|
|
// https://en.wikipedia.org/wiki/Regional_indicator_symbol
|
|
runes = append(runes, 0x1F1E6+rune(b-'a'))
|
|
}
|
|
return string(runes)
|
|
}
|
|
|
|
type mvCity struct {
|
|
name string
|
|
best *ipnstate.PeerStatus // highest priority peer in the city
|
|
peers []*ipnstate.PeerStatus
|
|
}
|
|
|
|
func newMullvadPeers(status *ipnstate.Status) mullvadPeers {
|
|
countries := make(map[string]*mvCountry)
|
|
for _, ps := range status.Peer {
|
|
if !ps.ExitNodeOption || ps.Location == nil {
|
|
continue
|
|
}
|
|
loc := ps.Location
|
|
country, ok := countries[loc.CountryCode]
|
|
if !ok {
|
|
country = &mvCountry{
|
|
code: loc.CountryCode,
|
|
name: loc.Country,
|
|
cities: make(map[string]*mvCity),
|
|
}
|
|
countries[loc.CountryCode] = country
|
|
}
|
|
city, ok := countries[loc.CountryCode].cities[loc.CityCode]
|
|
if !ok {
|
|
city = &mvCity{
|
|
name: loc.City,
|
|
}
|
|
countries[loc.CountryCode].cities[loc.CityCode] = city
|
|
}
|
|
city.peers = append(city.peers, ps)
|
|
if city.best == nil || ps.Location.Priority > city.best.Location.Priority {
|
|
city.best = ps
|
|
}
|
|
if country.best == nil || ps.Location.Priority > country.best.Location.Priority {
|
|
country.best = ps
|
|
}
|
|
}
|
|
return mullvadPeers{countries}
|
|
}
|
|
|
|
// onExit is called by the systray package when the menu is exiting.
|
|
func (menu *Menu) onExit() {
|
|
log.Printf("exiting")
|
|
if menu.bgCancel != nil {
|
|
menu.bgCancel()
|
|
}
|
|
if menu.eventCancel != nil {
|
|
menu.eventCancel()
|
|
}
|
|
|
|
os.Remove(menu.notificationIcon.Name())
|
|
}
|