Skip to content

Commit cd5bd4a

Browse files
alex-poorclaude
andcommitted
internal/tray: add profile switching submenu to tray menu
Allow switching Tailscale profiles directly from the system tray right-click menu, without needing to open the main window. The active profile is shown with a bullet indicator that updates whether the switch is initiated from the tray submenu or from the main window's profile dropdown. Depends on DeedleFake/tray#2, which fixes three bugs in the tray library that were preventing submenu children from rendering and causing a deadlock when SetProps is called concurrently with the desktop environment's GetLayout requests. The temporary replace directive in go.mod should be removed once that PR is merged and deedles.dev/tray is updated to a new pseudo-version. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 061ec0f commit cd5bd4a

4 files changed

Lines changed: 111 additions & 14 deletions

File tree

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,5 @@ require (
106106
)
107107

108108
tool honnef.co/go/tools/cmd/staticcheck
109+
110+
replace deedles.dev/tray => github.com/alex-poor/tray v0.1.11-0.20260407004037-2451d98fc544

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
33
deedles.dev/mk v0.1.0 h1:xrvuJA3+R/j6/6AZPc+o31I1rotdKLrAYJxhZJwdOuc=
44
deedles.dev/mk v0.1.0/go.mod h1:TSFsz0T+BvhNqJae0yrj+KadkN4elx248PCpq2Ol4ME=
5-
deedles.dev/tray v0.1.11-0.20251126205835-30c3ecc68b10 h1:UGvQ/yXKOutxqV7cZrPgdqqs/mjSA8OPR0PL15qmnuQ=
6-
deedles.dev/tray v0.1.11-0.20251126205835-30c3ecc68b10/go.mod h1:Lfd/jNMT3QUxxQ23qt/JYM1bDAqwPIj+A0dWX/qtw+Q=
75
deedles.dev/xiter v0.2.1 h1:yyyfo1sDwARp5lyMvILBJpEI28sIFN0TNYagAdBUa+s=
86
deedles.dev/xiter v0.2.1/go.mod h1:59997UHUsKAy/8bHUClTfeXdyuLZ6z/+yF++vIpxfx8=
97
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
@@ -20,6 +18,8 @@ github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9 h1:1ltq
2018
github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9/go.mod h1:7uhhqiBaR4CpN0k9rMjOtjpcfGd6DG2m04zQxKnWQ0I=
2119
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
2220
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
21+
github.com/alex-poor/tray v0.1.11-0.20260407004037-2451d98fc544 h1:C08lmoOuP+D33/PA2mFs4q+fw3593TPVlvWiR9dsrjA=
22+
github.com/alex-poor/tray v0.1.11-0.20260407004037-2451d98fc544/go.mod h1:T6LSCKPLN2Y+PfawRiZOq3awibqxBm4RCmYrDQgMf04=
2323
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
2424
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
2525
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=

internal/tray/tray.go

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import (
1010
"unique"
1111

1212
"deedles.dev/tray"
13+
"deedles.dev/trayscale/internal/metadata"
1314
"deedles.dev/trayscale/internal/tsutil"
15+
"tailscale.com/ipn"
1416
)
1517

