mirror of
https://github.com/tailscale/tailscale.git
synced 2025-04-21 06:01:42 +00:00
cmd/systray: rebuild menu on pref change, assorted other fixes
- rebuild menu when prefs change outside of systray, such as setting an exit node - refactor onClick handler code - compare lowercase country name, the same as macOS and Windows (now sorts Ukraine before USA) - fix "connected / disconnected" menu items on stopped status - prevent nil pointer on "This Device" menu item Updates #1708 Change-Id: Ia101a4a3005adb9118051b3416f5a64a4a45987d Signed-off-by: Will Norris <will@tailscale.com>
This commit is contained in:
parent
76ca1adc64
commit
3837b6cebc
@ -7,7 +7,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -30,12 +29,15 @@ import (
|
|||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/ipnstate"
|
"tailscale.com/ipn/ipnstate"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/util/stringsx"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
localClient tailscale.LocalClient
|
localClient tailscale.LocalClient
|
||||||
chState chan ipn.State // tailscale state changes
|
chState chan ipn.State // tailscale state changes
|
||||||
|
|
||||||
|
chRebuild chan struct{} // triggers a menu rebuild
|
||||||
|
|
||||||
appIcon *os.File
|
appIcon *os.File
|
||||||
|
|
||||||
// newMenuDelay is the amount of time to sleep after creating a new menu,
|
// newMenuDelay is the amount of time to sleep after creating a new menu,
|
||||||
@ -111,6 +113,7 @@ func onReady() {
|
|||||||
io.Copy(appIcon, connected.renderWithBorder(3))
|
io.Copy(appIcon, connected.renderWithBorder(3))
|
||||||
|
|
||||||
chState = make(chan ipn.State, 1)
|
chState = make(chan ipn.State, 1)
|
||||||
|
chRebuild = make(chan struct{}, 1)
|
||||||
|
|
||||||
menu := new(Menu)
|
menu := new(Menu)
|
||||||
menu.rebuild(fetchState(ctx))
|
menu.rebuild(fetchState(ctx))
|
||||||
@ -146,6 +149,10 @@ func fetchState(ctx context.Context) state {
|
|||||||
// You cannot iterate over the items in a menu, nor can you remove some items like separators.
|
// 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.
|
// So for now we rebuild the whole thing, and can optimize this later if needed.
|
||||||
func (menu *Menu) rebuild(state state) {
|
func (menu *Menu) rebuild(state state) {
|
||||||
|
if state.status == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
menu.mu.Lock()
|
menu.mu.Lock()
|
||||||
defer menu.mu.Unlock()
|
defer menu.mu.Unlock()
|
||||||
|
|
||||||
@ -181,25 +188,20 @@ func (menu *Menu) rebuild(state state) {
|
|||||||
item = accounts.AddSubMenuItem(title, "")
|
item = accounts.AddSubMenuItem(title, "")
|
||||||
}
|
}
|
||||||
setRemoteIcon(item, profile.UserProfile.ProfilePicURL)
|
setRemoteIcon(item, profile.UserProfile.ProfilePicURL)
|
||||||
go func(profile ipn.LoginProfile) {
|
onClick(ctx, item, func(ctx context.Context) {
|
||||||
for {
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
|
||||||
case <-item.ClickedCh:
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case menu.accountsCh <- profile.ID:
|
case menu.accountsCh <- profile.ID:
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
|
||||||
}(profile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if state.status != nil && state.status.Self != nil {
|
if state.status != nil && state.status.Self != nil && len(state.status.Self.TailscaleIPs) > 0 {
|
||||||
title := fmt.Sprintf("This Device: %s (%s)", state.status.Self.HostName, state.status.Self.TailscaleIPs[0])
|
title := fmt.Sprintf("This Device: %s (%s)", state.status.Self.HostName, state.status.Self.TailscaleIPs[0])
|
||||||
menu.self = systray.AddMenuItem(title, "")
|
menu.self = systray.AddMenuItem(title, "")
|
||||||
|
} else {
|
||||||
|
menu.self = systray.AddMenuItem("This Device: not connected", "")
|
||||||
|
menu.self.Disable()
|
||||||
}
|
}
|
||||||
systray.AddSeparator()
|
systray.AddSeparator()
|
||||||
|
|
||||||
@ -266,6 +268,8 @@ func (menu *Menu) eventLoop(ctx context.Context) {
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
|
case <-chRebuild:
|
||||||
|
menu.rebuild(fetchState(ctx))
|
||||||
case state := <-chState:
|
case state := <-chState:
|
||||||
switch state {
|
switch state {
|
||||||
case ipn.Running:
|
case ipn.Running:
|
||||||
@ -277,10 +281,11 @@ func (menu *Menu) eventLoop(ctx context.Context) {
|
|||||||
menu.disconnect.Show()
|
menu.disconnect.Show()
|
||||||
menu.disconnect.Enable()
|
menu.disconnect.Enable()
|
||||||
case ipn.NoState, ipn.Stopped:
|
case ipn.NoState, ipn.Stopped:
|
||||||
|
setAppIcon(disconnected)
|
||||||
|
menu.rebuild(fetchState(ctx))
|
||||||
menu.connect.SetTitle("Connect")
|
menu.connect.SetTitle("Connect")
|
||||||
menu.connect.Enable()
|
menu.connect.Enable()
|
||||||
menu.disconnect.Hide()
|
menu.disconnect.Hide()
|
||||||
setAppIcon(disconnected)
|
|
||||||
case ipn.Starting:
|
case ipn.Starting:
|
||||||
setAppIcon(loading)
|
setAppIcon(loading)
|
||||||
}
|
}
|
||||||
@ -337,7 +342,6 @@ func (menu *Menu) eventLoop(ctx context.Context) {
|
|||||||
log.Printf("failed setting exit node: %v", err)
|
log.Printf("failed setting exit node: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
menu.rebuild(fetchState(ctx))
|
|
||||||
|
|
||||||
case <-menu.quit.ClickedCh:
|
case <-menu.quit.ClickedCh:
|
||||||
systray.Quit()
|
systray.Quit()
|
||||||
@ -345,6 +349,20 @@ func (menu *Menu) eventLoop(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// watchIPNBus subscribes to the tailscale event bus and sends state updates to chState.
|
||||||
// This method does not return.
|
// This method does not return.
|
||||||
func watchIPNBus(ctx context.Context) {
|
func watchIPNBus(ctx context.Context) {
|
||||||
@ -383,6 +401,9 @@ func watchIPNBusInner(ctx context.Context) error {
|
|||||||
chState <- *n.State
|
chState <- *n.State
|
||||||
log.Printf("new state: %v", n.State)
|
log.Printf("new state: %v", n.State)
|
||||||
}
|
}
|
||||||
|
if n.Prefs != nil {
|
||||||
|
chRebuild <- struct{}{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -425,25 +446,17 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
|||||||
time.Sleep(newMenuDelay)
|
time.Sleep(newMenuDelay)
|
||||||
|
|
||||||
// register a click handler for a menu item to set nodeID as the exit node.
|
// register a click handler for a menu item to set nodeID as the exit node.
|
||||||
onClick := func(item *systray.MenuItem, nodeID tailcfg.StableNodeID) {
|
setExitNodeOnClick := func(item *systray.MenuItem, nodeID tailcfg.StableNodeID) {
|
||||||
go func() {
|
onClick(ctx, item, func(ctx context.Context) {
|
||||||
for {
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
|
||||||
case <-item.ClickedCh:
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case menu.exitNodeCh <- nodeID:
|
case menu.exitNodeCh <- nodeID:
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
noExitNodeMenu := menu.exitNodes.AddSubMenuItemCheckbox("None", "", status.ExitNodeStatus == nil)
|
noExitNodeMenu := menu.exitNodes.AddSubMenuItemCheckbox("None", "", status.ExitNodeStatus == nil)
|
||||||
onClick(noExitNodeMenu, "")
|
setExitNodeOnClick(noExitNodeMenu, "")
|
||||||
|
|
||||||
// Show recommended exit node if available.
|
// Show recommended exit node if available.
|
||||||
if status.Self.CapMap.Contains(tailcfg.NodeAttrSuggestExitNodeUI) {
|
if status.Self.CapMap.Contains(tailcfg.NodeAttrSuggestExitNodeUI) {
|
||||||
@ -458,7 +471,7 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
menu.exitNodes.AddSeparator()
|
menu.exitNodes.AddSeparator()
|
||||||
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", false)
|
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", false)
|
||||||
onClick(rm, sugg.ID)
|
setExitNodeOnClick(rm, sugg.ID)
|
||||||
if status.ExitNodeStatus != nil && sugg.ID == status.ExitNodeStatus.ID {
|
if status.ExitNodeStatus != nil && sugg.ID == status.ExitNodeStatus.ID {
|
||||||
rm.Check()
|
rm.Check()
|
||||||
}
|
}
|
||||||
@ -490,7 +503,7 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
|||||||
if status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID {
|
if status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID {
|
||||||
sm.Check()
|
sm.Check()
|
||||||
}
|
}
|
||||||
onClick(sm, ps.ID)
|
setExitNodeOnClick(sm, ps.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -510,7 +523,7 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
|||||||
|
|
||||||
// single-city country, no submenu
|
// single-city country, no submenu
|
||||||
if len(country.cities) == 1 || hideMullvadCities {
|
if len(country.cities) == 1 || hideMullvadCities {
|
||||||
onClick(countryMenu, country.best.ID)
|
setExitNodeOnClick(countryMenu, country.best.ID)
|
||||||
if status.ExitNodeStatus != nil {
|
if status.ExitNodeStatus != nil {
|
||||||
for _, city := range country.cities {
|
for _, city := range country.cities {
|
||||||
for _, ps := range city.peers {
|
for _, ps := range city.peers {
|
||||||
@ -527,12 +540,12 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
|||||||
// multi-city country, build submenu with "best available" option and cities.
|
// multi-city country, build submenu with "best available" option and cities.
|
||||||
time.Sleep(newMenuDelay)
|
time.Sleep(newMenuDelay)
|
||||||
bm := countryMenu.AddSubMenuItemCheckbox("Best Available", "", false)
|
bm := countryMenu.AddSubMenuItemCheckbox("Best Available", "", false)
|
||||||
onClick(bm, country.best.ID)
|
setExitNodeOnClick(bm, country.best.ID)
|
||||||
countryMenu.AddSeparator()
|
countryMenu.AddSeparator()
|
||||||
|
|
||||||
for _, city := range country.sortedCities() {
|
for _, city := range country.sortedCities() {
|
||||||
cityMenu := countryMenu.AddSubMenuItemCheckbox(city.name, "", false)
|
cityMenu := countryMenu.AddSubMenuItemCheckbox(city.name, "", false)
|
||||||
onClick(cityMenu, city.best.ID)
|
setExitNodeOnClick(cityMenu, city.best.ID)
|
||||||
if status.ExitNodeStatus != nil {
|
if status.ExitNodeStatus != nil {
|
||||||
for _, ps := range city.peers {
|
for _, ps := range city.peers {
|
||||||
if status.ExitNodeStatus.ID == ps.ID {
|
if status.ExitNodeStatus.ID == ps.ID {
|
||||||
@ -558,7 +571,7 @@ type mullvadPeers struct {
|
|||||||
func (mp mullvadPeers) sortedCountries() []*mvCountry {
|
func (mp mullvadPeers) sortedCountries() []*mvCountry {
|
||||||
countries := slices.Collect(maps.Values(mp.countries))
|
countries := slices.Collect(maps.Values(mp.countries))
|
||||||
slices.SortFunc(countries, func(a, b *mvCountry) int {
|
slices.SortFunc(countries, func(a, b *mvCountry) int {
|
||||||
return cmp.Compare(a.name, b.name)
|
return stringsx.CompareFold(a.name, b.name)
|
||||||
})
|
})
|
||||||
return countries
|
return countries
|
||||||
}
|
}
|
||||||
@ -574,7 +587,7 @@ type mvCountry struct {
|
|||||||
func (mc *mvCountry) sortedCities() []*mvCity {
|
func (mc *mvCountry) sortedCities() []*mvCity {
|
||||||
cities := slices.Collect(maps.Values(mc.cities))
|
cities := slices.Collect(maps.Values(mc.cities))
|
||||||
slices.SortFunc(cities, func(a, b *mvCity) int {
|
slices.SortFunc(cities, func(a, b *mvCity) int {
|
||||||
return cmp.Compare(a.name, b.name)
|
return stringsx.CompareFold(a.name, b.name)
|
||||||
})
|
})
|
||||||
return cities
|
return cities
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user