From 326bf49998a5e4bdab3b89aebc9b7477e074ab98 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 13:52:47 -0400 Subject: [PATCH 01/38] internal/tsutil: change `Poller.Poll()` to be a receive-only channel This makes way more sense. Now it doesn't require manual `struct{}{}` usages and it can't be closed accidentally and break things. --- internal/tsutil/poller.go | 17 +++++++---------- internal/ui/app.go | 10 +++++----- internal/ui/io.go | 2 +- internal/ui/mullvadpage.go | 2 +- internal/ui/peerpage.go | 4 ++-- internal/ui/selfpage.go | 12 ++++++------ internal/ui/settings.go | 2 +- 7 files changed, 23 insertions(+), 26 deletions(-) diff --git a/internal/tsutil/poller.go b/internal/tsutil/poller.go index 1879fae..6304d78 100644 --- a/internal/tsutil/poller.go +++ b/internal/tsutil/poller.go @@ -143,7 +143,7 @@ func (p *Poller) Run(ctx context.Context) { case <-ctx.Done(): return case <-check.C: - case <-p.poll: + case p.poll <- struct{}{}: check.Reset(interval) case interval = <-p.interval: check.Reset(interval) @@ -154,15 +154,12 @@ func (p *Poller) Run(ctx context.Context) { } } -// Poll returns a channel that, when sent to, causes a new status to -// be fetched from Tailscale. A send to the channel does not resolve -// until the poller begins to fetch the status, meaning that a send to -// Poll followed immediately by a receive from Get will always result -// in the new Status. -// -// Do not close the returned channel. Doing so will result in -// undefined behavior. -func (p *Poller) Poll() chan<- struct{} { +// Poll returns a channel that, when received from, causes a new +// status to be fetched from Tailscale. A receive from the channel +// does not resolve until the poller begins to fetch the status, +// meaning that a receive from Poll followed immediately by a receive +// from Get will always result in the new Status. +func (p *Poller) Poll() <-chan struct{} { p.init() return p.poll diff --git a/internal/ui/app.go b/internal/ui/app.go index a373bfb..a555bc6 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -178,7 +178,7 @@ func (a *App) startTS(ctx context.Context) error { if err != nil { return err } - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() return nil } @@ -187,7 +187,7 @@ func (a *App) stopTS(ctx context.Context) error { if err != nil { return err } - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() return nil } @@ -298,7 +298,7 @@ func (a *App) onAppActivate(ctx context.Context) { slog.Error("failed to switch profiles", "err", err, "id", profile.ID, "name", profile.Name) return } - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() }) contentVariant := glib.NewVariantString("content") @@ -310,7 +310,7 @@ func (a *App) onAppActivate(ctx context.Context) { a.win = nil return false }) - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() a.win.MainWindow.Present() } @@ -366,7 +366,7 @@ func (a *App) initTray(ctx context.Context) { slog.Error("toggle exit node from tray", "err", err) return } - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() if toggle { a.notify("Exit node", "Enabled") diff --git a/internal/ui/io.go b/internal/ui/io.go index 8a5971c..d927949 100644 --- a/internal/ui/io.go +++ b/internal/ui/io.go @@ -68,6 +68,6 @@ func (a *App) saveFile(ctx context.Context, name string, file gio.Filer) { return } - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() slog.Info("done saving file") } diff --git a/internal/ui/mullvadpage.go b/internal/ui/mullvadpage.go index d601ba9..87a6bc9 100644 --- a/internal/ui/mullvadpage.go +++ b/internal/ui/mullvadpage.go @@ -178,7 +178,7 @@ func (page *MullvadPage) getExitNodeRow(peer *ipnstate.PeerStatus) *mullvadExitN sw.SetActive(!s) return true } - page.app.poller.Poll() <- struct{}{} + <-page.app.poller.Poll() return true }) diff --git a/internal/ui/peerpage.go b/internal/ui/peerpage.go index 2cffd0d..20eedaa 100644 --- a/internal/ui/peerpage.go +++ b/internal/ui/peerpage.go @@ -188,7 +188,7 @@ func (page *PeerPage) init(a *App, status *tsutil.Status, peer *ipnstate.PeerSta slog.Error("advertise routes", "err", err) return } - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() }) row := adw.NewActionRow() @@ -227,7 +227,7 @@ func (page *PeerPage) init(a *App, status *tsutil.Status, peer *ipnstate.PeerSta page.ExitNodeRow.ActivatableWidget().(*gtk.Switch).SetActive(!s) return true } - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() return true }) } diff --git a/internal/ui/selfpage.go b/internal/ui/selfpage.go index 8d85924..2b41ff2 100644 --- a/internal/ui/selfpage.go +++ b/internal/ui/selfpage.go @@ -141,7 +141,7 @@ func (page *SelfPage) init(a *App, status *tsutil.Status) { slog.Error("advertise routes", "err", err) return } - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() }) row := adw.NewActionRow() @@ -202,7 +202,7 @@ func (page *SelfPage) init(a *App, status *tsutil.Status) { slog.Error("delete file", "err", err) return } - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() } }) }) @@ -240,7 +240,7 @@ func (page *SelfPage) init(a *App, status *tsutil.Status) { page.AdvertiseExitNodeRow.ActivatableWidget().(*gtk.Switch).SetActive(!s) return true } - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() return true }) @@ -255,7 +255,7 @@ func (page *SelfPage) init(a *App, status *tsutil.Status) { page.AllowLANAccessRow.ActivatableWidget().(*gtk.Switch).SetActive(!s) return true } - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() return true }) @@ -270,7 +270,7 @@ func (page *SelfPage) init(a *App, status *tsutil.Status) { page.AcceptRoutesRow.ActivatableWidget().(*gtk.Switch).SetActive(!s) return true } - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() return true }) @@ -308,7 +308,7 @@ func (page *SelfPage) init(a *App, status *tsutil.Status) { return } - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() }) }) diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 10246ce..241df00 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -81,7 +81,7 @@ func (a *App) showChangeControlServer() { a.win.Toast(fmt.Sprintf("Error setting control URL: %v", err)) return } - a.poller.Poll() <- struct{}{} + <-a.poller.Poll() } }) } From 8456a7cbdbdb3cede48af55776db2111a1ebe538 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 14:07:42 -0400 Subject: [PATCH 02/38] internal/tsutil: add a small clarification to a godoc comment --- internal/tsutil/poller.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/tsutil/poller.go b/internal/tsutil/poller.go index 6304d78..30e251d 100644 --- a/internal/tsutil/poller.go +++ b/internal/tsutil/poller.go @@ -168,6 +168,9 @@ func (p *Poller) Poll() <-chan struct{} { // Get returns a channel that will yield the latest Status fetched. If // a new Status is in the process of being fetched, it will wait for // that to finish and then yield that. +// +// Note that receiving from this channel does not trigger a poll. To +// do that, receive from [Poll] first. func (p *Poller) Get() <-chan *Status { p.init() From d286640226c33bb10ffc92f89e4f072e1b13e32a Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 14:12:42 -0400 Subject: [PATCH 03/38] internal/ui: move profile dropdown handling into `MainWindow` --- internal/ui/app.go | 58 +----------------------------- internal/ui/mainwindow.go | 74 +++++++++++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 64 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index a555bc6..387c2ec 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -20,7 +20,6 @@ import ( "github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/inhies/go-bytesize" "tailscale.com/client/tailscale/apitype" - "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" ) @@ -40,7 +39,6 @@ type App struct { spinnum int operatorCheck bool - profiles []ipn.LoginProfile files *[]apitype.WaitingFile } @@ -101,8 +99,6 @@ func (a *App) update(s *tsutil.Status) { } a.files = &s.Files - a.profiles = s.Profiles - if a.win == nil { return } @@ -253,63 +249,11 @@ func (a *App) onAppActivate(ctx context.Context) { a.app.SetAccelsForAction("app.quit", []string{"q"}) a.win = NewMainWindow(a) - - a.win.StatusSwitch.ConnectStateSet(func(s bool) bool { - if s == a.win.StatusSwitch.State() { - return false - } - - // TODO: Handle this, and other switches, asynchrounously instead - // of freezing the entire UI. - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - f := a.stopTS - if s { - f = a.startTS - } - - err := f(ctx) - if err != nil { - slog.Error("set Tailscale status", "err", err) - a.win.StatusSwitch.SetActive(!s) - return true - } - return true - }) - - a.win.ProfileDropDown.NotifyProperty("selected-item", func() { - item := a.win.ProfileDropDown.SelectedItem().Cast().(*gtk.StringObject).String() - index := slices.IndexFunc(a.profiles, func(p ipn.LoginProfile) bool { - // TODO: Find a reasonable way to do this by profile ID instead. - return p.Name == item - }) - if index < 0 { - slog.Error("selected unknown profile", "name", item) - return - } - profile := a.profiles[index] - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - err := tsutil.SwitchProfile(ctx, profile.ID) - if err != nil { - slog.Error("failed to switch profiles", "err", err, "id", profile.ID, "name", profile.Name) - return - } - <-a.poller.Poll() - }) - - contentVariant := glib.NewVariantString("content") - a.win.PeersStack.NotifyProperty("visible-child", func() { - a.win.SplitView.ActivateAction("navigation.push", contentVariant) - }) - a.win.MainWindow.ConnectCloseRequest(func() bool { a.win = nil return false }) + <-a.poller.Poll() a.win.MainWindow.Present() } diff --git a/internal/ui/mainwindow.go b/internal/ui/mainwindow.go index 1fb836f..01d7cdf 100644 --- a/internal/ui/mainwindow.go +++ b/internal/ui/mainwindow.go @@ -1,15 +1,21 @@ package ui import ( + "context" _ "embed" + "log/slog" + "slices" "strings" + "time" "deedles.dev/trayscale/internal/listmodels" "deedles.dev/trayscale/internal/metadata" "deedles.dev/trayscale/internal/tsutil" "github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4/pkg/gio/v2" + "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" + "tailscale.com/ipn" ) //go:embed mainwindow.ui @@ -32,8 +38,9 @@ type MainWindow struct { pages map[string]Page statusPage *adw.StatusPage - ProfileModel *gtk.StringList - ProfileSortModel *gtk.SortListModel + profiles []ipn.LoginProfile + profileModel *gtk.StringList + profileSortModel *gtk.SortListModel } func NewMainWindow(app *App) *MainWindow { @@ -115,9 +122,61 @@ func NewMainWindow(app *App) *MainWindow { win.PeersStack.SetVisibleChildName(name) }) - win.ProfileModel = gtk.NewStringList(nil) - win.ProfileSortModel = gtk.NewSortListModel(win.ProfileModel, &stringListSorter.Sorter) - win.ProfileDropDown.SetModel(win.ProfileSortModel) + win.profileModel = gtk.NewStringList(nil) + win.profileSortModel = gtk.NewSortListModel(win.profileModel, &stringListSorter.Sorter) + win.ProfileDropDown.SetModel(win.profileSortModel) + + win.StatusSwitch.ConnectStateSet(func(s bool) bool { + if s == win.StatusSwitch.State() { + return false + } + + // TODO: Handle this, and other switches, asynchrounously instead + // of freezing the entire UI. + ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) + defer cancel() + + f := app.stopTS + if s { + f = app.startTS + } + + err := f(ctx) + if err != nil { + slog.Error("set Tailscale status", "err", err) + win.StatusSwitch.SetActive(!s) + return true + } + return true + }) + + win.ProfileDropDown.NotifyProperty("selected-item", func() { + item := win.ProfileDropDown.SelectedItem().Cast().(*gtk.StringObject).String() + index := slices.IndexFunc(win.profiles, func(p ipn.LoginProfile) bool { + // TODO: Find a reasonable way to do this by profile ID instead. + return p.Name == item + }) + if index < 0 { + slog.Error("selected unknown profile", "name", item) + return + } + profile := win.profiles[index] + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := tsutil.SwitchProfile(ctx, profile.ID) + if err != nil { + slog.Error("failed to switch profiles", "err", err, "id", profile.ID, "name", profile.Name) + return + } + <-app.poller.Poll() + }) + + contentVariant := glib.NewVariantString("content") + win.PeersStack.NotifyProperty("visible-child", func() { + win.SplitView.ActivateAction("navigation.push", contentVariant) + }) return &win } @@ -202,7 +261,8 @@ func (win *MainWindow) updatePeers(status *tsutil.Status) { } func (win *MainWindow) updateProfiles(s *tsutil.Status) { - listmodels.UpdateStrings(win.ProfileModel, func(yield func(string) bool) { + win.profiles = s.Profiles + listmodels.UpdateStrings(win.profileModel, func(yield func(string) bool) { for _, profile := range s.Profiles { name := profile.Name if metadata.Private { @@ -214,7 +274,7 @@ func (win *MainWindow) updateProfiles(s *tsutil.Status) { } }) - profileIndex, ok := listmodels.Index(win.ProfileSortModel, func(obj *gtk.StringObject) bool { + profileIndex, ok := listmodels.Index(win.profileSortModel, func(obj *gtk.StringObject) bool { return obj.String() == s.Profile.Name }) if ok { From f2f98afbbfd6894ce544b68362d0511a77afc235 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 14:22:53 -0400 Subject: [PATCH 04/38] internal/ui: add `OfflinePage` --- internal/ui/mainwindow.go | 17 +++++---------- internal/ui/offlinepage.go | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 internal/ui/offlinepage.go diff --git a/internal/ui/mainwindow.go b/internal/ui/mainwindow.go index 01d7cdf..8900f24 100644 --- a/internal/ui/mainwindow.go +++ b/internal/ui/mainwindow.go @@ -35,8 +35,7 @@ type MainWindow struct { ProfileDropDown *gtk.DropDown PageMenuButton *gtk.MenuButton - pages map[string]Page - statusPage *adw.StatusPage + pages map[string]Page profiles []ipn.LoginProfile profileModel *gtk.StringList @@ -52,11 +51,6 @@ func NewMainWindow(app *App) *MainWindow { win.MainWindow.SetApplication(&app.app.Application) - win.statusPage = adw.NewStatusPage() - win.statusPage.SetTitle("Not Connected") - win.statusPage.SetIconName("network-offline-symbolic") - win.statusPage.SetDescription("Tailscale is not connected") - win.PeersStack.NotifyProperty("visible-child-name", func() { page := win.pages[win.PeersStack.VisibleChildName()] @@ -203,7 +197,7 @@ func (win *MainWindow) Update(status *tsutil.Status) { func (win *MainWindow) updatePeersOffline() { var found bool for name, page := range win.pages { - if name == "status" { + if name == "offline" { found = true continue } @@ -211,8 +205,7 @@ func (win *MainWindow) updatePeersOffline() { win.removePage(name, page) } if !found { - vp := win.PeersStack.AddTitled(win.statusPage, "status", "Not Connected") - vp.SetIconName("network-offline-symbolic") + win.addPage("offline", NewOfflinePage(win.app)) } } @@ -222,8 +215,8 @@ func (win *MainWindow) updatePeers(status *tsutil.Status) { return } - if win.PeersStack.ChildByName("status") != nil { - win.PeersStack.Remove(win.statusPage) + if page := win.pages["offline"]; page != nil { + win.removePage("offline", page) } if _, ok := win.pages["self"]; !ok { diff --git a/internal/ui/offlinepage.go b/internal/ui/offlinepage.go new file mode 100644 index 0000000..e1dcb41 --- /dev/null +++ b/internal/ui/offlinepage.go @@ -0,0 +1,42 @@ +package ui + +import ( + "deedles.dev/trayscale/internal/tsutil" + "github.com/diamondburned/gotk4-adwaita/pkg/adw" + "github.com/diamondburned/gotk4/pkg/gio/v2" + "github.com/diamondburned/gotk4/pkg/gtk/v4" +) + +type OfflinePage struct { + app *App + + Page *adw.StatusPage +} + +func NewOfflinePage(app *App) *OfflinePage { + page := OfflinePage{app: app} + + page.Page = adw.NewStatusPage() + page.Page.SetTitle("Not Connected") + page.Page.SetIconName("network-offline-symbolic") + page.Page.SetDescription("Tailscale is not connected") + + return &page +} + +func (page *OfflinePage) Widget() gtk.Widgetter { + return page.Page +} + +func (page *OfflinePage) Actions() gio.ActionGrouper { + return nil +} + +func (page *OfflinePage) Init(row *PageRow) { + row.SetTitle(page.Page.Title()) + row.SetIconName(page.Page.IconName()) +} + +func (page *OfflinePage) Update(status *tsutil.Status) bool { + return !status.Online() +} From d719af49e38476efa88d6ddb5d8796dc8dd75c17 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 14:29:37 -0400 Subject: [PATCH 05/38] internal/ui: make pages responsible for removing themselves based on online status --- internal/ui/mainwindow.go | 36 ++++++++++++------------------------ internal/ui/mullvadpage.go | 4 ++++ internal/ui/peerpage.go | 4 ++++ internal/ui/selfpage.go | 4 ++++ 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/internal/ui/mainwindow.go b/internal/ui/mainwindow.go index 8900f24..efb2251 100644 --- a/internal/ui/mainwindow.go +++ b/internal/ui/mainwindow.go @@ -194,31 +194,15 @@ func (win *MainWindow) Update(status *tsutil.Status) { win.updatePeers(status) } -func (win *MainWindow) updatePeersOffline() { - var found bool - for name, page := range win.pages { - if name == "offline" { - found = true - continue - } - - win.removePage(name, page) - } - if !found { - win.addPage("offline", NewOfflinePage(win.app)) - } -} - func (win *MainWindow) updatePeers(status *tsutil.Status) { if !status.Online() { - win.updatePeersOffline() + if _, ok := win.pages["offline"]; !ok { + win.addPage("offline", NewOfflinePage(win.app)) + } + win.updatePages(status) return } - if page := win.pages["offline"]; page != nil { - win.removePage("offline", page) - } - if _, ok := win.pages["self"]; !ok { win.addPage("self", NewSelfPage(win.app, status)) } @@ -239,6 +223,10 @@ func (win *MainWindow) updatePeers(status *tsutil.Status) { win.addPage(name, NewPeerPage(win.app, status, peer)) } + win.updatePages(status) +} + +func (win *MainWindow) updatePages(status *tsutil.Status) { var remove []string for name, page := range win.pages { ok := page.Update(status) @@ -253,10 +241,10 @@ func (win *MainWindow) updatePeers(status *tsutil.Status) { win.PeersList.InvalidateSort() } -func (win *MainWindow) updateProfiles(s *tsutil.Status) { - win.profiles = s.Profiles +func (win *MainWindow) updateProfiles(status *tsutil.Status) { + win.profiles = status.Profiles listmodels.UpdateStrings(win.profileModel, func(yield func(string) bool) { - for _, profile := range s.Profiles { + for _, profile := range status.Profiles { name := profile.Name if metadata.Private { name = "profile@example.com" @@ -268,7 +256,7 @@ func (win *MainWindow) updateProfiles(s *tsutil.Status) { }) profileIndex, ok := listmodels.Index(win.profileSortModel, func(obj *gtk.StringObject) bool { - return obj.String() == s.Profile.Name + return obj.String() == status.Profile.Name }) if ok { win.ProfileDropDown.SetSelected(uint(profileIndex)) diff --git a/internal/ui/mullvadpage.go b/internal/ui/mullvadpage.go index 87a6bc9..19cb5fd 100644 --- a/internal/ui/mullvadpage.go +++ b/internal/ui/mullvadpage.go @@ -65,6 +65,10 @@ func (page *MullvadPage) Init(row *PageRow) { } func (page *MullvadPage) Update(status *tsutil.Status) bool { + if !status.Online() { + return false + } + if !tsutil.CanMullvad(status.Status.Self) { return false } diff --git a/internal/ui/peerpage.go b/internal/ui/peerpage.go index 20eedaa..8ace6ec 100644 --- a/internal/ui/peerpage.go +++ b/internal/ui/peerpage.go @@ -245,6 +245,10 @@ func (page *PeerPage) Init(row *PageRow) { } func (page *PeerPage) Update(status *tsutil.Status) bool { + if !status.Online() { + return false + } + page.peer = status.Status.Peer[page.peer.PublicKey] if page.peer == nil { return false diff --git a/internal/ui/selfpage.go b/internal/ui/selfpage.go index 2b41ff2..5d3503e 100644 --- a/internal/ui/selfpage.go +++ b/internal/ui/selfpage.go @@ -391,6 +391,10 @@ func (page *SelfPage) Init(row *PageRow) { } func (page *SelfPage) Update(status *tsutil.Status) bool { + if !status.Online() { + return false + } + page.peer = status.Status.Self page.row.SetTitle(peerName(status, page.peer)) From 5fb1185e32fb398f053fe9a6dc4da156bd54d403 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 14:57:37 -0400 Subject: [PATCH 06/38] meta: update some dependencies --- go.mod | 31 +++++++++++- go.sum | 103 +++++++++++++++++++++++++++++++++++--- internal/tsutil/client.go | 4 +- internal/tsutil/poller.go | 2 +- 4 files changed, 131 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 4bc600c..39cc518 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/klauspost/compress v1.18.0 github.com/stretchr/testify v1.10.0 golang.org/x/net v0.40.0 - tailscale.com v1.82.5 + tailscale.com v1.84.0 ) require ( @@ -21,35 +21,61 @@ require ( github.com/KarpelesLab/weak v0.1.1 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect + github.com/aws/aws-sdk-go-v2/config v1.29.14 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect + github.com/aws/aws-sdk-go-v2/service/ssm v1.59.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect + github.com/aws/smithy-go v1.22.3 // indirect github.com/coder/websocket v1.8.13 // indirect github.com/coreos/go-iptables v0.8.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dblohm7/wingoes v0.0.0-20240820181039-f2b84150679e // indirect + github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect github.com/fxamacker/cbor/v2 v2.8.0 // indirect + github.com/gaissmai/bart v0.20.4 // indirect github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/nftables v0.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/csrf v1.7.3 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/illarion/gonotify/v3 v3.0.2 // indirect github.com/jsimonetti/rtnetlink v1.4.2 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mdlayher/sdnotify v1.0.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/miekg/dns v1.1.66 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/peterbourgon/ff/v3 v3.4.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/safchain/ethtool v0.6.0 // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect + github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect github.com/tailscale/goupnp v1.0.1-0.20210804030727-66b27ba4e403 // indirect + github.com/tailscale/hujson v0.0.0-20250226034555-ec1d1c113d33 // indirect github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 // indirect github.com/toqueteos/webbrowser v1.2.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -63,12 +89,15 @@ require ( golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 // indirect golang.org/x/text v0.25.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.33.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect honnef.co/go/tools v0.6.1 // indirect k8s.io/client-go v0.33.1 // indirect sigs.k8s.io/yaml v1.4.0 // indirect diff --git a/go.sum b/go.sum index 182424d..890381d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= deedles.dev/mk v0.1.0 h1:xrvuJA3+R/j6/6AZPc+o31I1rotdKLrAYJxhZJwdOuc= deedles.dev/mk v0.1.0/go.mod h1:TSFsz0T+BvhNqJae0yrj+KadkN4elx248PCpq2Ol4ME= deedles.dev/tray v0.1.9 h1:xPYh0tOpYkSQN2Pjt9t72qtMEPgOEi9jmjUZ5ZR0yhw= @@ -16,12 +18,46 @@ github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= +github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= +github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= +github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= +github.com/aws/aws-sdk-go-v2/service/ssm v1.59.0 h1:KWArCwA/WkuHWKfygkNz0B6YS6OvdgoJUaJHX0Qby1s= +github.com/aws/aws-sdk-go-v2/service/ssm v1.59.0/go.mod h1:PUWUl5MDiYNQkUHN9Pyd9kgtA/YhbxnSnHP+yQqzrM8= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= +github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= +github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc= github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= +github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= +github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= +github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dblohm7/wingoes v0.0.0-20240820181039-f2b84150679e h1:L+XrFvD0vBIBm+Wf9sFN6aU395t7JROoai0qXZraA4U= @@ -30,16 +66,26 @@ github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250310094704-65bb91d1403f h1 github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250310094704-65bb91d1403f/go.mod h1:fkvdR7MYO1sI0ex07VYLTc+YK87v24aRFYyMJQ/xAeA= github.com/diamondburned/gotk4/pkg v0.3.1 h1:uhkXSUPUsCyz3yujdvl7DSN8jiLS2BgNTQE95hk6ygg= github.com/diamondburned/gotk4/pkg v0.3.1/go.mod h1:DqeOW+MxSZFg9OO+esk4JgQk0TiUJJUBfMltKhG+ub4= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI= +github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= -github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= +github.com/gaissmai/bart v0.20.4 h1:Ik47r1fy3jRVU+1eYzKSW3ho2UgBVTVnUS8O993584U= +github.com/gaissmai/bart v0.20.4/go.mod h1:cEed+ge8dalcbpi8wtS9x9m2hn/fNJH5suhdGQOHnYk= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8 h1:o8UqXPI6SVwQt04RGsqKp3qqmbOfTNMqDrWsc4O47kk= github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9 h1:Kzr9J0S0V2PRxiX6B6xw1kWjzsIyjLO2Ibi4fNTaYBM= github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -49,6 +95,8 @@ github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= +github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg= @@ -65,12 +113,20 @@ github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeV github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4= +github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= +github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= +github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= +github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90= github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -86,34 +142,64 @@ github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy5 github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= +github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= +github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= +github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/safchain/ethtool v0.6.0 h1:38VicU4p9ewEQFLemCFiGsknSMn7S3xXEzxaGYTjcn4= +github.com/safchain/ethtool v0.6.0/go.mod h1:JzoNbG8xeg/BeVeVoMCtCb3UPWoppZZbFpA+1WFh+M0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM= +github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= github.com/tailscale/goupnp v1.0.1-0.20210804030727-66b27ba4e403 h1:tB2UwtefWtWjIOp5UjU2eHPdP1EY3JZyAkes6WOsvIo= github.com/tailscale/goupnp v1.0.1-0.20210804030727-66b27ba4e403/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= +github.com/tailscale/hujson v0.0.0-20250226034555-ec1d1c113d33 h1:idh63uw+gsG05HwjZsAENCG4KZfyvjK03bpjxa5qRRk= +github.com/tailscale/hujson v0.0.0-20250226034555-ec1d1c113d33/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= -github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 h1:BcEJP2ewTIK2ZCsqgl6YGpuO6+oKqqag5HHb7ehljKw= -github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= +github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 h1:h/41LFTrwMxB9Xvvug0kRdQCU5TlV1+pAMQw0ZtDE3U= +github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= +github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= +github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ= github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM= +github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= +github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= @@ -131,6 +217,8 @@ golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/exp/typeparams v0.0.0-20250305212735-054e65f0b394 h1:VI4qDpTkfFaCXEPrbojidLgVQhj2x4nzTccG0hjaLlU= golang.org/x/exp/typeparams v0.0.0-20250305212735-054e65f0b394/go.mod h1:LKZHyeOpPuZcMgxeHjJp4p5yvxrCX1xDvH10zYHhjjQ= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= @@ -142,9 +230,12 @@ golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= @@ -172,5 +263,5 @@ sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.82.5 h1:p5owmyPoPM1tFVHR3LjquFuLfpZLzafvhe5kjVavHtE= -tailscale.com v1.82.5/go.mod h1:iU6kohVzG+bP0/5XjqBAnW8/6nSG/Du++bO+x7VJZD0= +tailscale.com v1.84.0 h1:WzelL3/TXAAN+Vv5UyK0n0JCOL9n0qpjRL4tjVEA1Ok= +tailscale.com v1.84.0/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= diff --git a/internal/tsutil/client.go b/internal/tsutil/client.go index bafb12d..75fd10c 100644 --- a/internal/tsutil/client.go +++ b/internal/tsutil/client.go @@ -17,10 +17,12 @@ import ( "tailscale.com/net/netmon" "tailscale.com/tailcfg" "tailscale.com/types/logger" + "tailscale.com/util/eventbus" ) var ( localClient local.Client + bus = eventbus.New() monitor = initMonitor() netcheckClient = netcheck.Client{ NetMon: monitor, @@ -29,7 +31,7 @@ var ( ) func initMonitor() *netmon.Monitor { - monitor, err := netmon.New(logger.Discard) + monitor, err := netmon.New(bus, logger.Discard) if err != nil { slog.Error("init netmon monitor", "err", err) } diff --git a/internal/tsutil/poller.go b/internal/tsutil/poller.go index 30e251d..401f2df 100644 --- a/internal/tsutil/poller.go +++ b/internal/tsutil/poller.go @@ -12,10 +12,10 @@ import ( "deedles.dev/mk" "tailscale.com/client/tailscale/apitype" + "tailscale.com/feature/taildrop" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" - "tailscale.com/taildrop" ) // A Poller gets the latest Tailscale status at regular intervals or From bf48c891e821f82ba3f8a715123540a48327900c Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 15:35:38 -0400 Subject: [PATCH 07/38] internal/tsutil: don't retry polling if getting profiles fails --- internal/tsutil/poller.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/internal/tsutil/poller.go b/internal/tsutil/poller.go index 401f2df..569da58 100644 --- a/internal/tsutil/poller.go +++ b/internal/tsutil/poller.go @@ -107,15 +107,6 @@ func (p *Poller) Run(ctx context.Context) { return } slog.Error("get profile status", "err", err) - select { - case <-ctx.Done(): - return - case <-time.After(retry): - if retry < 30*time.Second { - retry *= 2 - } - continue - } } retry = interval From a2d44539276ea292eec517c8cc5b0263c007fb1a Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 18:02:42 -0400 Subject: [PATCH 08/38] internal/tsutil: overhaul polling mechanism to decouple differnt types of status information --- internal/tray/tray.go | 20 ++-- internal/tsutil/client.go | 2 +- internal/tsutil/poller.go | 198 ++++++++++++++++++++++--------------- internal/ui/app.go | 85 +++++++++------- internal/ui/mainwindow.go | 28 ++++-- internal/ui/mullvadpage.go | 8 +- internal/ui/offlinepage.go | 7 +- internal/ui/peerpage.go | 12 ++- internal/ui/selfpage.go | 23 ++++- internal/ui/settings.go | 2 +- internal/ui/ui.go | 2 +- 11 files changed, 238 insertions(+), 149 deletions(-) diff --git a/internal/tray/tray.go b/internal/tray/tray.go index 4e3554e..8b6eae3 100644 --- a/internal/tray/tray.go +++ b/internal/tray/tray.go @@ -56,7 +56,7 @@ type Tray struct { quitItem *tray.MenuItem } -func (t *Tray) Start(s *tsutil.Status) error { +func (t *Tray) Start(s *tsutil.NetStatus) error { if t.item != nil { return nil } @@ -100,18 +100,18 @@ func (t *Tray) Close() error { return err } -func (t *Tray) Update(s *tsutil.Status) { +func (t *Tray) Update(status *tsutil.NetStatus) { if t == nil || t.item == nil { return } - selfTitle, connected := selfTitle(s) + selfTitle, connected := selfTitle(status) - t.updateStatusIcon(s) + t.updateStatusIcon(status) - t.connToggleItem.SetProps(tray.MenuItemLabel(connToggleText(s.Online()))) + t.connToggleItem.SetProps(tray.MenuItemLabel(connToggleText(status.Online()))) t.exitToggleItem.SetProps( - tray.MenuItemLabel(exitToggleText(s)), + tray.MenuItemLabel(exitToggleText(status)), tray.MenuItemEnabled(connected), ) t.selfNodeItem.SetProps( @@ -120,7 +120,7 @@ func (t *Tray) Update(s *tsutil.Status) { ) } -func (t *Tray) updateStatusIcon(s *tsutil.Status) { +func (t *Tray) updateStatusIcon(s *tsutil.NetStatus) { newIcon := statusIcon(s) if newIcon == t.icon { return @@ -130,7 +130,7 @@ func (t *Tray) updateStatusIcon(s *tsutil.Status) { t.item.SetProps(tray.ItemIconPixmap(newIcon)) } -func statusIcon(s *tsutil.Status) *tray.Pixmap { +func statusIcon(s *tsutil.NetStatus) *tray.Pixmap { if !s.Online() { return &statusIconInactive } @@ -140,7 +140,7 @@ func statusIcon(s *tsutil.Status) *tray.Pixmap { return &statusIconActive } -func selfTitle(s *tsutil.Status) (string, bool) { +func selfTitle(s *tsutil.NetStatus) (string, bool) { addr, ok := s.SelfAddr() if !ok { if len(s.Status.Self.TailscaleIPs) == 0 { @@ -160,7 +160,7 @@ func connToggleText(online bool) string { return "Connect" } -func exitToggleText(s *tsutil.Status) string { +func exitToggleText(s *tsutil.NetStatus) string { if s.Status != nil && s.Status.ExitNodeStatus != nil { // TODO: Show some actual information about the current exit node? return "Disable exit node" diff --git a/internal/tsutil/client.go b/internal/tsutil/client.go index 75fd10c..6288d0c 100644 --- a/internal/tsutil/client.go +++ b/internal/tsutil/client.go @@ -249,7 +249,7 @@ func WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) { return localClient.AwaitWaitingFiles(ctx, time.Second) } -func ProfileStatus(ctx context.Context) (ipn.LoginProfile, []ipn.LoginProfile, error) { +func GetProfileStatus(ctx context.Context) (ipn.LoginProfile, []ipn.LoginProfile, error) { return localClient.ProfileStatus(ctx) } diff --git a/internal/tsutil/poller.go b/internal/tsutil/poller.go index 569da58..4e1f2f5 100644 --- a/internal/tsutil/poller.go +++ b/internal/tsutil/poller.go @@ -15,7 +15,6 @@ import ( "tailscale.com/feature/taildrop" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" ) // A Poller gets the latest Tailscale status at regular intervals or @@ -33,11 +32,12 @@ type Poller struct { // If non-nil, New will be called when a new status is received from // Tailscale. - New func(*Status) + New func(Status) + + once sync.Once - once sync.Once poll chan struct{} - get chan *Status + get chan *NetStatus interval chan time.Duration } @@ -57,112 +57,132 @@ func (p *Poller) init() { func (p *Poller) Run(ctx context.Context) { p.init() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + n := newNotifier() + go p.watchStatus(ctx, n) + go p.watchFiles(ctx, n) + go p.watchProfiles(ctx, n) + interval := p.Interval if interval < 0 { interval = 5 * time.Second } - retry := interval check := time.NewTicker(interval) defer check.Stop() for { + select { + case <-ctx.Done(): + return + case p.poll <- struct{}{}: + n = n.Notify() + check.Reset(interval) + case interval = <-p.interval: + check.Reset(interval) + } + } +} + +func (p *Poller) watchStatus(ctx context.Context, n *notifier) { + s := new(NetStatus) + for { + var status *ipnstate.Status + var prefs *ipn.Prefs + status, err := GetStatus(ctx) if err != nil { if ctx.Err() != nil { return } slog.Error("get Tailscale status", "err", err) - select { - case <-ctx.Done(): - return - case <-time.After(retry): - if retry < 30*time.Second { - retry *= 2 - } - continue - } + goto wait } - prefs, err := Prefs(ctx) + prefs, err = Prefs(ctx) if err != nil { if ctx.Err() != nil { return } slog.Error("get Tailscale prefs", "err", err) - select { - case <-ctx.Done(): - return - case <-time.After(retry): - if retry < 30*time.Second { - retry *= 2 - } - continue - } + goto wait } - profile, profiles, err := ProfileStatus(ctx) - if err != nil { + s = &NetStatus{Status: status, Prefs: prefs} + p.New(s) + + wait: + select { + case <-ctx.Done(): + return + case p.get <- s: + goto wait + case <-n.notify: + n = n.next + } + } +} + +func (p *Poller) watchFiles(ctx context.Context, n *notifier) { + for { + files, err := WaitingFiles(ctx) + if err != nil && !errors.Is(err, taildrop.ErrNoTaildrop) { if ctx.Err() != nil { return } - slog.Error("get profile status", "err", err) + slog.Error("get waiting files", "err", err) + goto wait } - retry = interval + p.New(&FileStatus{Files: files}) - var files []apitype.WaitingFile - if status.Self.HasCap(tailcfg.CapabilityFileSharing) { - files, err = WaitingFiles(ctx) - if err != nil && !errors.Is(err, taildrop.ErrNoTaildrop) { - if ctx.Err() != nil { - return - } - slog.Error("get waiting files", "err", err) - } + wait: + select { + case <-ctx.Done(): + return + case <-n.notify: + n = n.next } + } +} - s := &Status{Status: status, Prefs: prefs, Files: files, Profile: profile, Profiles: profiles} - if p.New != nil { - // TODO: Only call this if the status changed from the previous - // poll? Is that remotely feasible? - p.New(s) +func (p *Poller) watchProfiles(ctx context.Context, n *notifier) { + for { + profile, profiles, err := GetProfileStatus(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + slog.Error("get profile status", "err", err) + goto wait } - send: + p.New(&ProfileStatus{Profile: profile, Profiles: profiles}) + + wait: select { case <-ctx.Done(): return - case <-check.C: - case p.poll <- struct{}{}: - check.Reset(interval) - case interval = <-p.interval: - check.Reset(interval) - goto send - case p.get <- s: - goto send // I've never used a goto before. + case <-n.notify: + n = n.next } } } // Poll returns a channel that, when received from, causes a new -// status to be fetched from Tailscale. A receive from the channel -// does not resolve until the poller begins to fetch the status, -// meaning that a receive from Poll followed immediately by a receive -// from Get will always result in the new Status. +// status to be fetched from Tailscale. func (p *Poller) Poll() <-chan struct{} { p.init() return p.poll } -// Get returns a channel that will yield the latest Status fetched. If -// a new Status is in the process of being fetched, it will wait for -// that to finish and then yield that. -// -// Note that receiving from this channel does not trigger a poll. To -// do that, receive from [Poll] first. -func (p *Poller) Get() <-chan *Status { +// GetNet returns a channel that yields the most recently fetched +// network status. It will block until the network status has been +// fetched successfully once. +func (p *Poller) GetNet() <-chan *NetStatus { p.init() return p.get @@ -177,27 +197,24 @@ func (p *Poller) SetInterval() chan<- time.Duration { return p.interval } -// Status is a type that wraps various status-related types that -// Tailscale provides. -type Status struct { - Status *ipnstate.Status - Prefs *ipn.Prefs - Files []apitype.WaitingFile - Profile ipn.LoginProfile - Profiles []ipn.LoginProfile +type Status any + +type NetStatus struct { + Status *ipnstate.Status + Prefs *ipn.Prefs } // Online returns true if s indicates that the local node is online // and connected to the tailnet. -func (s *Status) Online() bool { - return (s.Status != nil) && (s.Status.BackendState == ipn.Running.String()) +func (s *NetStatus) Online() bool { + return s.Status.BackendState == ipn.Running.String() } -func (s *Status) NeedsAuth() bool { - return (s.Status != nil) && (s.Status.BackendState == ipn.NeedsLogin.String()) +func (s *NetStatus) NeedsAuth() bool { + return s.Status.BackendState == ipn.NeedsLogin.String() } -func (s *Status) OperatorIsCurrent() bool { +func (s *NetStatus) OperatorIsCurrent() bool { current, err := user.Current() if err != nil { slog.Error("get current user", "err", err) @@ -207,10 +224,7 @@ func (s *Status) OperatorIsCurrent() bool { return s.Prefs.OperatorUser == current.Username } -func (s *Status) SelfAddr() (netip.Addr, bool) { - if s.Status == nil { - return netip.Addr{}, false - } +func (s *NetStatus) SelfAddr() (netip.Addr, bool) { if s.Status.Self == nil { return netip.Addr{}, false } @@ -220,3 +234,29 @@ func (s *Status) SelfAddr() (netip.Addr, bool) { return slices.MinFunc(s.Status.Self.TailscaleIPs, netip.Addr.Compare), true } + +type FileStatus struct { + Files []apitype.WaitingFile +} + +type ProfileStatus struct { + Profile ipn.LoginProfile + Profiles []ipn.LoginProfile +} + +type notifier struct { + notify chan struct{} + next *notifier +} + +func newNotifier() *notifier { + return ¬ifier{ + notify: make(chan struct{}), + } +} + +func (n *notifier) Notify() *notifier { + n.next = newNotifier() + close(n.notify) + return n.next +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 387c2ec..2801b7c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -76,42 +76,55 @@ func (a *App) stopSpin() { }) } -func (a *App) update(s *tsutil.Status) { - online := s.Online() - a.tray.Update(s) - if a.online != online { - a.online = online - - body := "Tailscale is not connected." - if online { - body = "Tailscale is connected." +func (a *App) update(status tsutil.Status) { + switch status := status.(type) { + case *tsutil.NetStatus: + online := status.Online() + a.tray.Update(status) + if a.online != online { + a.online = online + + body := "Tailscale is not connected." + if online { + body = "Tailscale is connected." + } + a.notify("Tailscale Status", body) // TODO: Notify on startup if not connected? } - a.notify("Tailscale Status", body) // TODO: Notify on startup if not connected? - } - if a.files != nil { - for _, file := range s.Files { - if !slices.Contains(*a.files, file) { - body := fmt.Sprintf("%v (%v)", file.Name, bytesize.ByteSize(file.Size)) - a.notify("New Incoming File", body) + if a.win == nil { + return + } + + a.win.Update(status) + + if a.online && !a.operatorCheck { + a.operatorCheck = true + if !status.OperatorIsCurrent() { + Info{ + Heading: "User is not Tailscale Operator", + Body: "Some functionality may not work as expected. To resolve, run\nsudo tailscale set --operator=$USER\nin the command-line.", + }.Show(a, nil) } } - } - a.files = &s.Files - if a.win == nil { - return - } + case *tsutil.FileStatus: + if a.files != nil { + for _, file := range status.Files { + if !slices.Contains(*a.files, file) { + body := fmt.Sprintf("%v (%v)", file.Name, bytesize.ByteSize(file.Size)) + a.notify("New Incoming File", body) + } + } + } + a.files = &status.Files - a.win.Update(s) + if a.win != nil { + a.win.Update(status) + } - if a.online && !a.operatorCheck { - a.operatorCheck = true - if !s.OperatorIsCurrent() { - Info{ - Heading: "User is not Tailscale Operator", - Body: "Some functionality may not work as expected. To resolve, run\nsudo tailscale set --operator=$USER\nin the command-line.", - }.Show(a, nil) + case *tsutil.ProfileStatus: + if a.win != nil { + a.win.Update(status) } } } @@ -155,7 +168,7 @@ func (a *App) init(ctx context.Context) { } func (a *App) startTS(ctx context.Context) error { - status := <-a.poller.Get() + status := <-a.poller.GetNet() if status.NeedsAuth() { Confirmation{ Heading: "Login Required", @@ -190,7 +203,7 @@ func (a *App) stopTS(ctx context.Context) error { func (a *App) onAppOpen(ctx context.Context, files []gio.Filer) { type selectOption = SelectOption[*ipnstate.PeerStatus] - s := <-a.poller.Get() + s := <-a.poller.GetNet() if !s.Online() { return } @@ -260,7 +273,7 @@ func (a *App) onAppActivate(ctx context.Context) { func (a *App) initTray(ctx context.Context) { if a.tray != nil { - err := a.tray.Start(<-a.poller.Get()) + err := a.tray.Start(<-a.poller.GetNet()) if err != nil { slog.Error("failed to start tray icon", "err", err) } @@ -299,7 +312,7 @@ func (a *App) initTray(ctx context.Context) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - s := <-a.poller.Get() + s := <-a.poller.GetNet() if s.Status == nil { return } @@ -322,7 +335,7 @@ func (a *App) initTray(ctx context.Context) { OnSelfNode: func() { glib.IdleAdd(func() { - s := <-a.poller.Get() + s := <-a.poller.GetNet() addr, ok := s.SelfAddr() if !ok { return @@ -339,7 +352,7 @@ func (a *App) initTray(ctx context.Context) { }, } - err := a.tray.Start(<-a.poller.Get()) + err := a.tray.Start(<-a.poller.GetNet()) if err != nil { slog.Error("failed to start tray icon", "err", err) } @@ -368,7 +381,7 @@ func (a *App) Run(ctx context.Context) { a.poller = &tsutil.Poller{ Interval: a.getInterval(), - New: func(s *tsutil.Status) { glib.IdleAdd(func() { a.update(s) }) }, + New: func(s tsutil.Status) { glib.IdleAdd(func() { a.update(s) }) }, } go a.poller.Run(ctx) diff --git a/internal/ui/mainwindow.go b/internal/ui/mainwindow.go index efb2251..ac6bd76 100644 --- a/internal/ui/mainwindow.go +++ b/internal/ui/mainwindow.go @@ -185,16 +185,26 @@ func (win *MainWindow) removePage(name string, page Page) { win.PeersStack.Remove(page.Widget()) } -func (win *MainWindow) Update(status *tsutil.Status) { - online := status.Online() - win.StatusSwitch.SetState(online) - win.StatusSwitch.SetActive(online) +func (win *MainWindow) Update(status tsutil.Status) { + switch status := status.(type) { + case *tsutil.NetStatus: + online := status.Online() + win.StatusSwitch.SetState(online) + win.StatusSwitch.SetActive(online) + + win.updatePeers(status) + + case *tsutil.FileStatus: + if self, ok := win.pages["self"].(*SelfPage); ok { + self.UpdateFiles(status) + } - win.updateProfiles(status) - win.updatePeers(status) + case *tsutil.ProfileStatus: + win.updateProfiles(status) + } } -func (win *MainWindow) updatePeers(status *tsutil.Status) { +func (win *MainWindow) updatePeers(status *tsutil.NetStatus) { if !status.Online() { if _, ok := win.pages["offline"]; !ok { win.addPage("offline", NewOfflinePage(win.app)) @@ -226,7 +236,7 @@ func (win *MainWindow) updatePeers(status *tsutil.Status) { win.updatePages(status) } -func (win *MainWindow) updatePages(status *tsutil.Status) { +func (win *MainWindow) updatePages(status *tsutil.NetStatus) { var remove []string for name, page := range win.pages { ok := page.Update(status) @@ -241,7 +251,7 @@ func (win *MainWindow) updatePages(status *tsutil.Status) { win.PeersList.InvalidateSort() } -func (win *MainWindow) updateProfiles(status *tsutil.Status) { +func (win *MainWindow) updateProfiles(status *tsutil.ProfileStatus) { win.profiles = status.Profiles listmodels.UpdateStrings(win.profileModel, func(yield func(string) bool) { for _, profile := range status.Profiles { diff --git a/internal/ui/mullvadpage.go b/internal/ui/mullvadpage.go index 19cb5fd..815558d 100644 --- a/internal/ui/mullvadpage.go +++ b/internal/ui/mullvadpage.go @@ -34,7 +34,7 @@ type MullvadPage struct { exitNodes map[tailcfg.StableNodeID]*mullvadExitNodeRow } -func NewMullvadPage(a *App, status *tsutil.Status) *MullvadPage { +func NewMullvadPage(a *App, status *tsutil.NetStatus) *MullvadPage { page := MullvadPage{ app: a, locations: make(map[string]*adw.ExpanderRow), @@ -64,7 +64,11 @@ func (page *MullvadPage) Init(row *PageRow) { row.SetTitle(mullvadPageBaseName) } -func (page *MullvadPage) Update(status *tsutil.Status) bool { +func (page *MullvadPage) Update(s tsutil.Status) bool { + status, ok := s.(*tsutil.NetStatus) + if !ok { + return true + } if !status.Online() { return false } diff --git a/internal/ui/offlinepage.go b/internal/ui/offlinepage.go index e1dcb41..c0d100d 100644 --- a/internal/ui/offlinepage.go +++ b/internal/ui/offlinepage.go @@ -37,6 +37,9 @@ func (page *OfflinePage) Init(row *PageRow) { row.SetIconName(page.Page.IconName()) } -func (page *OfflinePage) Update(status *tsutil.Status) bool { - return !status.Online() +func (page *OfflinePage) Update(status tsutil.Status) bool { + if status, ok := status.(*tsutil.NetStatus); ok { + return !status.Online() + } + return true } diff --git a/internal/ui/peerpage.go b/internal/ui/peerpage.go index 8ace6ec..a1a1fb0 100644 --- a/internal/ui/peerpage.go +++ b/internal/ui/peerpage.go @@ -78,14 +78,14 @@ type PeerPage struct { routeModel *gioutil.ListModel[netip.Prefix] } -func NewPeerPage(a *App, status *tsutil.Status, peer *ipnstate.PeerStatus) *PeerPage { +func NewPeerPage(a *App, status *tsutil.NetStatus, peer *ipnstate.PeerStatus) *PeerPage { var page PeerPage fillFromBuilder(&page, peerPageXML) page.init(a, status, peer) return &page } -func (page *PeerPage) init(a *App, status *tsutil.Status, peer *ipnstate.PeerStatus) { +func (page *PeerPage) init(a *App, status *tsutil.NetStatus, peer *ipnstate.PeerStatus) { page.app = a page.peer = peer @@ -244,7 +244,11 @@ func (page *PeerPage) Init(row *PageRow) { page.row = row } -func (page *PeerPage) Update(status *tsutil.Status) bool { +func (page *PeerPage) Update(s tsutil.Status) bool { + status, ok := s.(*tsutil.NetStatus) + if !ok { + return true + } if !status.Online() { return false } @@ -293,7 +297,7 @@ func (page *PeerPage) Update(status *tsutil.Status) bool { return true } -func peerName(status *tsutil.Status, peer *ipnstate.PeerStatus) string { +func peerName(status *tsutil.NetStatus, peer *ipnstate.PeerStatus) string { return tsutil.DNSOrQuoteHostname(status.Status, peer) } diff --git a/internal/ui/selfpage.go b/internal/ui/selfpage.go index 5d3503e..90f82ae 100644 --- a/internal/ui/selfpage.go +++ b/internal/ui/selfpage.go @@ -71,14 +71,14 @@ type SelfPage struct { fileModel *gioutil.ListModel[apitype.WaitingFile] } -func NewSelfPage(a *App, status *tsutil.Status) *SelfPage { +func NewSelfPage(a *App, status *tsutil.NetStatus) *SelfPage { var page SelfPage fillFromBuilder(&page, selfPageXML) page.init(a, status) return &page } -func (page *SelfPage) init(a *App, status *tsutil.Status) { +func (page *SelfPage) init(a *App, status *tsutil.NetStatus) { page.app = a page.peer = status.Status.Self @@ -390,7 +390,18 @@ func (page *SelfPage) Init(row *PageRow) { row.SetSubtitle("This machine") } -func (page *SelfPage) Update(status *tsutil.Status) bool { +func (page *SelfPage) Update(status tsutil.Status) bool { + switch status := status.(type) { + case *tsutil.NetStatus: + return page.UpdateNet(status) + case *tsutil.FileStatus: + return page.UpdateFiles(status) + default: + return true + } +} + +func (page *SelfPage) UpdateNet(status *tsutil.NetStatus) bool { if !status.Online() { return false } @@ -421,8 +432,12 @@ func (page *SelfPage) Update(status *tsutil.Status) bool { } listmodels.Update(page.addrModel, slices.Values(page.peer.TailscaleIPs)) - listmodels.Update(page.fileModel, slices.Values(status.Files)) listmodels.Update(page.routeModel, routes) return true } + +func (page *SelfPage) UpdateFiles(status *tsutil.FileStatus) bool { + listmodels.Update(page.fileModel, slices.Values(status.Files)) + return true +} diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 241df00..2412401 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -57,7 +57,7 @@ func (a *App) runSettings(ctx context.Context) { } func (a *App) showChangeControlServer() { - status := <-a.poller.Get() + status := <-a.poller.GetNet() Prompt{ Heading: "Control Server URL", diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 268c967..9b47654 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -168,7 +168,7 @@ type Page interface { Actions() gio.ActionGrouper Init(*PageRow) - Update(*tsutil.Status) bool + Update(tsutil.Status) bool } type PageRow struct { From 0e246af95dfe00fd1c421956af850b809da4d74b Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 21:16:19 -0400 Subject: [PATCH 09/38] internal/tsutil: use `local.IPNBusWatcher` --- internal/tray/tray.go | 20 ++--- internal/tsutil/client.go | 17 ++-- internal/tsutil/poller.go | 154 +++++++++++++++++++++++++++---------- internal/tsutil/tsutil.go | 66 ++++++++-------- internal/ui/app.go | 36 ++++----- internal/ui/mainwindow.go | 12 +-- internal/ui/mullvadpage.go | 66 ++++++++-------- internal/ui/offlinepage.go | 2 +- internal/ui/peerpage.go | 106 +++++++++++++------------ internal/ui/selfpage.go | 46 +++++------ internal/ui/settings.go | 4 +- 11 files changed, 301 insertions(+), 228 deletions(-) diff --git a/internal/tray/tray.go b/internal/tray/tray.go index 8b6eae3..20c37ff 100644 --- a/internal/tray/tray.go +++ b/internal/tray/tray.go @@ -56,7 +56,7 @@ type Tray struct { quitItem *tray.MenuItem } -func (t *Tray) Start(s *tsutil.NetStatus) error { +func (t *Tray) Start(s *tsutil.IPNStatus) error { if t.item != nil { return nil } @@ -100,7 +100,7 @@ func (t *Tray) Close() error { return err } -func (t *Tray) Update(status *tsutil.NetStatus) { +func (t *Tray) Update(status *tsutil.IPNStatus) { if t == nil || t.item == nil { return } @@ -120,7 +120,7 @@ func (t *Tray) Update(status *tsutil.NetStatus) { ) } -func (t *Tray) updateStatusIcon(s *tsutil.NetStatus) { +func (t *Tray) updateStatusIcon(s *tsutil.IPNStatus) { newIcon := statusIcon(s) if newIcon == t.icon { return @@ -130,26 +130,26 @@ func (t *Tray) updateStatusIcon(s *tsutil.NetStatus) { t.item.SetProps(tray.ItemIconPixmap(newIcon)) } -func statusIcon(s *tsutil.NetStatus) *tray.Pixmap { +func statusIcon(s *tsutil.IPNStatus) *tray.Pixmap { if !s.Online() { return &statusIconInactive } - if s.Status.ExitNodeStatus != nil { + if s.ExitNodeActive() { return &statusIconExitNode } return &statusIconActive } -func selfTitle(s *tsutil.NetStatus) (string, bool) { +func selfTitle(s *tsutil.IPNStatus) (string, bool) { addr, ok := s.SelfAddr() if !ok { - if len(s.Status.Self.TailscaleIPs) == 0 { + if s.NetMap.SelfNode.Addresses().Len() == 0 { return "Address unknown", false } return "Not connected", false } - return fmt.Sprintf("%v (%v)", tsutil.DNSOrQuoteHostname(s.Status, s.Status.Self), addr), true + return fmt.Sprintf("%v (%v)", s.NetMap.SelfNode.DisplayName(true), addr), true } func connToggleText(online bool) string { @@ -160,8 +160,8 @@ func connToggleText(online bool) string { return "Connect" } -func exitToggleText(s *tsutil.NetStatus) string { - if s.Status != nil && s.Status.ExitNodeStatus != nil { +func exitToggleText(s *tsutil.IPNStatus) string { + if s.ExitNodeActive() { // TODO: Show some actual information about the current exit node? return "Disable exit node" } diff --git a/internal/tsutil/client.go b/internal/tsutil/client.go index 6288d0c..708d7be 100644 --- a/internal/tsutil/client.go +++ b/internal/tsutil/client.go @@ -65,9 +65,9 @@ func Stop(ctx context.Context) error { } // ExitNode uses the specified peer as an exit node, or unsets -// an existing exit node if peer is nil. -func ExitNode(ctx context.Context, peer *ipnstate.PeerStatus) error { - if peer == nil { +// an existing exit node if peer is an empty string. +func ExitNode(ctx context.Context, peer tailcfg.StableNodeID) error { + if peer == "" { var prefs ipn.Prefs prefs.ClearExitNode() _, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ @@ -81,17 +81,12 @@ func ExitNode(ctx context.Context, peer *ipnstate.PeerStatus) error { return nil } - status, err := localClient.Status(ctx) - if err != nil { - return fmt.Errorf("get status: %w", err) + prefs := ipn.Prefs{ + ExitNodeID: peer, } - - var prefs ipn.Prefs - prefs.SetExitNodeIP(peer.TailscaleIPs[0].String(), status) - _, err = localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ + _, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ Prefs: prefs, ExitNodeIDSet: true, - ExitNodeIPSet: true, }) if err != nil { return fmt.Errorf("edit prefs: %w", err) diff --git a/internal/tsutil/poller.go b/internal/tsutil/poller.go index 4e1f2f5..c7cf74c 100644 --- a/internal/tsutil/poller.go +++ b/internal/tsutil/poller.go @@ -11,10 +11,12 @@ import ( "time" "deedles.dev/mk" + "deedles.dev/trayscale/internal/xnetip" "tailscale.com/client/tailscale/apitype" "tailscale.com/feature/taildrop" "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" + "tailscale.com/types/netmap" ) // A Poller gets the latest Tailscale status at regular intervals or @@ -37,14 +39,14 @@ type Poller struct { once sync.Once poll chan struct{} - get chan *NetStatus + getIPN chan *IPNStatus interval chan time.Duration } func (p *Poller) init() { p.once.Do(func() { mk.Chan(&p.poll, 0) - mk.Chan(&p.get, 0) + mk.Chan(&p.getIPN, 0) mk.Chan(&p.interval, 0) }) } @@ -61,7 +63,7 @@ func (p *Poller) Run(ctx context.Context) { defer cancel() n := newNotifier() - go p.watchStatus(ctx, n) + go p.watchIPN(ctx) go p.watchFiles(ctx, n) go p.watchProfiles(ctx, n) @@ -81,46 +83,86 @@ func (p *Poller) Run(ctx context.Context) { n = n.Notify() check.Reset(interval) case interval = <-p.interval: + n = n.Notify() check.Reset(interval) } } } -func (p *Poller) watchStatus(ctx context.Context, n *notifier) { - s := new(NetStatus) - for { - var status *ipnstate.Status - var prefs *ipn.Prefs +func (p *Poller) watchIPN(ctx context.Context) { + const watcherOpts = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyNoPrivateKeys | ipn.NotifyWatchEngineUpdates - status, err := GetStatus(ctx) - if err != nil { - if ctx.Err() != nil { +watch: + watcher, err := localClient.WatchIPNBus(ctx, watcherOpts) + if err != nil { + slog.Error("start IPN bus watcher", "err", err) + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + goto watch + } + } + defer watcher.Close() + + set := make(chan IPNStatus) + go func() { + var get chan *IPNStatus + var s *IPNStatus + for { + select { + case <-ctx.Done(): return + case v := <-set: + s = &v + get = p.getIPN + p.New(s) + case get <- s: } - slog.Error("get Tailscale status", "err", err) - goto wait } + }() - prefs, err = Prefs(ctx) + var s IPNStatus + for { + notify, err := watcher.Next() if err != nil { if ctx.Err() != nil { return } - slog.Error("get Tailscale prefs", "err", err) - goto wait + slog.Error("get next IPN bus notification", "err", err) + continue } - s = &NetStatus{Status: status, Prefs: prefs} - p.New(s) + var dirty bool + if notify.State != nil { + s.State = *notify.State + dirty = true + } + if notify.Prefs != nil && notify.Prefs.Valid() { + s.Prefs = *notify.Prefs + dirty = true + } + if notify.NetMap != nil { + s.NetMap = notify.NetMap + s.rebuildPeers() + dirty = true + } + if notify.Engine != nil { + s.Engine = notify.Engine + dirty = true + } + if notify.BrowseToURL != nil { + s.BrowseToURL = *notify.BrowseToURL + dirty = true + } + if !dirty { + continue + } - wait: select { case <-ctx.Done(): return - case p.get <- s: - goto wait - case <-n.notify: - n = n.next + case set <- s: } } } @@ -179,13 +221,13 @@ func (p *Poller) Poll() <-chan struct{} { return p.poll } -// GetNet returns a channel that yields the most recently fetched +// GetIPN returns a channel that yields the most recently fetched // network status. It will block until the network status has been // fetched successfully once. -func (p *Poller) GetNet() <-chan *NetStatus { +func (p *Poller) GetIPN() <-chan *IPNStatus { p.init() - return p.get + return p.getIPN } // SetInterval returns a channel that modifies the polling interval of @@ -199,40 +241,68 @@ func (p *Poller) SetInterval() chan<- time.Duration { type Status any -type NetStatus struct { - Status *ipnstate.Status - Prefs *ipn.Prefs +type IPNStatus struct { + State ipn.State + Prefs ipn.PrefsView + NetMap *netmap.NetworkMap + Peers map[tailcfg.StableNodeID]tailcfg.NodeView + Engine *ipn.EngineStatus + BrowseToURL string +} + +func (s *IPNStatus) rebuildPeers() { + if s.Peers == nil { + mk.Map(&s.Peers, 0) + } + clear(s.Peers) + + for _, peer := range s.NetMap.Peers { + s.Peers[peer.StableID()] = peer + } } // Online returns true if s indicates that the local node is online // and connected to the tailnet. -func (s *NetStatus) Online() bool { - return s.Status.BackendState == ipn.Running.String() +func (s *IPNStatus) Online() bool { + return s.State == ipn.Running +} + +func (s *IPNStatus) NeedsAuth() bool { + return s.State == ipn.NeedsLogin } -func (s *NetStatus) NeedsAuth() bool { - return s.Status.BackendState == ipn.NeedsLogin.String() +func (s *IPNStatus) ExitNodeActive() bool { + return s.Prefs.ExitNodeID() != "" || s.Prefs.ExitNodeIP().IsValid() } -func (s *NetStatus) OperatorIsCurrent() bool { +func (s *IPNStatus) ExitNode() tailcfg.NodeView { + if node, ok := s.Peers[s.Prefs.ExitNodeID()]; ok { + return node + } + if addr := s.Prefs.ExitNodeIP(); addr.IsValid() { + peer, _ := s.NetMap.PeerByTailscaleIP(addr) + return peer + } + return tailcfg.NodeView{} +} + +func (s *IPNStatus) OperatorIsCurrent() bool { current, err := user.Current() if err != nil { slog.Error("get current user", "err", err) return false } - return s.Prefs.OperatorUser == current.Username + return s.Prefs.OperatorUser() == current.Username } -func (s *NetStatus) SelfAddr() (netip.Addr, bool) { - if s.Status.Self == nil { - return netip.Addr{}, false - } - if len(s.Status.Self.TailscaleIPs) == 0 { +func (s *IPNStatus) SelfAddr() (netip.Addr, bool) { + if s.NetMap.SelfNode.Addresses().Len() == 0 { return netip.Addr{}, false } - return slices.MinFunc(s.Status.Self.TailscaleIPs, netip.Addr.Compare), true + // TODO: Don't copy the slice. + return slices.MinFunc(s.NetMap.SelfNode.Addresses().AsSlice(), xnetip.ComparePrefixes).Addr(), true } type FileStatus struct { diff --git a/internal/tsutil/tsutil.go b/internal/tsutil/tsutil.go index 3a268d2..68a17ae 100644 --- a/internal/tsutil/tsutil.go +++ b/internal/tsutil/tsutil.go @@ -2,53 +2,48 @@ package tsutil import ( "cmp" - "fmt" - "strings" - "golang.org/x/net/idna" "tailscale.com/client/tailscale/apitype" - "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" - "tailscale.com/util/dnsname" ) // DNSOrQuoteHostname returns a nicely printable version of a peer's name. The function is copied from // https://github.com/tailscale/tailscale/blob/b0ed863d55d6b51569ce5c6bd0b7021338ce6a82/cmd/tailscale/cli/status.go#L285 -func DNSOrQuoteHostname(st *ipnstate.Status, ps *ipnstate.PeerStatus) string { - baseName := ps.DNSName - if st.CurrentTailnet != nil { - baseName = dnsname.TrimSuffix(baseName, st.CurrentTailnet.MagicDNSSuffix) - } - if baseName != "" { - if strings.HasPrefix(baseName, "xn-") { - if u, err := idna.ToUnicode(baseName); err == nil { - return fmt.Sprintf("%s (%s)", baseName, u) - } - } - return baseName - } - return fmt.Sprintf("(%q)", dnsname.SanitizeHostname(ps.HostName)) -} +//func DNSOrQuoteHostname(st *ipnstate.Status, ps *ipnstate.PeerStatus) string { +// baseName := ps.DNSName +// if st.CurrentTailnet != nil { +// baseName = dnsname.TrimSuffix(baseName, st.CurrentTailnet.MagicDNSSuffix) +// } +// if baseName != "" { +// if strings.HasPrefix(baseName, "xn-") { +// if u, err := idna.ToUnicode(baseName); err == nil { +// return fmt.Sprintf("%s (%s)", baseName, u) +// } +// } +// return baseName +// } +// return fmt.Sprintf("(%q)", dnsname.SanitizeHostname(ps.HostName)) +//} // IsMullvad returns true if peer is a Mullvad exit node. -func IsMullvad(peer *ipnstate.PeerStatus) bool { - return (peer.Tags != nil) && peer.Tags.ContainsFunc(func(tag string) bool { +func IsMullvad(peer tailcfg.NodeView) bool { + return peer.Tags().ContainsFunc(func(tag string) bool { return tag == "tag:mullvad-exit-node" }) } // CanMullvad returns true if peer is allowed to access Mullvad exit // nodes. -func CanMullvad(peer *ipnstate.PeerStatus) bool { +func CanMullvad(peer tailcfg.NodeView) bool { return peer.HasCap("mullvad") } // CompareLocations alphabestically compares the countries and then, // if necessary, cities of two Locations. -func CompareLocations(loc1, loc2 *tailcfg.Location) int { +func CompareLocations(loc1, loc2 tailcfg.LocationView) int { return cmp.Or( - cmp.Compare(loc1.Country, loc2.Country), - cmp.Compare(loc1.City, loc2.City), + cmp.Compare(loc1.Country(), loc2.Country()), + cmp.Compare(loc1.City(), loc2.City()), ) } @@ -57,15 +52,18 @@ func CompareLocations(loc1, loc2 *tailcfg.Location) int { // deterministic order if their locations or hostnames are identical, // so the result of calling this is never 0. To determine if peers are // the same, compare their IDs manually. -func ComparePeers(p1, p2 *ipnstate.PeerStatus) int { +func ComparePeers(p1, p2 tailcfg.NodeView) int { + i1 := p1.Hostinfo() + i2 := p2.Hostinfo() + loc := 0 - if p1.Location != nil && p2.Location != nil { - loc = CompareLocations(p1.Location, p2.Location) + if i1.Location().Valid() && i2.Location().Valid() { + loc = CompareLocations(i1.Location(), i2.Location()) } return cmp.Or( loc, - cmp.Compare(p1.HostName, p2.HostName), - cmp.Compare(p1.ID, p2.ID), + cmp.Compare(i1.Hostname(), i2.Hostname()), + cmp.Compare(p1.ID(), p2.ID()), ) } @@ -80,6 +78,6 @@ func CompareWaitingFiles(f1, f2 apitype.WaitingFile) int { // CanReceiveFiles returns true if peer can be sent files via // Taildrop. -func CanReceiveFiles(peer *ipnstate.PeerStatus) bool { - return peer.NoFileSharingReason == "" -} +//func CanReceiveFiles(peer tailcfg.NodeView) bool { +// return peer.NoFileSharingReason == "" +//} diff --git a/internal/ui/app.go b/internal/ui/app.go index 2801b7c..30c8cee 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -20,7 +20,7 @@ import ( "github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/inhies/go-bytesize" "tailscale.com/client/tailscale/apitype" - "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" ) //go:embed app.css @@ -78,7 +78,7 @@ func (a *App) stopSpin() { func (a *App) update(status tsutil.Status) { switch status := status.(type) { - case *tsutil.NetStatus: + case *tsutil.IPNStatus: online := status.Online() a.tray.Update(status) if a.online != online { @@ -168,7 +168,7 @@ func (a *App) init(ctx context.Context) { } func (a *App) startTS(ctx context.Context) error { - status := <-a.poller.GetNet() + status := <-a.poller.GetIPN() if status.NeedsAuth() { Confirmation{ Heading: "Login Required", @@ -177,7 +177,7 @@ func (a *App) startTS(ctx context.Context) error { Reject: "_Cancel", }.Show(a, func(accept bool) { if accept { - gtk.NewURILauncher(status.Status.AuthURL).Launch(ctx, &a.win.MainWindow.Window, nil) + gtk.NewURILauncher(status.BrowseToURL).Launch(ctx, &a.win.MainWindow.Window, nil) } }) return nil @@ -201,20 +201,21 @@ func (a *App) stopTS(ctx context.Context) error { } func (a *App) onAppOpen(ctx context.Context, files []gio.Filer) { - type selectOption = SelectOption[*ipnstate.PeerStatus] + type selectOption = SelectOption[tailcfg.NodeView] - s := <-a.poller.GetNet() + s := <-a.poller.GetIPN() if !s.Online() { return } options := func(yield func(selectOption) bool) { - for _, peer := range s.Status.Peer { - if tsutil.IsMullvad(peer) || !tsutil.CanReceiveFiles(peer) { + // TODO: Only show nodes that can receive files. + for _, peer := range s.Peers { + if tsutil.IsMullvad(peer) { continue } option := selectOption{ - Title: tsutil.DNSOrQuoteHostname(s.Status, peer), + Title: peer.DisplayName(true), Value: peer, } if !yield(option) { @@ -223,7 +224,7 @@ func (a *App) onAppOpen(ctx context.Context, files []gio.Filer) { } } - Select[*ipnstate.PeerStatus]{ + Select[tailcfg.NodeView]{ Heading: "Send file(s) to...", Options: slices.SortedFunc(options, func(o1, o2 selectOption) int { return cmp.Compare(o1.Title, o2.Title) @@ -232,7 +233,7 @@ func (a *App) onAppOpen(ctx context.Context, files []gio.Filer) { for _, option := range options { a.notify("Taildrop", fmt.Sprintf("Sending %v file(s) to %v...", len(files), option.Title)) for _, file := range files { - go a.pushFile(ctx, option.Value.ID, file) + go a.pushFile(ctx, option.Value.StableID(), file) } } }) @@ -273,7 +274,7 @@ func (a *App) onAppActivate(ctx context.Context) { func (a *App) initTray(ctx context.Context) { if a.tray != nil { - err := a.tray.Start(<-a.poller.GetNet()) + err := a.tray.Start(<-a.poller.GetIPN()) if err != nil { slog.Error("failed to start tray icon", "err", err) } @@ -312,11 +313,8 @@ func (a *App) initTray(ctx context.Context) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - s := <-a.poller.GetNet() - if s.Status == nil { - return - } - toggle := s.Status.ExitNodeStatus == nil + s := <-a.poller.GetIPN() + toggle := s.ExitNodeActive() err := tsutil.SetUseExitNode(ctx, toggle) if err != nil { a.notify("Toggle exit node", err.Error()) @@ -335,7 +333,7 @@ func (a *App) initTray(ctx context.Context) { OnSelfNode: func() { glib.IdleAdd(func() { - s := <-a.poller.GetNet() + s := <-a.poller.GetIPN() addr, ok := s.SelfAddr() if !ok { return @@ -352,7 +350,7 @@ func (a *App) initTray(ctx context.Context) { }, } - err := a.tray.Start(<-a.poller.GetNet()) + err := a.tray.Start(<-a.poller.GetIPN()) if err != nil { slog.Error("failed to start tray icon", "err", err) } diff --git a/internal/ui/mainwindow.go b/internal/ui/mainwindow.go index ac6bd76..9c4bb6d 100644 --- a/internal/ui/mainwindow.go +++ b/internal/ui/mainwindow.go @@ -187,7 +187,7 @@ func (win *MainWindow) removePage(name string, page Page) { func (win *MainWindow) Update(status tsutil.Status) { switch status := status.(type) { - case *tsutil.NetStatus: + case *tsutil.IPNStatus: online := status.Online() win.StatusSwitch.SetState(online) win.StatusSwitch.SetActive(online) @@ -204,7 +204,7 @@ func (win *MainWindow) Update(status tsutil.Status) { } } -func (win *MainWindow) updatePeers(status *tsutil.NetStatus) { +func (win *MainWindow) updatePeers(status *tsutil.IPNStatus) { if !status.Online() { if _, ok := win.pages["offline"]; !ok { win.addPage("offline", NewOfflinePage(win.app)) @@ -216,16 +216,16 @@ func (win *MainWindow) updatePeers(status *tsutil.NetStatus) { if _, ok := win.pages["self"]; !ok { win.addPage("self", NewSelfPage(win.app, status)) } - if _, ok := win.pages["mullvad"]; !ok && tsutil.CanMullvad(status.Status.Self) { + if _, ok := win.pages["mullvad"]; !ok && tsutil.CanMullvad(status.NetMap.SelfNode) { win.addPage("mullvad", NewMullvadPage(win.app, status)) } - for _, peer := range status.Status.Peer { + for id, peer := range status.Peers { if tsutil.IsMullvad(peer) { continue } - name := string(peer.ID) + name := string(id) if _, ok := win.pages[name]; ok { continue } @@ -236,7 +236,7 @@ func (win *MainWindow) updatePeers(status *tsutil.NetStatus) { win.updatePages(status) } -func (win *MainWindow) updatePages(status *tsutil.NetStatus) { +func (win *MainWindow) updatePages(status *tsutil.IPNStatus) { var remove []string for name, page := range win.pages { ok := page.Update(status) diff --git a/internal/ui/mullvadpage.go b/internal/ui/mullvadpage.go index 815558d..fd086ce 100644 --- a/internal/ui/mullvadpage.go +++ b/internal/ui/mullvadpage.go @@ -13,7 +13,6 @@ import ( "github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4/pkg/gio/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" - "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" "tailscale.com/util/set" ) @@ -34,7 +33,7 @@ type MullvadPage struct { exitNodes map[tailcfg.StableNodeID]*mullvadExitNodeRow } -func NewMullvadPage(a *App, status *tsutil.NetStatus) *MullvadPage { +func NewMullvadPage(a *App, status *tsutil.IPNStatus) *MullvadPage { page := MullvadPage{ app: a, locations: make(map[string]*adw.ExpanderRow), @@ -65,7 +64,7 @@ func (page *MullvadPage) Init(row *PageRow) { } func (page *MullvadPage) Update(s tsutil.Status) bool { - status, ok := s.(*tsutil.NetStatus) + status, ok := s.(*tsutil.IPNStatus) if !ok { return true } @@ -73,7 +72,7 @@ func (page *MullvadPage) Update(s tsutil.Status) bool { return false } - if !tsutil.CanMullvad(status.Status.Self) { + if !tsutil.CanMullvad(status.NetMap.SelfNode) { return false } @@ -81,28 +80,31 @@ func (page *MullvadPage) Update(s tsutil.Status) bool { icon := "network-workgroup-symbolic" var exitNodeID tailcfg.StableNodeID - if status.Status.ExitNodeStatus != nil { - exitNodeID = status.Status.ExitNodeStatus.ID + if exitNode := status.ExitNode(); exitNode.Valid() { + exitNodeID = exitNode.StableID() } var exitNodeCountryCode string found := make(set.Set[tailcfg.StableNodeID]) - for _, peer := range status.Status.Peer { + for id, peer := range status.Peers { if tsutil.IsMullvad(peer) { - found.Add(peer.ID) - exitNode := peer.ID == exitNodeID + found.Add(id) + exitNode := id == exitNodeID row := page.getExitNodeRow(peer) sw := row.row.ActivatableWidget().(*gtk.Switch) sw.SetState(exitNode) sw.SetActive(exitNode) + loc := peer.Hostinfo().Location() + countryCode := loc.CountryCode() + page.locations[countryCode].SetSubtitle("") + if exitNode { - subtitle = mullvadLongLocationName(peer.Location) + subtitle = mullvadLongLocationName(loc) icon = "network-vpn-symbolic" - exitNodeCountryCode = peer.Location.CountryCode + exitNodeCountryCode = countryCode } - page.locations[peer.Location.CountryCode].SetSubtitle("") } } for id, row := range page.exitNodes { @@ -127,8 +129,8 @@ func (page *MullvadPage) Update(s tsutil.Status) bool { return true } -func (page *MullvadPage) getLocationRow(loc *tailcfg.Location) *adw.ExpanderRow { - if row, ok := page.locations[loc.CountryCode]; ok { +func (page *MullvadPage) getLocationRow(loc tailcfg.LocationView) *adw.ExpanderRow { + if row, ok := page.locations[loc.CountryCode()]; ok { return row } @@ -146,19 +148,21 @@ func (page *MullvadPage) getLocationRow(loc *tailcfg.Location) *adw.ExpanderRow ) }) - page.locations[loc.CountryCode] = row + page.locations[loc.CountryCode()] = row page.LocationList.Append(row) return row } -func (page *MullvadPage) getExitNodeRow(peer *ipnstate.PeerStatus) *mullvadExitNodeRow { - if row, ok := page.exitNodes[peer.ID]; ok { +func (page *MullvadPage) getExitNodeRow(peer tailcfg.NodeView) *mullvadExitNodeRow { + if row, ok := page.exitNodes[peer.StableID()]; ok { return row } + info := peer.Hostinfo() + row := adw.NewSwitchRow() - row.SetTitle(peer.Location.City) - row.SetSubtitle(peer.HostName) + row.SetTitle(info.Location().City()) + row.SetSubtitle(info.Hostname()) sw := row.ActivatableWidget().(*gtk.Switch) sw.SetMarginTop(12) @@ -176,9 +180,9 @@ func (page *MullvadPage) getExitNodeRow(peer *ipnstate.PeerStatus) *mullvadExitN } } - var node *ipnstate.PeerStatus + var node tailcfg.StableNodeID if s { - node = peer + node = peer.StableID() } err := tsutil.ExitNode(context.TODO(), node) if err != nil { @@ -190,13 +194,13 @@ func (page *MullvadPage) getExitNodeRow(peer *ipnstate.PeerStatus) *mullvadExitN return true }) - page.getLocationRow(peer.Location).AddRow(row) + page.getLocationRow(info.Location()).AddRow(row) exitNodeRow := mullvadExitNodeRow{ - country: peer.Location.CountryCode, + country: info.Location().CountryCode(), row: row, } - page.exitNodes[peer.ID] = &exitNodeRow + page.exitNodes[peer.StableID()] = &exitNodeRow return &exitNodeRow } @@ -205,20 +209,20 @@ type mullvadExitNodeRow struct { row *adw.SwitchRow } -func mullvadLongLocationName(loc *tailcfg.Location) string { +func mullvadLongLocationName(loc tailcfg.LocationView) string { return fmt.Sprintf( "%v %v, %v", - countryCodeToFlag(loc.CountryCode), - loc.City, - loc.Country, + countryCodeToFlag(loc.CountryCode()), + loc.City(), + loc.Country(), ) } -func mullvadLocationName(loc *tailcfg.Location) string { +func mullvadLocationName(loc tailcfg.LocationView) string { return fmt.Sprintf( "%v %v", - countryCodeToFlag(loc.CountryCode), - loc.Country, + countryCodeToFlag(loc.CountryCode()), + loc.Country(), ) } diff --git a/internal/ui/offlinepage.go b/internal/ui/offlinepage.go index c0d100d..72ac0cb 100644 --- a/internal/ui/offlinepage.go +++ b/internal/ui/offlinepage.go @@ -38,7 +38,7 @@ func (page *OfflinePage) Init(row *PageRow) { } func (page *OfflinePage) Update(status tsutil.Status) bool { - if status, ok := status.(*tsutil.NetStatus); ok { + if status, ok := status.(*tsutil.IPNStatus); ok { return !status.Online() } return true diff --git a/internal/ui/peerpage.go b/internal/ui/peerpage.go index a1a1fb0..96a0e32 100644 --- a/internal/ui/peerpage.go +++ b/internal/ui/peerpage.go @@ -20,6 +20,8 @@ import ( "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" "tailscale.com/ipn/ipnstate" + "tailscale.com/net/tsaddr" + "tailscale.com/tailcfg" ) //go:embed peerpage.ui @@ -28,7 +30,7 @@ var peerPageXML string type PeerPage struct { app *App row *PageRow - peer *ipnstate.PeerStatus + peer tailcfg.NodeView actions *gio.SimpleActionGroup Page *adw.StatusPage @@ -74,18 +76,18 @@ type PeerPage struct { SendDirButton *adw.ButtonRow DropTarget *gtk.DropTarget - addrModel *gioutil.ListModel[netip.Addr] + addrModel *gioutil.ListModel[netip.Prefix] routeModel *gioutil.ListModel[netip.Prefix] } -func NewPeerPage(a *App, status *tsutil.NetStatus, peer *ipnstate.PeerStatus) *PeerPage { +func NewPeerPage(a *App, status *tsutil.IPNStatus, peer tailcfg.NodeView) *PeerPage { var page PeerPage fillFromBuilder(&page, peerPageXML) page.init(a, status, peer) return &page } -func (page *PeerPage) init(a *App, status *tsutil.NetStatus, peer *ipnstate.PeerStatus) { +func (page *PeerPage) init(a *App, status *tsutil.IPNStatus, peer tailcfg.NodeView) { page.app = a page.peer = peer @@ -93,7 +95,7 @@ func (page *PeerPage) init(a *App, status *tsutil.NetStatus, peer *ipnstate.Peer copyFQDNAction := gio.NewSimpleAction("copyFQDN", nil) copyFQDNAction.ConnectActivate(func(p *glib.Variant) { - a.clip(glib.NewValue(strings.TrimSuffix(page.peer.DNSName, "."))) + a.clip(glib.NewValue(strings.TrimSuffix(page.peer.Name(), "."))) a.win.Toast("Copied FQDN to clipboard") }) page.actions.AddAction(copyFQDNAction) @@ -109,7 +111,7 @@ func (page *PeerPage) init(a *App, status *tsutil.NetStatus, peer *ipnstate.Peer open, finish = dialog.SelectMultipleFolders, dialog.SelectMultipleFoldersFinish } - dialog.SetTitle(fmt.Sprintf("Select %v(s) to send to %v", mode, page.peer.HostName)) + dialog.SetTitle(fmt.Sprintf("Select %v(s) to send to %v", mode, page.peer.Hostinfo().Hostname())) open(context.TODO(), &a.win.MainWindow.Window, func(res gio.AsyncResulter) { files, err := finish(res) @@ -121,7 +123,7 @@ func (page *PeerPage) init(a *App, status *tsutil.NetStatus, peer *ipnstate.Peer } for _, file := range listmodels.Values[gio.Filer](files) { - go a.pushFile(context.TODO(), page.peer.ID, file) + go a.pushFile(context.TODO(), page.peer.StableID(), file) } }) }) @@ -134,15 +136,15 @@ func (page *PeerPage) init(a *App, status *tsutil.NetStatus, peer *ipnstate.Peer if !ok { return true } - go a.pushFile(context.TODO(), page.peer.ID, file) + go a.pushFile(context.TODO(), page.peer.StableID(), file) return true }) - page.addrModel = gioutil.NewListModel[netip.Addr]() + page.addrModel = gioutil.NewListModel[netip.Prefix]() listmodels.BindListBox( page.IPList, - gtk.NewSortListModel(page.addrModel, &addrSorter.Sorter), - func(addr netip.Addr) gtk.Widgetter { + gtk.NewSortListModel(page.addrModel, &prefixSorter.Sorter), + func(addr netip.Prefix) gtk.Widgetter { copyButton := gtk.NewButtonFromIconName("edit-copy-symbolic") copyButton.SetMarginTop(12) // Why is this necessary? @@ -217,9 +219,9 @@ func (page *PeerPage) init(a *App, status *tsutil.NetStatus, peer *ipnstate.Peer } } - var node *ipnstate.PeerStatus + var node tailcfg.StableNodeID if s { - node = page.peer + node = page.peer.StableID() } err := tsutil.ExitNode(context.TODO(), node) if err != nil { @@ -245,7 +247,7 @@ func (page *PeerPage) Init(row *PageRow) { } func (page *PeerPage) Update(s tsutil.Status) bool { - status, ok := s.(*tsutil.NetStatus) + status, ok := s.(*tsutil.IPNStatus) if !ok { return true } @@ -253,35 +255,41 @@ func (page *PeerPage) Update(s tsutil.Status) bool { return false } - page.peer = status.Status.Peer[page.peer.PublicKey] - if page.peer == nil { + page.peer = status.Peers[page.peer.StableID()] + if !page.peer.Valid() { return false } - page.row.SetTitle(peerName(status, page.peer)) - page.row.SetSubtitle(peerSubtitle(page.peer)) - page.row.SetIconName(peerIcon(page.peer)) - - page.Page.SetTitle(page.peer.HostName) - page.Page.SetDescription(page.peer.DNSName) - - page.ExitNodeRow.SetVisible(page.peer.ExitNodeOption) - page.ExitNodeRow.ActivatableWidget().(*gtk.Switch).SetState(page.peer.ExitNode) - page.ExitNodeRow.ActivatableWidget().(*gtk.Switch).SetActive(page.peer.ExitNode) - page.RxBytes.SetText(strconv.FormatInt(page.peer.RxBytes, 10)) - page.TxBytes.SetText(strconv.FormatInt(page.peer.TxBytes, 10)) - page.Created.SetText(formatTime(page.peer.Created)) - page.LastSeen.SetText(formatTime(page.peer.LastSeen)) - page.LastSeenRow.SetVisible(!page.peer.Online) - page.LastWrite.SetText(formatTime(page.peer.LastWrite)) - page.LastHandshake.SetText(formatTime(page.peer.LastHandshake)) - page.Online.SetFromIconName(boolIcon(page.peer.Online)) + online := page.peer.Online().Get() + exitNodeOption := tsaddr.ContainsExitRoutes(page.peer.AllowedIPs()) + exitNode := page.peer.Equal(status.ExitNode()) + + var enginePeer ipnstate.PeerStatusLite + if status.Engine != nil { + enginePeer = status.Engine.LivePeers[page.peer.Key()] + } + + page.row.SetTitle(peerName(page.peer)) + page.row.SetSubtitle(peerSubtitle(exitNodeOption, exitNode)) + page.row.SetIconName(peerIcon(online, exitNodeOption, exitNode)) + + page.Page.SetTitle(page.peer.Hostinfo().Hostname()) + page.Page.SetDescription(page.peer.Name()) + + page.ExitNodeRow.SetVisible(exitNodeOption) + page.ExitNodeRow.ActivatableWidget().(*gtk.Switch).SetState(exitNode) + page.ExitNodeRow.ActivatableWidget().(*gtk.Switch).SetActive(exitNode) + page.RxBytes.SetText(strconv.FormatInt(enginePeer.RxBytes, 10)) + page.TxBytes.SetText(strconv.FormatInt(enginePeer.TxBytes, 10)) + page.Created.SetText(formatTime(page.peer.Created())) + page.LastSeen.SetText(formatTime(page.peer.LastSeen().Get())) + page.LastSeenRow.SetVisible(!online) + //page.LastWrite.SetText(formatTime(page.peer.LastWrite)) + page.LastHandshake.SetText(formatTime(enginePeer.LastHandshake)) + page.Online.SetFromIconName(boolIcon(online)) routes := func(yield func(netip.Prefix) bool) { - if page.peer.PrimaryRoutes == nil { - return - } - for _, r := range page.peer.PrimaryRoutes.All() { + for _, r := range page.peer.PrimaryRoutes().All() { if r.Bits() == 0 { continue } @@ -291,37 +299,37 @@ func (page *PeerPage) Update(s tsutil.Status) bool { } } - listmodels.Update(page.addrModel, slices.Values(page.peer.TailscaleIPs)) + listmodels.Update(page.addrModel, xiter.V2(page.peer.Addresses().All())) listmodels.Update(page.routeModel, routes) return true } -func peerName(status *tsutil.NetStatus, peer *ipnstate.PeerStatus) string { - return tsutil.DNSOrQuoteHostname(status.Status, peer) +func peerName(peer tailcfg.NodeView) string { + return peer.DisplayName(true) } -func peerSubtitle(peer *ipnstate.PeerStatus) string { - if peer.ExitNode { +func peerSubtitle(exitNodeOption, exitNode bool) string { + if exitNode { return "Current exit node" } - if peer.ExitNodeOption { + if exitNodeOption { return "Exit node option" } return "" } -func peerIcon(peer *ipnstate.PeerStatus) string { - if peer.ExitNode { - if !peer.Online { +func peerIcon(online bool, exitNodeOption, exitNode bool) string { + if exitNode { + if !online { return "network-vpn-acquiring-symbolic" } return "network-vpn-symbolic" } - if !peer.Online { + if !online { return "network-wired-offline-symbolic" } - if peer.ExitNodeOption { + if exitNodeOption { return "folder-remote-symbolic" } diff --git a/internal/ui/selfpage.go b/internal/ui/selfpage.go index 90f82ae..0c45189 100644 --- a/internal/ui/selfpage.go +++ b/internal/ui/selfpage.go @@ -21,7 +21,7 @@ import ( "github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/inhies/go-bytesize" "tailscale.com/client/tailscale/apitype" - "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" ) //go:embed selfpage.ui @@ -30,7 +30,7 @@ var selfPageXML string type SelfPage struct { app *App row *PageRow - peer *ipnstate.PeerStatus + peer tailcfg.NodeView actions *gio.SimpleActionGroup Page *adw.StatusPage @@ -66,36 +66,36 @@ type SelfPage struct { DERPLatencies *adw.ExpanderRow FilesList *gtk.ListBox - addrModel *gioutil.ListModel[netip.Addr] + addrModel *gioutil.ListModel[netip.Prefix] routeModel *gioutil.ListModel[netip.Prefix] fileModel *gioutil.ListModel[apitype.WaitingFile] } -func NewSelfPage(a *App, status *tsutil.NetStatus) *SelfPage { +func NewSelfPage(a *App, status *tsutil.IPNStatus) *SelfPage { var page SelfPage fillFromBuilder(&page, selfPageXML) page.init(a, status) return &page } -func (page *SelfPage) init(a *App, status *tsutil.NetStatus) { +func (page *SelfPage) init(a *App, status *tsutil.IPNStatus) { page.app = a - page.peer = status.Status.Self + page.peer = status.NetMap.SelfNode page.actions = gio.NewSimpleActionGroup() copyFQDN := gio.NewSimpleAction("copyFQDN", nil) copyFQDN.ConnectActivate(func(p *glib.Variant) { - a.clip(glib.NewValue(strings.TrimSuffix(page.peer.DNSName, "."))) + a.clip(glib.NewValue(strings.TrimSuffix(page.peer.Name(), "."))) a.win.Toast("Copied FQDN to clipboard") }) page.actions.AddAction(copyFQDN) - page.addrModel = gioutil.NewListModel[netip.Addr]() + page.addrModel = gioutil.NewListModel[netip.Prefix]() listmodels.BindListBox( page.IPList, - gtk.NewSortListModel(page.addrModel, &addrSorter.Sorter), - func(addr netip.Addr) gtk.Widgetter { + gtk.NewSortListModel(page.addrModel, &prefixSorter.Sorter), + func(addr netip.Prefix) gtk.Widgetter { copyButton := gtk.NewButtonFromIconName("edit-copy-symbolic") copyButton.SetMarginTop(12) // Why is this necessary? @@ -227,7 +227,7 @@ func (page *SelfPage) init(a *App, status *tsutil.NetStatus) { } if s { - err := tsutil.ExitNode(context.TODO(), nil) + err := tsutil.ExitNode(context.TODO(), "") if err != nil { slog.Error("disable existing exit node", "err", err) // Continue anyways. @@ -392,7 +392,7 @@ func (page *SelfPage) Init(row *PageRow) { func (page *SelfPage) Update(status tsutil.Status) bool { switch status := status.(type) { - case *tsutil.NetStatus: + case *tsutil.IPNStatus: return page.UpdateNet(status) case *tsutil.FileStatus: return page.UpdateFiles(status) @@ -401,28 +401,28 @@ func (page *SelfPage) Update(status tsutil.Status) bool { } } -func (page *SelfPage) UpdateNet(status *tsutil.NetStatus) bool { +func (page *SelfPage) UpdateNet(status *tsutil.IPNStatus) bool { if !status.Online() { return false } - page.peer = status.Status.Self + page.peer = status.NetMap.SelfNode - page.row.SetTitle(peerName(status, page.peer)) + page.row.SetTitle(peerName(page.peer)) page.row.SetIconName("computer-symbolic") - page.Page.SetTitle(page.peer.HostName) - page.Page.SetDescription(page.peer.DNSName) + page.Page.SetTitle(page.peer.Hostinfo().Hostname()) + page.Page.SetDescription(page.peer.Name()) page.AdvertiseExitNodeRow.ActivatableWidget().(*gtk.Switch).SetState(status.Prefs.AdvertisesExitNode()) page.AdvertiseExitNodeRow.ActivatableWidget().(*gtk.Switch).SetActive(status.Prefs.AdvertisesExitNode()) - page.AllowLANAccessRow.ActivatableWidget().(*gtk.Switch).SetState(status.Prefs.ExitNodeAllowLANAccess) - page.AllowLANAccessRow.ActivatableWidget().(*gtk.Switch).SetActive(status.Prefs.ExitNodeAllowLANAccess) - page.AcceptRoutesRow.ActivatableWidget().(*gtk.Switch).SetState(status.Prefs.RouteAll) - page.AcceptRoutesRow.ActivatableWidget().(*gtk.Switch).SetActive(status.Prefs.RouteAll) + page.AllowLANAccessRow.ActivatableWidget().(*gtk.Switch).SetState(status.Prefs.ExitNodeAllowLANAccess()) + page.AllowLANAccessRow.ActivatableWidget().(*gtk.Switch).SetActive(status.Prefs.ExitNodeAllowLANAccess()) + page.AcceptRoutesRow.ActivatableWidget().(*gtk.Switch).SetState(status.Prefs.RouteAll()) + page.AcceptRoutesRow.ActivatableWidget().(*gtk.Switch).SetActive(status.Prefs.RouteAll()) routes := func(yield func(netip.Prefix) bool) { - for _, r := range status.Prefs.AdvertiseRoutes { + for _, r := range status.Prefs.AdvertiseRoutes().All() { if r.Bits() != 0 { if !yield(r) { return @@ -431,7 +431,7 @@ func (page *SelfPage) UpdateNet(status *tsutil.NetStatus) bool { } } - listmodels.Update(page.addrModel, slices.Values(page.peer.TailscaleIPs)) + listmodels.Update(page.addrModel, xiter.V2(page.peer.Addresses().All())) listmodels.Update(page.routeModel, routes) return true diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 2412401..106c5ef 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -57,7 +57,7 @@ func (a *App) runSettings(ctx context.Context) { } func (a *App) showChangeControlServer() { - status := <-a.poller.GetNet() + status := <-a.poller.GetIPN() Prompt{ Heading: "Control Server URL", @@ -66,7 +66,7 @@ func (a *App) showChangeControlServer() { {ID: "default", Label: "Use _Default"}, {ID: "set", Label: "_Set URL", Appearance: adw.ResponseSuggested, Default: true}, }, - }.Show(a, status.Prefs.ControlURL, func(response, val string) { + }.Show(a, status.Prefs.ControlURL(), func(response, val string) { switch response { case "default": val = ipn.DefaultControlURL From 44c18263613d8bd13da704b0b746dab900eadc49 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 21:18:29 -0400 Subject: [PATCH 10/38] internal/ui: remove now-unused `LastWrite` row from `PeerPage` --- internal/ui/mainwindow.ui | 48 +------------------------------------- internal/ui/peerpage.go | 3 --- internal/ui/peerpage.ui | 8 ------- internal/ui/preferences.ui | 2 +- internal/ui/trayscale.cmb | 6 ++--- 5 files changed, 5 insertions(+), 62 deletions(-) diff --git a/internal/ui/mainwindow.ui b/internal/ui/mainwindow.ui index 874e2b6..36251d1 100644 --- a/internal/ui/mainwindow.ui +++ b/internal/ui/mainwindow.ui @@ -2,9 +2,9 @@ + - ToastOverlay 600 @@ -30,28 +30,6 @@ open-menu-symbolic - - -
- - peer.copyFQDN - _Copy FQDN - -
-
- - peer.sendFile - Send _file... - file - - - peer.sendFile - Send _directory... - dir - -
-
-
@@ -91,30 +69,6 @@ open-menu-symbolic - - -
- - app.change_control_server - Change Control _Server - - - app.preferences - _Preferences - -
-
- - app.about - _About - - - app.quit - _Quit - -
-
-
True
diff --git a/internal/ui/peerpage.go b/internal/ui/peerpage.go index 96a0e32..822ff65 100644 --- a/internal/ui/peerpage.go +++ b/internal/ui/peerpage.go @@ -64,8 +64,6 @@ type PeerPage struct { LastSeen *gtk.Label CreatedRow *adw.ActionRow Created *gtk.Label - LastWriteRow *adw.ActionRow - LastWrite *gtk.Label LastHandshakeRow *adw.ActionRow LastHandshake *gtk.Label RxBytesRow *adw.ActionRow @@ -284,7 +282,6 @@ func (page *PeerPage) Update(s tsutil.Status) bool { page.Created.SetText(formatTime(page.peer.Created())) page.LastSeen.SetText(formatTime(page.peer.LastSeen().Get())) page.LastSeenRow.SetVisible(!online) - //page.LastWrite.SetText(formatTime(page.peer.LastWrite)) page.LastHandshake.SetText(formatTime(enginePeer.LastHandshake)) page.Online.SetFromIconName(boolIcon(online)) diff --git a/internal/ui/peerpage.ui b/internal/ui/peerpage.ui index f23845b..71f0aee 100644 --- a/internal/ui/peerpage.ui +++ b/internal/ui/peerpage.ui @@ -54,14 +54,6 @@
- - - Last write - - - - - Last handshake diff --git a/internal/ui/preferences.ui b/internal/ui/preferences.ui index 75ea636..f598f39 100644 --- a/internal/ui/preferences.ui +++ b/internal/ui/preferences.ui @@ -1,5 +1,5 @@ - + diff --git a/internal/ui/trayscale.cmb b/internal/ui/trayscale.cmb index 6e4f4e2..ecaf7d8 100644 --- a/internal/ui/trayscale.cmb +++ b/internal/ui/trayscale.cmb @@ -2,9 +2,9 @@ - - - + + + From 3314214ecfcc8a367146437b3bf22269986f1d4a Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 21:20:34 -0400 Subject: [PATCH 11/38] Revert "internal/ui: remove now-unused `LastWrite` row from `PeerPage`" This reverts commit 44c18263613d8bd13da704b0b746dab900eadc49. --- internal/ui/mainwindow.ui | 48 +++++++++++++++++++++++++++++++++++++- internal/ui/peerpage.go | 3 +++ internal/ui/peerpage.ui | 8 +++++++ internal/ui/preferences.ui | 2 +- internal/ui/trayscale.cmb | 6 ++--- 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/internal/ui/mainwindow.ui b/internal/ui/mainwindow.ui index 36251d1..874e2b6 100644 --- a/internal/ui/mainwindow.ui +++ b/internal/ui/mainwindow.ui @@ -2,9 +2,9 @@ - + ToastOverlay 600 @@ -30,6 +30,28 @@ open-menu-symbolic + + +
+ + peer.copyFQDN + _Copy FQDN + +
+
+ + peer.sendFile + Send _file... + file + + + peer.sendFile + Send _directory... + dir + +
+
+
@@ -69,6 +91,30 @@ open-menu-symbolic + + +
+ + app.change_control_server + Change Control _Server + + + app.preferences + _Preferences + +
+
+ + app.about + _About + + + app.quit + _Quit + +
+
+
True
diff --git a/internal/ui/peerpage.go b/internal/ui/peerpage.go index 822ff65..96a0e32 100644 --- a/internal/ui/peerpage.go +++ b/internal/ui/peerpage.go @@ -64,6 +64,8 @@ type PeerPage struct { LastSeen *gtk.Label CreatedRow *adw.ActionRow Created *gtk.Label + LastWriteRow *adw.ActionRow + LastWrite *gtk.Label LastHandshakeRow *adw.ActionRow LastHandshake *gtk.Label RxBytesRow *adw.ActionRow @@ -282,6 +284,7 @@ func (page *PeerPage) Update(s tsutil.Status) bool { page.Created.SetText(formatTime(page.peer.Created())) page.LastSeen.SetText(formatTime(page.peer.LastSeen().Get())) page.LastSeenRow.SetVisible(!online) + //page.LastWrite.SetText(formatTime(page.peer.LastWrite)) page.LastHandshake.SetText(formatTime(enginePeer.LastHandshake)) page.Online.SetFromIconName(boolIcon(online)) diff --git a/internal/ui/peerpage.ui b/internal/ui/peerpage.ui index 71f0aee..f23845b 100644 --- a/internal/ui/peerpage.ui +++ b/internal/ui/peerpage.ui @@ -54,6 +54,14 @@
+ + + Last write + + + + + Last handshake diff --git a/internal/ui/preferences.ui b/internal/ui/preferences.ui index f598f39..75ea636 100644 --- a/internal/ui/preferences.ui +++ b/internal/ui/preferences.ui @@ -1,5 +1,5 @@ - + diff --git a/internal/ui/trayscale.cmb b/internal/ui/trayscale.cmb index ecaf7d8..6e4f4e2 100644 --- a/internal/ui/trayscale.cmb +++ b/internal/ui/trayscale.cmb @@ -2,9 +2,9 @@ - - - + + + From d7531b66ac40f586a0ece5eb1e949e2fc88b5ebe Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 21:34:30 -0400 Subject: [PATCH 12/38] internal/ui: remove now-unused `LastWrite` row from `PeerPage` and fix menus --- internal/ui/mainwindow.go | 11 ++++++--- internal/ui/mainwindow.ui | 49 ++------------------------------------ internal/ui/menu.ui | 45 ++++++++++++++++++++++++++++++++++ internal/ui/peerpage.go | 3 --- internal/ui/peerpage.ui | 8 ------- internal/ui/preferences.ui | 2 +- internal/ui/trayscale.cmb | 6 ++--- 7 files changed, 59 insertions(+), 65 deletions(-) create mode 100644 internal/ui/menu.ui diff --git a/internal/ui/mainwindow.go b/internal/ui/mainwindow.go index 9c4bb6d..b90a314 100644 --- a/internal/ui/mainwindow.go +++ b/internal/ui/mainwindow.go @@ -18,8 +18,13 @@ import ( "tailscale.com/ipn" ) -//go:embed mainwindow.ui -var mainWindowXML string +var ( + //go:embed mainwindow.ui + mainWindowXML string + + //go:embed menu.ui + menuXML string +) type MainWindow struct { app *App @@ -47,7 +52,7 @@ func NewMainWindow(app *App) *MainWindow { app: app, pages: make(map[string]Page), } - fillFromBuilder(&win, mainWindowXML) + fillFromBuilder(&win, menuXML, mainWindowXML) win.MainWindow.SetApplication(&app.app.Application) diff --git a/internal/ui/mainwindow.ui b/internal/ui/mainwindow.ui index 874e2b6..1e7e0b0 100644 --- a/internal/ui/mainwindow.ui +++ b/internal/ui/mainwindow.ui @@ -4,7 +4,6 @@ - ToastOverlay 600 @@ -30,28 +29,7 @@ open-menu-symbolic - - -
- - peer.copyFQDN - _Copy FQDN - -
-
- - peer.sendFile - Send _file... - file - - - peer.sendFile - Send _directory... - dir - -
-
-
+ PageMenu
@@ -91,31 +69,8 @@ open-menu-symbolic - - -
- - app.change_control_server - Change Control _Server - - - app.preferences - _Preferences - -
-
- - app.about - _About - - - app.quit - _Quit - -
-
-
True + MainMenu
diff --git a/internal/ui/menu.ui b/internal/ui/menu.ui new file mode 100644 index 0000000..6983120 --- /dev/null +++ b/internal/ui/menu.ui @@ -0,0 +1,45 @@ + + + +
+ + Change Control _Server + app.change_control_server + + + _Preferences + app.preferences + +
+
+ + _About + app.about + + + _Quit + app.quit + +
+
+ +
+ + peer.copyFQDN + _Copy FQDN + +
+
+ + peer.sendFile + Send _file... + file + + + peer.sendFile + Send _directory... + dir + +
+
+
diff --git a/internal/ui/peerpage.go b/internal/ui/peerpage.go index 96a0e32..822ff65 100644 --- a/internal/ui/peerpage.go +++ b/internal/ui/peerpage.go @@ -64,8 +64,6 @@ type PeerPage struct { LastSeen *gtk.Label CreatedRow *adw.ActionRow Created *gtk.Label - LastWriteRow *adw.ActionRow - LastWrite *gtk.Label LastHandshakeRow *adw.ActionRow LastHandshake *gtk.Label RxBytesRow *adw.ActionRow @@ -284,7 +282,6 @@ func (page *PeerPage) Update(s tsutil.Status) bool { page.Created.SetText(formatTime(page.peer.Created())) page.LastSeen.SetText(formatTime(page.peer.LastSeen().Get())) page.LastSeenRow.SetVisible(!online) - //page.LastWrite.SetText(formatTime(page.peer.LastWrite)) page.LastHandshake.SetText(formatTime(enginePeer.LastHandshake)) page.Online.SetFromIconName(boolIcon(online)) diff --git a/internal/ui/peerpage.ui b/internal/ui/peerpage.ui index f23845b..71f0aee 100644 --- a/internal/ui/peerpage.ui +++ b/internal/ui/peerpage.ui @@ -54,14 +54,6 @@
- - - Last write - - - - - Last handshake diff --git a/internal/ui/preferences.ui b/internal/ui/preferences.ui index 75ea636..f598f39 100644 --- a/internal/ui/preferences.ui +++ b/internal/ui/preferences.ui @@ -1,5 +1,5 @@ - + diff --git a/internal/ui/trayscale.cmb b/internal/ui/trayscale.cmb index 6e4f4e2..3244a8b 100644 --- a/internal/ui/trayscale.cmb +++ b/internal/ui/trayscale.cmb @@ -2,9 +2,9 @@ - - - + + + From 8d90ac2d0ae796a9094c45eb8a4fd6ad184a0a26 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 21:39:22 -0400 Subject: [PATCH 13/38] internal/ui: add `menu.ui` to the Cambalache project --- internal/ui/mainwindow.ui | 2 +- internal/ui/menu.ui | 13 ++++++++----- internal/ui/trayscale.cmb | 3 ++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/internal/ui/mainwindow.ui b/internal/ui/mainwindow.ui index 1e7e0b0..fd2a105 100644 --- a/internal/ui/mainwindow.ui +++ b/internal/ui/mainwindow.ui @@ -69,8 +69,8 @@ open-menu-symbolic - True MainMenu + True diff --git a/internal/ui/menu.ui b/internal/ui/menu.ui index 6983120..a450f90 100644 --- a/internal/ui/menu.ui +++ b/internal/ui/menu.ui @@ -1,24 +1,27 @@ + + - + +
- Change Control _Server app.change_control_server + Change Control _Server - _Preferences app.preferences + _Preferences
- _About app.about + _About - _Quit app.quit + _Quit
diff --git a/internal/ui/trayscale.cmb b/internal/ui/trayscale.cmb index 3244a8b..266ff8e 100644 --- a/internal/ui/trayscale.cmb +++ b/internal/ui/trayscale.cmb @@ -2,9 +2,10 @@ - + + From f260e6616751a9dc9818ab8f9f7ff18bb28228b0 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 21:45:07 -0400 Subject: [PATCH 14/38] internal/ui: fix peer IP addresses --- internal/ui/peerpage.go | 10 +++++----- internal/ui/selfpage.go | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/ui/peerpage.go b/internal/ui/peerpage.go index 822ff65..080ef19 100644 --- a/internal/ui/peerpage.go +++ b/internal/ui/peerpage.go @@ -74,7 +74,7 @@ type PeerPage struct { SendDirButton *adw.ButtonRow DropTarget *gtk.DropTarget - addrModel *gioutil.ListModel[netip.Prefix] + addrModel *gioutil.ListModel[netip.Addr] routeModel *gioutil.ListModel[netip.Prefix] } @@ -138,11 +138,11 @@ func (page *PeerPage) init(a *App, status *tsutil.IPNStatus, peer tailcfg.NodeVi return true }) - page.addrModel = gioutil.NewListModel[netip.Prefix]() + page.addrModel = gioutil.NewListModel[netip.Addr]() listmodels.BindListBox( page.IPList, - gtk.NewSortListModel(page.addrModel, &prefixSorter.Sorter), - func(addr netip.Prefix) gtk.Widgetter { + gtk.NewSortListModel(page.addrModel, &addrSorter.Sorter), + func(addr netip.Addr) gtk.Widgetter { copyButton := gtk.NewButtonFromIconName("edit-copy-symbolic") copyButton.SetMarginTop(12) // Why is this necessary? @@ -296,7 +296,7 @@ func (page *PeerPage) Update(s tsutil.Status) bool { } } - listmodels.Update(page.addrModel, xiter.V2(page.peer.Addresses().All())) + listmodels.Update(page.addrModel, xiter.Map(xiter.V2(page.peer.Addresses().All()), netip.Prefix.Addr)) listmodels.Update(page.routeModel, routes) return true diff --git a/internal/ui/selfpage.go b/internal/ui/selfpage.go index 0c45189..1b1ba6e 100644 --- a/internal/ui/selfpage.go +++ b/internal/ui/selfpage.go @@ -66,7 +66,7 @@ type SelfPage struct { DERPLatencies *adw.ExpanderRow FilesList *gtk.ListBox - addrModel *gioutil.ListModel[netip.Prefix] + addrModel *gioutil.ListModel[netip.Addr] routeModel *gioutil.ListModel[netip.Prefix] fileModel *gioutil.ListModel[apitype.WaitingFile] } @@ -91,11 +91,11 @@ func (page *SelfPage) init(a *App, status *tsutil.IPNStatus) { }) page.actions.AddAction(copyFQDN) - page.addrModel = gioutil.NewListModel[netip.Prefix]() + page.addrModel = gioutil.NewListModel[netip.Addr]() listmodels.BindListBox( page.IPList, - gtk.NewSortListModel(page.addrModel, &prefixSorter.Sorter), - func(addr netip.Prefix) gtk.Widgetter { + gtk.NewSortListModel(page.addrModel, &addrSorter.Sorter), + func(addr netip.Addr) gtk.Widgetter { copyButton := gtk.NewButtonFromIconName("edit-copy-symbolic") copyButton.SetMarginTop(12) // Why is this necessary? @@ -431,7 +431,7 @@ func (page *SelfPage) UpdateNet(status *tsutil.IPNStatus) bool { } } - listmodels.Update(page.addrModel, xiter.V2(page.peer.Addresses().All())) + listmodels.Update(page.addrModel, xiter.Map(xiter.V2(page.peer.Addresses().All()), netip.Prefix.Addr)) listmodels.Update(page.routeModel, routes) return true From 4371d6d27e1d6282c452474ec478f99d1c82b159 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 21:54:40 -0400 Subject: [PATCH 15/38] internal/ui: use `Gtk.Entry` not `Gtk.Text` How in the world did I manage to do that... --- internal/ui/dialogs.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/ui/dialogs.go b/internal/ui/dialogs.go index 745d45e..76ab611 100644 --- a/internal/ui/dialogs.go +++ b/internal/ui/dialogs.go @@ -52,10 +52,8 @@ type PromptResponse struct { } func (d Prompt) Show(a *App, initialValue string, res func(response, val string)) { - input := gtk.NewText() - if initialValue != "" { - input.Buffer().SetText(initialValue, len(initialValue)) - } + input := gtk.NewEntry() + input.SetText(initialValue) dialog := adw.NewAlertDialog(d.Heading, d.Body) dialog.SetExtraChild(input) @@ -71,11 +69,11 @@ func (d Prompt) Show(a *App, initialValue string, res func(response, val string) } dialog.ConnectResponse(func(response string) { - res(response, input.Buffer().Text()) + res(response, input.Text()) }) input.ConnectActivate(func() { defer dialog.Close() - res(def, input.Buffer().Text()) + res(def, input.Text()) }) dialog.Present(a.window()) From a8b078b5e5d2db28363b983e35ee8ef942d75f18 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 22:04:10 -0400 Subject: [PATCH 16/38] internal/ui: tweak `Prompt` usages a bit --- internal/ui/dialogs.go | 10 +++++++--- internal/ui/selfpage.go | 4 ++-- internal/ui/settings.go | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/ui/dialogs.go b/internal/ui/dialogs.go index 76ab611..72c6b3d 100644 --- a/internal/ui/dialogs.go +++ b/internal/ui/dialogs.go @@ -39,9 +39,11 @@ func (d Confirmation) Show(a *App, res func(bool)) { } type Prompt struct { - Heading string - Body string - Responses []PromptResponse + Heading string + Body string + Placeholder string + Purpose gtk.InputPurpose + Responses []PromptResponse } type PromptResponse struct { @@ -54,6 +56,8 @@ type PromptResponse struct { func (d Prompt) Show(a *App, initialValue string, res func(response, val string)) { input := gtk.NewEntry() input.SetText(initialValue) + input.SetInputPurpose(d.Purpose) + input.SetPlaceholderText(d.Placeholder) dialog := adw.NewAlertDialog(d.Heading, d.Body) dialog.SetExtraChild(input) diff --git a/internal/ui/selfpage.go b/internal/ui/selfpage.go index 1b1ba6e..cb6c680 100644 --- a/internal/ui/selfpage.go +++ b/internal/ui/selfpage.go @@ -276,8 +276,8 @@ func (page *SelfPage) init(a *App, status *tsutil.IPNStatus) { page.AdvertiseRouteButton.ConnectClicked(func() { Prompt{ - Heading: "Add IP", - Body: "IP prefix to advertise", + Heading: "Add IP Prefix", + Placeholder: "10.0.0.0/24", Responses: []PromptResponse{ {ID: "cancel", Label: "_Cancel"}, {ID: "add", Label: "_Add", Appearance: adw.ResponseSuggested, Default: true}, diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 106c5ef..d1bbf20 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -61,6 +61,7 @@ func (a *App) showChangeControlServer() { Prompt{ Heading: "Control Server URL", + Purpose: gtk.InputPurposeURL, Responses: []PromptResponse{ {ID: "cancel", Label: "_Cancel"}, {ID: "default", Label: "Use _Default"}, From 51e19566ec3e872041d9f91f8a4961000eab7264 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 22:39:19 -0400 Subject: [PATCH 17/38] internal/tsutil: add file target information and fix some data races --- internal/tsutil/client.go | 4 ++++ internal/tsutil/poller.go | 34 +++++++++++++++++++++++++++------- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/internal/tsutil/client.go b/internal/tsutil/client.go index 708d7be..709df25 100644 --- a/internal/tsutil/client.go +++ b/internal/tsutil/client.go @@ -244,6 +244,10 @@ func WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) { return localClient.AwaitWaitingFiles(ctx, time.Second) } +func FileTargets(ctx context.Context) ([]apitype.FileTarget, error) { + return localClient.FileTargets(ctx) +} + func GetProfileStatus(ctx context.Context) (ipn.LoginProfile, []ipn.LoginProfile, error) { return localClient.ProfileStatus(ctx) } diff --git a/internal/tsutil/poller.go b/internal/tsutil/poller.go index c7cf74c..d877e5c 100644 --- a/internal/tsutil/poller.go +++ b/internal/tsutil/poller.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log/slog" + "maps" "net/netip" "os/user" "slices" @@ -17,6 +18,7 @@ import ( "tailscale.com/ipn" "tailscale.com/tailcfg" "tailscale.com/types/netmap" + "tailscale.com/util/set" ) // A Poller gets the latest Tailscale status at regular intervals or @@ -85,6 +87,8 @@ func (p *Poller) Run(ctx context.Context) { case interval = <-p.interval: n = n.Notify() check.Reset(interval) + case <-check.C: + n = n.Notify() } } } @@ -105,7 +109,7 @@ watch: } defer watcher.Close() - set := make(chan IPNStatus) + set := make(chan *IPNStatus) go func() { var get chan *IPNStatus var s *IPNStatus @@ -113,8 +117,7 @@ watch: select { case <-ctx.Done(): return - case v := <-set: - s = &v + case s = <-set: get = p.getIPN p.New(s) case get <- s: @@ -144,7 +147,7 @@ watch: } if notify.NetMap != nil { s.NetMap = notify.NetMap - s.rebuildPeers() + s.rebuildPeers(ctx) dirty = true } if notify.Engine != nil { @@ -162,7 +165,7 @@ watch: select { case <-ctx.Done(): return - case set <- s: + case set <- s.copy(): } } } @@ -246,19 +249,36 @@ type IPNStatus struct { Prefs ipn.PrefsView NetMap *netmap.NetworkMap Peers map[tailcfg.StableNodeID]tailcfg.NodeView + FileTargets set.Set[tailcfg.StableNodeID] Engine *ipn.EngineStatus BrowseToURL string } -func (s *IPNStatus) rebuildPeers() { +func (s IPNStatus) copy() *IPNStatus { + s.Peers = maps.Clone(s.Peers) + s.FileTargets = maps.Clone(s.FileTargets) + return &s +} + +func (s *IPNStatus) rebuildPeers(ctx context.Context) { if s.Peers == nil { mk.Map(&s.Peers, 0) } clear(s.Peers) - for _, peer := range s.NetMap.Peers { s.Peers[peer.StableID()] = peer } + + targets, err := FileTargets(ctx) + if err != nil { + slog.Error("failed to get file targets", "err", err) + return + } + s.FileTargets.Make() + clear(s.FileTargets) + for _, target := range targets { + s.FileTargets.Add(target.Node.StableID) + } } // Online returns true if s indicates that the local node is online From 7d2eae52eb431168a1e646bf2bc2216adbb864f0 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 22:40:05 -0400 Subject: [PATCH 18/38] internal/ui: filter by file targets --- internal/ui/app.go | 2 +- internal/ui/peerpage.go | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 30c8cee..975d1dc 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -210,7 +210,7 @@ func (a *App) onAppOpen(ctx context.Context, files []gio.Filer) { options := func(yield func(selectOption) bool) { // TODO: Only show nodes that can receive files. for _, peer := range s.Peers { - if tsutil.IsMullvad(peer) { + if !s.FileTargets.Contains(peer.StableID()) || tsutil.IsMullvad(peer) { continue } diff --git a/internal/ui/peerpage.go b/internal/ui/peerpage.go index 080ef19..9523eec 100644 --- a/internal/ui/peerpage.go +++ b/internal/ui/peerpage.go @@ -74,6 +74,8 @@ type PeerPage struct { SendDirButton *adw.ButtonRow DropTarget *gtk.DropTarget + sendFileAction *gio.SimpleAction + addrModel *gioutil.ListModel[netip.Addr] routeModel *gioutil.ListModel[netip.Prefix] } @@ -98,8 +100,8 @@ func (page *PeerPage) init(a *App, status *tsutil.IPNStatus, peer tailcfg.NodeVi }) page.actions.AddAction(copyFQDNAction) - sendFileAction := gio.NewSimpleAction("sendFile", glib.NewVariantType("s")) - sendFileAction.ConnectActivate(func(p *glib.Variant) { + page.sendFileAction = gio.NewSimpleAction("sendFile", glib.NewVariantType("s")) + page.sendFileAction.ConnectActivate(func(p *glib.Variant) { dialog := gtk.NewFileDialog() dialog.SetModal(true) @@ -125,7 +127,7 @@ func (page *PeerPage) init(a *App, status *tsutil.IPNStatus, peer tailcfg.NodeVi } }) }) - page.actions.AddAction(sendFileAction) + page.actions.AddAction(page.sendFileAction) page.Page.AddController(page.DropTarget) page.DropTarget.SetGTypes([]glib.Type{gio.GTypeFile}) @@ -258,6 +260,8 @@ func (page *PeerPage) Update(s tsutil.Status) bool { return false } + page.sendFileAction.SetEnabled(status.FileTargets.Contains(page.peer.StableID())) + online := page.peer.Online().Get() exitNodeOption := tsaddr.ContainsExitRoutes(page.peer.AllowedIPs()) exitNode := page.peer.Equal(status.ExitNode()) From 0e10d35b55014254e3846bb70ffe53731733299d Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 22:55:42 -0400 Subject: [PATCH 19/38] internal/ui: select a new row manually if the current page has been removed --- internal/ui/mainwindow.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/ui/mainwindow.go b/internal/ui/mainwindow.go index b90a314..738515e 100644 --- a/internal/ui/mainwindow.go +++ b/internal/ui/mainwindow.go @@ -186,8 +186,17 @@ func (win *MainWindow) addPage(name string, page Page) *adw.ViewStackPage { } func (win *MainWindow) removePage(name string, page Page) { + var reselect bool + if win.PeersStack.VisibleChildName() == name { + reselect = true + } + delete(win.pages, name) win.PeersStack.Remove(page.Widget()) + + if reselect { + win.PeersList.SelectRow(win.PeersList.RowAtIndex(0)) + } } func (win *MainWindow) Update(status tsutil.Status) { From 7767682901a4d49af059c7b69a410f0bdddfaf3e Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 23:00:40 -0400 Subject: [PATCH 20/38] internal/ui: fix toggling of exit node from tray icon --- internal/ui/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 975d1dc..c784c92 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -314,7 +314,7 @@ func (a *App) initTray(ctx context.Context) { defer cancel() s := <-a.poller.GetIPN() - toggle := s.ExitNodeActive() + toggle := !s.ExitNodeActive() err := tsutil.SetUseExitNode(ctx, toggle) if err != nil { a.notify("Toggle exit node", err.Error()) From 820784009c59cd1c4d6720c71412db855051902e Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 23:09:10 -0400 Subject: [PATCH 21/38] internal/ui: do an update immediately when opening the window --- internal/ui/app.go | 1 + internal/ui/selfpage.go | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index c784c92..8b81f57 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -269,6 +269,7 @@ func (a *App) onAppActivate(ctx context.Context) { }) <-a.poller.Poll() + a.update(<-a.poller.GetIPN()) a.win.MainWindow.Present() } diff --git a/internal/ui/selfpage.go b/internal/ui/selfpage.go index cb6c680..f24de67 100644 --- a/internal/ui/selfpage.go +++ b/internal/ui/selfpage.go @@ -393,7 +393,7 @@ func (page *SelfPage) Init(row *PageRow) { func (page *SelfPage) Update(status tsutil.Status) bool { switch status := status.(type) { case *tsutil.IPNStatus: - return page.UpdateNet(status) + return page.UpdateIPN(status) case *tsutil.FileStatus: return page.UpdateFiles(status) default: @@ -401,7 +401,7 @@ func (page *SelfPage) Update(status tsutil.Status) bool { } } -func (page *SelfPage) UpdateNet(status *tsutil.IPNStatus) bool { +func (page *SelfPage) UpdateIPN(status *tsutil.IPNStatus) bool { if !status.Online() { return false } From ee12cef562d72d8a015285dc96f0a6a76f1165eb Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 23:11:56 -0400 Subject: [PATCH 22/38] internal/tsutil: add a backup timeout to the peer info builder --- internal/tsutil/poller.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/tsutil/poller.go b/internal/tsutil/poller.go index d877e5c..864ec12 100644 --- a/internal/tsutil/poller.go +++ b/internal/tsutil/poller.go @@ -261,6 +261,13 @@ func (s IPNStatus) copy() *IPNStatus { } func (s *IPNStatus) rebuildPeers(ctx context.Context) { + // This is a lot longer than it probably should be. It's basically + // just to make sure that the poller doesn't get completely stuck. If + // this is getting hit, though, the UI is going to be updating + // horribly slow. + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if s.Peers == nil { mk.Map(&s.Peers, 0) } From bac9bd4e7614b10b3ba98bef475272d22a260e71 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 23:16:04 -0400 Subject: [PATCH 23/38] cmd/trayscale: regenerate `default.pgo` --- cmd/trayscale/default.pgo | Bin 24028 -> 29939 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/cmd/trayscale/default.pgo b/cmd/trayscale/default.pgo index f4666aa92c1a208b0f7f250bc95b92016049c895..a86bf675895f4cced103e6f093150bea31cf36c5 100644 GIT binary patch literal 29939 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�O~GpTJRhU)T) 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 literal 24028 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�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 Date: Thu, 22 May 2025 23:19:00 -0400 Subject: [PATCH 24/38] internal/ui: schedule update when opening window instead of doing it immediately --- internal/ui/app.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 8b81f57..7b871ab 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -269,8 +269,11 @@ func (a *App) onAppActivate(ctx context.Context) { }) <-a.poller.Poll() - a.update(<-a.poller.GetIPN()) a.win.MainWindow.Present() + + glib.IdleAdd(func() { + a.update(<-a.poller.GetIPN()) + }) } func (a *App) initTray(ctx context.Context) { From b7056f5ea8162d6940f7fb54d314ac21a2602468 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 23:32:33 -0400 Subject: [PATCH 25/38] internal/tsutil: fix some crash bugs and log `IPNBusWatcher` errors --- internal/tray/tray.go | 7 ++----- internal/tsutil/poller.go | 17 +++++++++++++---- internal/ui/app.go | 5 +++-- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/internal/tray/tray.go b/internal/tray/tray.go index 20c37ff..f58b7f3 100644 --- a/internal/tray/tray.go +++ b/internal/tray/tray.go @@ -141,11 +141,8 @@ func statusIcon(s *tsutil.IPNStatus) *tray.Pixmap { } func selfTitle(s *tsutil.IPNStatus) (string, bool) { - addr, ok := s.SelfAddr() - if !ok { - if s.NetMap.SelfNode.Addresses().Len() == 0 { - return "Address unknown", false - } + addr := s.SelfAddr() + if !addr.IsValid() { return "Not connected", false } diff --git a/internal/tsutil/poller.go b/internal/tsutil/poller.go index 864ec12..1127bf8 100644 --- a/internal/tsutil/poller.go +++ b/internal/tsutil/poller.go @@ -136,6 +136,14 @@ watch: continue } + if notify.ErrMessage != nil { + var state ipn.State + if notify.State != nil { + state = *notify.State + } + slog.Error("watcher got error message", "state", state, "err", notify.ErrMessage) + } + var dirty bool if notify.State != nil { s.State = *notify.State @@ -155,6 +163,7 @@ watch: dirty = true } if notify.BrowseToURL != nil { + slog.Info("browse to URL", "url", *notify.BrowseToURL) s.BrowseToURL = *notify.BrowseToURL dirty = true } @@ -323,13 +332,13 @@ func (s *IPNStatus) OperatorIsCurrent() bool { return s.Prefs.OperatorUser() == current.Username } -func (s *IPNStatus) SelfAddr() (netip.Addr, bool) { - if s.NetMap.SelfNode.Addresses().Len() == 0 { - return netip.Addr{}, false +func (s *IPNStatus) SelfAddr() netip.Addr { + if s.NetMap == nil || s.NetMap.SelfNode.Addresses().Len() == 0 { + return netip.Addr{} } // TODO: Don't copy the slice. - return slices.MinFunc(s.NetMap.SelfNode.Addresses().AsSlice(), xnetip.ComparePrefixes).Addr(), true + return slices.MinFunc(s.NetMap.SelfNode.Addresses().AsSlice(), xnetip.ComparePrefixes).Addr() } type FileStatus struct { diff --git a/internal/ui/app.go b/internal/ui/app.go index 7b871ab..e7d4bc2 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -177,6 +177,7 @@ func (a *App) startTS(ctx context.Context) error { Reject: "_Cancel", }.Show(a, func(accept bool) { if accept { + slog.Info("auth", "url", status.BrowseToURL) gtk.NewURILauncher(status.BrowseToURL).Launch(ctx, &a.win.MainWindow.Window, nil) } }) @@ -338,8 +339,8 @@ func (a *App) initTray(ctx context.Context) { OnSelfNode: func() { glib.IdleAdd(func() { s := <-a.poller.GetIPN() - addr, ok := s.SelfAddr() - if !ok { + addr := s.SelfAddr() + if !addr.IsValid() { return } a.clip(glib.NewValue(addr.String())) From 645b423c9e260bdd094f086ca717dbe6a63f2b38 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Thu, 22 May 2025 23:35:12 -0400 Subject: [PATCH 26/38] internal/tsutil: add a TODO --- internal/tsutil/poller.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/tsutil/poller.go b/internal/tsutil/poller.go index 1127bf8..1781588 100644 --- a/internal/tsutil/poller.go +++ b/internal/tsutil/poller.go @@ -167,6 +167,7 @@ watch: s.BrowseToURL = *notify.BrowseToURL dirty = true } + // TODO: Handle health warnings. if !dirty { continue } From 2911d889a5ff2d4f5bf2a71317d302f052d38715 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Fri, 23 May 2025 00:15:27 -0400 Subject: [PATCH 27/38] internal/tsutil: add mechanism for waiting for an IPN update and add `StartLogin()` --- internal/tsutil/client.go | 4 ++++ internal/tsutil/poller.go | 21 +++++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/internal/tsutil/client.go b/internal/tsutil/client.go index 709df25..6e39548 100644 --- a/internal/tsutil/client.go +++ b/internal/tsutil/client.go @@ -255,3 +255,7 @@ func GetProfileStatus(ctx context.Context) (ipn.LoginProfile, []ipn.LoginProfile func SwitchProfile(ctx context.Context, id ipn.ProfileID) error { return localClient.SwitchProfile(ctx, id) } + +func StartLogin(ctx context.Context) error { + return localClient.StartLoginInteractive(ctx) +} diff --git a/internal/tsutil/poller.go b/internal/tsutil/poller.go index 1781588..83f180e 100644 --- a/internal/tsutil/poller.go +++ b/internal/tsutil/poller.go @@ -42,6 +42,7 @@ type Poller struct { poll chan struct{} getIPN chan *IPNStatus + nextIPN chan *IPNStatus interval chan time.Duration } @@ -49,6 +50,7 @@ func (p *Poller) init() { p.once.Do(func() { mk.Chan(&p.poll, 0) mk.Chan(&p.getIPN, 0) + mk.Chan(&p.nextIPN, 0) mk.Chan(&p.interval, 0) }) } @@ -163,7 +165,6 @@ watch: dirty = true } if notify.BrowseToURL != nil { - slog.Info("browse to URL", "url", *notify.BrowseToURL) s.BrowseToURL = *notify.BrowseToURL dirty = true } @@ -172,10 +173,15 @@ watch: continue } + c := s.copy() select { case <-ctx.Done(): return - case set <- s.copy(): + case set <- c: + } + select { + case p.nextIPN <- c: + default: } } } @@ -243,6 +249,17 @@ func (p *Poller) GetIPN() <-chan *IPNStatus { return p.getIPN } +// NextIPN returns a channel that is sent the new IPNStatus each time +// it is available if anyone is receiving from it. Unlike [GetIPN], +// this channel does not yield the previous status, so it is useful if +// an update is expected to arrive soon. Most usages should use +// [GetIPN] instead as it significantly faster. +func (p *Poller) NextIPN() <-chan *IPNStatus { + p.init() + + return p.nextIPN +} + // SetInterval returns a channel that modifies the polling interval of // a running poller. This will delay the next poll until the new // interval has elapsed. From 2c21baee5fdcdb91533c519d322b6e76d320059c Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Fri, 23 May 2025 00:16:00 -0400 Subject: [PATCH 28/38] internal/ui: fix browser login problem --- internal/ui/app.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index e7d4bc2..e88468f 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -177,7 +177,19 @@ func (a *App) startTS(ctx context.Context) error { Reject: "_Cancel", }.Show(a, func(accept bool) { if accept { - slog.Info("auth", "url", status.BrowseToURL) + ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second) + defer cancel() + + err := tsutil.StartLogin(ctx) + if err != nil { + slog.Error("failed to start login", "err", err) + if a.win != nil { + a.win.Toast("Failed to start login") + } + return + } + + status := <-a.poller.NextIPN() gtk.NewURILauncher(status.BrowseToURL).Launch(ctx, &a.win.MainWindow.Window, nil) } }) From ad932876d197be0bba6b03cd6ed7712ae97a774a Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Fri, 23 May 2025 00:45:32 -0400 Subject: [PATCH 29/38] internal/ui: make everything that can start Tailscale cause authentication --- internal/ui/app.go | 50 +++++++++++++++++++++++++++----------- internal/ui/mainwindow.go | 7 +++++- internal/ui/offlinepage.go | 16 ++++++------ internal/ui/offlinepage.ui | 26 ++++++++++++++++++++ internal/ui/trayscale.cmb | 1 + 5 files changed, 78 insertions(+), 22 deletions(-) create mode 100644 internal/ui/offlinepage.ui diff --git a/internal/ui/app.go b/internal/ui/app.go index e88468f..76d7eb0 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -177,20 +177,7 @@ func (a *App) startTS(ctx context.Context) error { Reject: "_Cancel", }.Show(a, func(accept bool) { if accept { - ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second) - defer cancel() - - err := tsutil.StartLogin(ctx) - if err != nil { - slog.Error("failed to start login", "err", err) - if a.win != nil { - a.win.Toast("Failed to start login") - } - return - } - - status := <-a.poller.NextIPN() - gtk.NewURILauncher(status.BrowseToURL).Launch(ctx, &a.win.MainWindow.Window, nil) + a.app.ActivateAction("login", nil) } }) return nil @@ -275,6 +262,41 @@ func (a *App) onAppActivate(ctx context.Context) { a.app.AddAction(quitAction) a.app.SetAccelsForAction("app.quit", []string{"q"}) + loginAction := gio.NewSimpleAction("login", nil) + loginAction.ConnectActivate(func(p *glib.Variant) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + err := tsutil.StartLogin(ctx) + if err != nil { + slog.Error("failed to start login", "err", err) + if a.win != nil { + a.win.Toast("Failed to start login") + } + return + } + + for { + select { + case <-ctx.Done(): + if a.win != nil { + a.win.Toast("Failed to start login") + } + return + case status := <-a.poller.NextIPN(): + if status.BrowseToURL != "" { + var win *gtk.Window + if a.win != nil { + win = &a.win.MainWindow.Window + } + gtk.NewURILauncher(status.BrowseToURL).Launch(ctx, win, nil) + return + } + } + } + }) + a.app.AddAction(loginAction) + a.win = NewMainWindow(a) a.win.MainWindow.ConnectCloseRequest(func() bool { a.win = nil diff --git a/internal/ui/mainwindow.go b/internal/ui/mainwindow.go index 738515e..5d9098d 100644 --- a/internal/ui/mainwindow.go +++ b/internal/ui/mainwindow.go @@ -150,7 +150,12 @@ func NewMainWindow(app *App) *MainWindow { }) win.ProfileDropDown.NotifyProperty("selected-item", func() { - item := win.ProfileDropDown.SelectedItem().Cast().(*gtk.StringObject).String() + obj, ok := win.ProfileDropDown.SelectedItem().Cast().(*gtk.StringObject) + if !ok { + return + } + + item := obj.String() index := slices.IndexFunc(win.profiles, func(p ipn.LoginProfile) bool { // TODO: Find a reasonable way to do this by profile ID instead. return p.Name == item diff --git a/internal/ui/offlinepage.go b/internal/ui/offlinepage.go index 72ac0cb..bacd913 100644 --- a/internal/ui/offlinepage.go +++ b/internal/ui/offlinepage.go @@ -1,26 +1,27 @@ package ui import ( + _ "embed" + "deedles.dev/trayscale/internal/tsutil" "github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4/pkg/gio/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" ) +//go:embed offlinepage.ui +var offlinePageXML string + type OfflinePage struct { app *App - Page *adw.StatusPage + Page *adw.StatusPage + NeedsAuthGroup *adw.PreferencesGroup } func NewOfflinePage(app *App) *OfflinePage { page := OfflinePage{app: app} - - page.Page = adw.NewStatusPage() - page.Page.SetTitle("Not Connected") - page.Page.SetIconName("network-offline-symbolic") - page.Page.SetDescription("Tailscale is not connected") - + fillFromBuilder(&page, offlinePageXML) return &page } @@ -39,6 +40,7 @@ func (page *OfflinePage) Init(row *PageRow) { func (page *OfflinePage) Update(status tsutil.Status) bool { if status, ok := status.(*tsutil.IPNStatus); ok { + page.NeedsAuthGroup.SetVisible(status.NeedsAuth()) return !status.Online() } return true diff --git a/internal/ui/offlinepage.ui b/internal/ui/offlinepage.ui new file mode 100644 index 0000000..075b759 --- /dev/null +++ b/internal/ui/offlinepage.ui @@ -0,0 +1,26 @@ + + + + + + + + + + Tailscale must be authenticated before it can connect. + Login Required + + + app.login + Open Browser to Login + + + + + + + Tailscale is not connected + network-offline-symbolic + Not Connected + + diff --git a/internal/ui/trayscale.cmb b/internal/ui/trayscale.cmb index 266ff8e..9617871 100644 --- a/internal/ui/trayscale.cmb +++ b/internal/ui/trayscale.cmb @@ -8,4 +8,5 @@ + From 8714e1445006246ea258b33a100b9f6f2b3fda78 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Fri, 23 May 2025 00:58:53 -0400 Subject: [PATCH 30/38] internal/ui: explain operator situation to user when trying to login --- internal/ui/app.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 76d7eb0..5702f26 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -91,13 +91,7 @@ func (a *App) update(status tsutil.Status) { a.notify("Tailscale Status", body) // TODO: Notify on startup if not connected? } - if a.win == nil { - return - } - - a.win.Update(status) - - if a.online && !a.operatorCheck { + if online && !a.operatorCheck { a.operatorCheck = true if !status.OperatorIsCurrent() { Info{ @@ -107,6 +101,10 @@ func (a *App) update(status tsutil.Status) { } } + if a.win != nil { + a.win.Update(status) + } + case *tsutil.FileStatus: if a.files != nil { for _, file := range status.Files { @@ -264,6 +262,15 @@ func (a *App) onAppActivate(ctx context.Context) { loginAction := gio.NewSimpleAction("login", nil) loginAction.ConnectActivate(func(p *glib.Variant) { + status := <-a.poller.GetIPN() + if !status.OperatorIsCurrent() { + Info{ + Heading: "User is not Tailscale Operator", + Body: "Login via Trayscale is not possible unless the current user is set as the operator. To resolve, run\nsudo tailscale set --operator=$USER\nin the command-line.", + }.Show(a, nil) + return + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() From 5a2e8e39d39bb4b7ae3f68ea9fd66d2f6db767ee Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Fri, 23 May 2025 01:00:07 -0400 Subject: [PATCH 31/38] internal/ui: remove a TODO --- internal/ui/app.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 5702f26..ec771e6 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -206,7 +206,6 @@ func (a *App) onAppOpen(ctx context.Context, files []gio.Filer) { return } options := func(yield func(selectOption) bool) { - // TODO: Only show nodes that can receive files. for _, peer := range s.Peers { if !s.FileTargets.Contains(peer.StableID()) || tsutil.IsMullvad(peer) { continue From f17ab31b586d7f7437408a76a359ea5da4fe4ec1 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Fri, 23 May 2025 01:12:15 -0400 Subject: [PATCH 32/38] internal/ui: fix discrepency in usage of `(*App).window()` --- internal/ui/app.go | 6 +----- internal/ui/dialogs.go | 12 ++++++------ internal/ui/ui.go | 10 ++++++++++ 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index ec771e6..1b46c10 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -291,11 +291,7 @@ func (a *App) onAppActivate(ctx context.Context) { return case status := <-a.poller.NextIPN(): if status.BrowseToURL != "" { - var win *gtk.Window - if a.win != nil { - win = &a.win.MainWindow.Window - } - gtk.NewURILauncher(status.BrowseToURL).Launch(ctx, win, nil) + gtk.NewURILauncher(status.BrowseToURL).Launch(ctx, a.window(), nil) return } } diff --git a/internal/ui/dialogs.go b/internal/ui/dialogs.go index 72c6b3d..65075e7 100644 --- a/internal/ui/dialogs.go +++ b/internal/ui/dialogs.go @@ -5,7 +5,7 @@ import ( "github.com/diamondburned/gotk4/pkg/gtk/v4" ) -func (a *App) window() gtk.Widgetter { +func (a *App) window() *gtk.Window { if a == nil { return nil } @@ -13,7 +13,7 @@ func (a *App) window() gtk.Widgetter { return nil } - return a.win.MainWindow + return &a.win.MainWindow.Window } type Confirmation struct { @@ -35,7 +35,7 @@ func (d Confirmation) Show(a *App, res func(bool)) { res(response == "accept") }) - dialog.Present(a.window()) + dialog.Present(pointerToWidgetter(a.window())) } type Prompt struct { @@ -80,7 +80,7 @@ func (d Prompt) Show(a *App, initialValue string, res func(response, val string) res(def, input.Text()) }) - dialog.Present(a.window()) + dialog.Present(pointerToWidgetter(a.window())) } type Info struct { @@ -100,7 +100,7 @@ func (d Info) Show(a *App, closed func()) { }) } - dialog.Present(a.window()) + dialog.Present(pointerToWidgetter(a.window())) } type Select[T any] struct { @@ -156,5 +156,5 @@ func (d Select[T]) Show(a *App, res func([]SelectOption[T])) { res(selected) }) - dialog.Present(a.window()) + dialog.Present(pointerToWidgetter(a.window())) } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 9b47654..dd44309 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -153,6 +153,16 @@ func expanderRowListBox(row *adw.ExpanderRow) *gtk.ListBox { panic("ExpanderRow ListBox not found") } +func pointerToWidgetter[T any, P interface { + gtk.Widgetter + *T +}](p P) gtk.Widgetter { + if p == nil { + return nil + } + return p +} + func NewObjectComparer[T any](f func(T, T) int) glib.CompareDataFunc { return glib.NewObjectComparer(func(o1, o2 *glib.Object) int { v1 := listmodels.Convert[T](o1) From d4679883cd011e72393cea6104178e3aeca2b360 Mon Sep 17 00:00:00 2001 From: DeedleFake Date: Fri, 23 May 2025 01:19:10 -0400 Subject: [PATCH 33/38] meta: add v0.18.0 to metainfo --- dev.deedles.Trayscale.metainfo.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dev.deedles.Trayscale.metainfo.xml b/dev.deedles.Trayscale.metainfo.xml index 6d16ef8..989c3dc 100644 --- a/dev.deedles.Trayscale.metainfo.xml +++ b/dev.deedles.Trayscale.metainfo.xml @@ -54,6 +54,16 @@ + + +
    +
  • Overhaul internal polling mechanism. Most time-sensative updates now use the IPN bus watcher instead of polling manually. The UI should now update based on changing daemon state a lot faster.
  • +
  • Fix logging in via a browser.
  • +
  • Add login button to offline page when not logged in.
  • +
  • Update Tailscale client to v1.84.0.
  • +
+
+