cmd/systray: improve profile menu

Bring UI closer to macOS and windows:
- split login and tailnet name over separate lines
- render profile picture (with very simple caching)
- use checkbox to indicate active profile. I've not found any desktops
  that can't render checkboxes, so I'd like to explore other options
  if needed.

Updates #1708

Change-Id: Ia101a4a3005adb9118051b3416f5a64a4a45987d
Signed-off-by: Will Norris <will@tailscale.com>
This commit is contained in:
Will Norris 2024-12-19 11:31:31 -08:00 committed by Will Norris
parent e8f1721147
commit 89adcd853d

View File

@ -12,6 +12,7 @@ import (
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
@ -118,10 +119,11 @@ func (menu *Menu) rebuild(state state) {
systray.AddSeparator()
account := "Account"
if state.curProfile.Name != "" {
account += fmt.Sprintf(" (%s)", state.curProfile.Name)
if pt := profileTitle(state.curProfile); pt != "" {
account = pt
}
accounts := systray.AddMenuItem(account, "")
setRemoteIcon(accounts, state.curProfile.UserProfile.ProfilePicURL)
// The dbus message about this menu item must propagate to the receiving
// end before we attach any submenu items. Otherwise the receiver may not
// yet record the parent menu item and error out.
@ -132,13 +134,14 @@ func (menu *Menu) rebuild(state state) {
// Aggregate all clicks into a shared channel.
menu.accountsCh = make(chan ipn.ProfileID)
for _, profile := range state.allProfiles {
title := fmt.Sprintf("%s (%s)", profile.Name, profile.NetworkProfile.DomainName)
// Note: we could use AddSubMenuItemCheckbox instead of this formatting
// hack, but checkboxes don't work across all desktops unfortunately.
title := profileTitle(profile)
var item *systray.MenuItem
if profile.ID == state.curProfile.ID {
title = "* " + title
item = accounts.AddSubMenuItemCheckbox(title, "", true)
} else {
item = accounts.AddSubMenuItem(title, "")
}
item := accounts.AddSubMenuItem(title, "")
setRemoteIcon(item, profile.UserProfile.ProfilePicURL)
go func(profile ipn.LoginProfile) {
for {
select {
@ -170,6 +173,44 @@ func (menu *Menu) rebuild(state state) {
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 != "" {
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)
}
}
// 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.