diff --git a/client/systray/systray.go b/client/systray/systray.go index d0287e647..33155008e 100644 --- a/client/systray/systray.go +++ b/client/systray/systray.go @@ -292,21 +292,23 @@ func (menu *Menu) rebuild() { 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 len(menu.allProfiles) > 1 { + 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: + } + }) + } } } @@ -352,16 +354,27 @@ func (menu *Menu) rebuild() { // profileTitle returns the title string for a profile menu item. func profileTitle(profile ipn.LoginProfile) string { - title := profile.Name + tailnet := "" if profile.NetworkProfile.DomainName != "" { - if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { - // windows and mac don't support multi-line menu - title += " (" + profile.NetworkProfile.DisplayNameOrDefault() + ")" - } else { - title += "\n" + profile.NetworkProfile.DisplayNameOrDefault() - } + tailnet = profile.NetworkProfile.DisplayNameOrDefault() } - return title + // windows and mac don't support multi-line menu items. + multiline := runtime.GOOS != "windows" && runtime.GOOS != "darwin" + + return formatProfileTitle(profile.Name, tailnet, multiline) +} + +// formatProfileTitle builds a profile menu label from a login name and an +// optional tailnet name. The tailnet portion is omitted when it matches the +// login name, so single-user tailnets don't show the same string twice. +func formatProfileTitle(name, tailnet string, multiline bool) string { + if tailnet == "" || strings.EqualFold(name, tailnet) { + return name + } + if multiline { + return name + "\n" + tailnet + } + return name + " (" + tailnet + ")" } var ( diff --git a/client/systray/systray_test.go b/client/systray/systray_test.go index 6b8ce8b95..6bb2bfee3 100644 --- a/client/systray/systray_test.go +++ b/client/systray/systray_test.go @@ -13,6 +13,33 @@ import ( "tailscale.com/types/key" ) +func TestProfileTitleMultiline(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + login string + tailnet string + multiline bool + want string + }{ + {"no_tailnet", "alice@example.com", "", true, "alice@example.com"}, + {"dup_exact", "example.com", "example.com", true, "example.com"}, + {"dup_casefold", "Example.com", "example.com", false, "Example.com"}, + {"distinct_multiline", "alice@example.com", "example.com", true, "alice@example.com\nexample.com"}, + {"distinct_singleline", "alice@example.com", "example.com", false, "alice@example.com (example.com)"}, + {"empty", "", "", true, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := formatProfileTitle(tt.login, tt.tailnet, tt.multiline); got != tt.want { + t.Errorf("profileTitleMultiline; got %v, want %v", got, tt.want) + } + }) + } +} + func TestRecommendedIsActive(t *testing.T) { t.Parallel()