1618
var (
@@ -48,11 +50,12 @@ func handler(f func()) tray.MenuItemProp {
4850
}
4951

5052
type Tray struct {
51-
OnShow func()
52-
OnConnToggle func()
53-
OnExitToggle func()
54-
OnSelfNode func()
55-
OnQuit func()
53+
OnShow func()
54+
OnConnToggle func()
55+
OnExitToggle func()
56+
OnSelfNode func()
57+
OnProfileSwitch func(ipn.ProfileID)
58+
OnQuit func()
5659

5760
m sync.Mutex
5861
item *tray.Item
@@ -62,10 +65,12 @@ type Tray struct {
6265
connToggleItem *tray.MenuItem
6366
exitToggleItem *tray.MenuItem
6467
selfNodeItem *tray.MenuItem
68+
profileItems map[ipn.ProfileID]*tray.MenuItem
69+
profileNames map[ipn.ProfileID]string
6570
quitItem *tray.MenuItem
6671
}
6772

68-
func (t *Tray) Start(status *tsutil.IPNStatus) error {
73+
func (t *Tray) Start(status *tsutil.IPNStatus, profiles *tsutil.ProfileStatus) error {
6974
if t.item != nil {
7075
return nil
7176
}
@@ -89,6 +94,38 @@ func (t *Tray) Start(status *tsutil.IPNStatus) error {
8994

9095
menu := item.Menu()
9196

97+
// The "Switch account" submenu must be the first item added to the
98+
// menu. There appears to be a quirk in dbusmenu (or some desktop
99+
// environment implementations) where children of a submenu added
100+
// via MenuItem.AddChild only render correctly when the submenu has
101+
// no preceding siblings at the time it is created.
102+
t.profileItems = make(map[ipn.ProfileID]*tray.MenuItem)
103+
t.profileNames = make(map[ipn.ProfileID]string)
104+
if profiles != nil && len(profiles.Profiles) > 1 {
105+
submenu, _ := menu.AddChild(tray.MenuItemLabel("Switch account"))
106+
for _, profile := range profiles.Profiles {
107+
id := profile.ID
108+
name := profileName(profile)
109+
t.profileNames[id] = name
110+
111+
label := " " + name
112+
if id == profiles.Profile.ID {
113+
label = "● " + name
114+
}
115+
116+
child, _ := submenu.AddChild(
117+
tray.MenuItemLabel(label),
118+
handler(func() {
119+
if t.OnProfileSwitch != nil {
120+
t.OnProfileSwitch(id)
121+
}
122+
}),
123+
)
124+
t.profileItems[id] = child
125+
}
126+
menu.AddChild(tray.MenuItemType(tray.Separator))
127+
}
128+
92129
t.showItem, _ = menu.AddChild(tray.MenuItemLabel("Show"), handler(t.OnShow))
93130
menu.AddChild(tray.MenuItemType(tray.Separator))
94131
t.connToggleItem, _ = menu.AddChild(handler(t.OnConnToggle))
@@ -220,3 +257,33 @@ func exitToggleText(status *tsutil.IPNStatus) string {
220257

221258
return "Enable exit node"
222259
}
260+
261+
// SetActiveProfile updates the profile indicator labels. It must be
262+
// called from a non-GTK goroutine to avoid deadlocking with D-Bus.
263+
func (t *Tray) SetActiveProfile(id ipn.ProfileID) {
264+
if t == nil {
265+
return
266+
}
267+
268+
// Serialize indicator updates so concurrent callers cannot
269+
// interleave their SetProps calls and end up with multiple items
270+
// marked active.
271+
t.m.Lock()
272+
defer t.m.Unlock()
273+
274+
for pid, item := range t.profileItems {
275+
name := t.profileNames[pid]
276+
label := " " + name
277+
if pid == id {
278+
label = "● " + name
279+
}
280+
item.SetProps(tray.MenuItemLabel(label))
281+
}
282+
}
283+
284+
func profileName(profile ipn.LoginProfile) string {
285+
if metadata.Private {
286+
return "profile@example.com"
287+
}
288+
return profile.Name
289+
}

internal/ui/app.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/diamondburned/gotk4/pkg/gtk/v4"
2222
"github.com/inhies/go-bytesize"
2323
"tailscale.com/client/tailscale/apitype"
24+
"tailscale.com/ipn"
2425
"tailscale.com/tailcfg"
2526
)
2627

@@ -33,10 +34,11 @@ type App struct {
3334
poller *tsutil.Poller
3435
online bool
3536

36-
app *adw.Application
37-
win *MainWindow
38-
settings *gio.Settings
39-
tray *tray.Tray
37+
app *adw.Application
38+
win *MainWindow
39+
settings *gio.Settings
40+
tray *tray.Tray
41+
activeProfile ipn.ProfileID
4042

4143
spinnum int
4244
operatorCheck bool
@@ -146,6 +148,11 @@ func (a *App) update(status tsutil.Status) {
146148
}
147149

148150
case *tsutil.ProfileStatus:
151+
if a.tray != nil && a.activeProfile != status.Profile.ID {
152+
a.activeProfile = status.Profile.ID
153+
go a.tray.SetActiveProfile(status.Profile.ID)
154+
}
155+
149156
if a.win != nil {
150157
a.win.Update(status)
151158
}
@@ -358,7 +365,7 @@ func (a *App) onAppActivate(ctx context.Context) {
358365

359366
func (a *App) initTray(ctx context.Context) {
360367
if a.tray != nil {
361-
err := a.tray.Start(<-a.poller.GetIPN())
368+
err := a.tray.Start(<-a.poller.GetIPN(), nil)
362369
if err != nil {
363370
slog.Error("failed to start tray icon", "err", err)
364371
}
@@ -428,12 +435,33 @@ func (a *App) initTray(ctx context.Context) {
428435
})
429436
},
430437

438+
OnProfileSwitch: func(id ipn.ProfileID) {
439+
go func() {
440+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
441+
defer cancel()
442+
443+
err := tsutil.SwitchProfile(ctx, id)
444+
if err != nil {
445+
slog.Error("switch profile from tray", "err", err)
446+
return
447+
}
448+
449+
a.tray.SetActiveProfile(id)
450+
}()
451+
},
452+
431453
OnQuit: func() {
432454
a.Quit()
433455
},
434456
}
435457

436-
err := a.tray.Start(<-a.poller.GetIPN())
458+
var ps *tsutil.ProfileStatus
459+
profile, profiles, err := tsutil.GetProfileStatus(ctx)
460+
if err == nil {
461+
ps = &tsutil.ProfileStatus{Profile: profile, Profiles: profiles}
462+
}
463+
464+
err = a.tray.Start(<-a.poller.GetIPN(), ps)
437465
if err != nil {
438466
slog.Error("failed to start tray icon", "err", err)
439467
}

0 commit comments

Comments
 (0)