diff --git a/cmd/trayscale/default.pgo b/cmd/trayscale/default.pgo
deleted file mode 100644
index f258dffe..00000000
Binary files a/cmd/trayscale/default.pgo and /dev/null differ
diff --git a/cmd/trayscale/trayscale.go b/cmd/trayscale/trayscale.go
index d8f86a69..d04506f3 100644
--- a/cmd/trayscale/trayscale.go
+++ b/cmd/trayscale/trayscale.go
@@ -7,7 +7,7 @@ import (
"os/signal"
"runtime/pprof"
- "deedles.dev/trayscale/internal/ui"
+ "deedles.dev/trayscale/internal/trayscale"
)
func profile() func() {
@@ -45,6 +45,6 @@ func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
- var a ui.App
- a.Run(ctx)
+ var app trayscale.App
+ app.Run(ctx)
}
diff --git a/go.mod b/go.mod
index 39cc518c..d0be5ac5 100644
--- a/go.mod
+++ b/go.mod
@@ -6,12 +6,12 @@ require (
deedles.dev/mk v0.1.0
deedles.dev/tray v0.1.9
deedles.dev/xiter v0.2.1
+ github.com/atotto/clipboard v0.1.4
github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250310094704-65bb91d1403f
github.com/diamondburned/gotk4/pkg v0.3.1
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf
github.com/klauspost/compress v1.18.0
github.com/stretchr/testify v1.10.0
- golang.org/x/net v0.40.0
tailscale.com v1.84.0
)
@@ -86,6 +86,7 @@ require (
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
golang.org/x/exp/typeparams v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/mod v0.24.0 // indirect
+ golang.org/x/net v0.40.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
diff --git a/go.sum b/go.sum
index 890381d9..6cbbdbf7 100644
--- a/go.sum
+++ b/go.sum
@@ -20,6 +20,8 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V
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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
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=
diff --git a/internal/ctxutil/ctxutil.go b/internal/ctxutil/ctxutil.go
new file mode 100644
index 00000000..2756c1c1
--- /dev/null
+++ b/internal/ctxutil/ctxutil.go
@@ -0,0 +1,12 @@
+package ctxutil
+
+import "context"
+
+func Recv[T any, C ~<-chan T](ctx context.Context, c C) (v T, ok bool) {
+ select {
+ case <-ctx.Done():
+ return v, false
+ case v, ok := <-c:
+ return v, ok
+ }
+}
diff --git a/internal/tray/tray.go b/internal/tray/tray.go
index f58b7f32..71001043 100644
--- a/internal/tray/tray.go
+++ b/internal/tray/tray.go
@@ -5,6 +5,7 @@ import (
_ "embed"
"fmt"
"image/png"
+ "sync"
"deedles.dev/tray"
"deedles.dev/trayscale/internal/tsutil"
@@ -46,6 +47,7 @@ type Tray struct {
OnSelfNode func()
OnQuit func()
+ m sync.RWMutex
item *tray.Item
icon *tray.Pixmap
@@ -56,7 +58,10 @@ type Tray struct {
quitItem *tray.MenuItem
}
-func (t *Tray) Start(s *tsutil.IPNStatus) error {
+func (t *Tray) Start(status *tsutil.IPNStatus) error {
+ t.m.Lock()
+ defer t.m.Unlock()
+
if t.item != nil {
return nil
}
@@ -84,13 +89,16 @@ func (t *Tray) Start(s *tsutil.IPNStatus) error {
menu.AddChild(tray.MenuItemType(tray.Separator))
t.quitItem, _ = menu.AddChild(tray.MenuItemLabel("Quit"), handler(t.OnQuit))
- t.Update(s)
+ t.update(status)
return nil
}
func (t *Tray) Close() error {
- if t == nil || t.item == nil {
+ t.m.Lock()
+ defer t.m.Unlock()
+
+ if t.item == nil {
return nil
}
@@ -100,8 +108,20 @@ func (t *Tray) Close() error {
return err
}
-func (t *Tray) Update(status *tsutil.IPNStatus) {
- if t == nil || t.item == nil {
+func (t *Tray) Update(s tsutil.Status) {
+ status, ok := s.(*tsutil.IPNStatus)
+ if !ok {
+ return
+ }
+
+ t.m.RLock()
+ defer t.m.RUnlock()
+
+ t.update(status)
+}
+
+func (t *Tray) update(status *tsutil.IPNStatus) {
+ if t.item == nil {
return
}
diff --git a/internal/trayscale/trayscale.go b/internal/trayscale/trayscale.go
new file mode 100644
index 00000000..8e0e9d86
--- /dev/null
+++ b/internal/trayscale/trayscale.go
@@ -0,0 +1,139 @@
+package trayscale
+
+import (
+ "context"
+ "log/slog"
+ "os"
+ "runtime"
+ "time"
+
+ "deedles.dev/trayscale/internal/ctxutil"
+ "deedles.dev/trayscale/internal/tray"
+ "deedles.dev/trayscale/internal/tsutil"
+ "deedles.dev/trayscale/internal/ui"
+ "github.com/atotto/clipboard"
+)
+
+type App struct {
+ poller *tsutil.Poller
+ tray *tray.Tray
+ app *ui.App
+}
+
+func (app *App) Run(ctx context.Context) {
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ context.AfterFunc(ctx, app.Quit)
+
+ app.poller = &tsutil.Poller{
+ Interval: 5 * time.Second,
+ New: app.Update,
+ }
+
+ app.tray = &tray.Tray{
+ OnShow: app.app.ShowWindow,
+ OnConnToggle: func() { app.toggleConn(ctx) },
+ OnExitToggle: func() { app.toggleExit(ctx) },
+ OnSelfNode: func() { app.copySelf(ctx) },
+ OnQuit: app.Quit,
+ }
+
+ app.app = ui.NewApp(app)
+ defer app.app.Unref()
+
+ go app.poller.Run(ctx)
+
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ app.app.Run(os.Args)
+}
+
+func (app *App) Quit() {
+ app.app.Quit()
+}
+
+func (app *App) Update(status tsutil.Status) {
+ app.tray.Update(status)
+ app.app.Update(status)
+}
+
+func (app *App) toggleConn(ctx context.Context) {
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+
+ status, ok := ctxutil.Recv(ctx, app.poller.GetIPN())
+ if !ok {
+ return
+ }
+
+ f := tsutil.Start
+ if status.Online() {
+ f = tsutil.Stop
+ }
+
+ err := f(ctx)
+ if err != nil {
+ slog.Error("failed to toggle Tailscale", "source", "tray icon", "err", err)
+ return
+ }
+
+ ctxutil.Recv(ctx, app.poller.Poll())
+}
+
+func (app *App) toggleExit(ctx context.Context) {
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+
+ status, ok := ctxutil.Recv(ctx, app.poller.GetIPN())
+ if !ok {
+ return
+ }
+
+ exitNodeActive := status.ExitNodeActive()
+ err := tsutil.SetUseExitNode(ctx, !exitNodeActive)
+ if err != nil {
+ app.app.Notify("Toggle exit node", err.Error())
+ slog.Error("failed to toggle Tailscale", "source", "tray icon", "err", err)
+ return
+ }
+
+ if exitNodeActive {
+ app.app.Notify("Tailscale exit node", "Disabled")
+ return
+ }
+ app.app.Notify("Exit node", "Enabled")
+}
+
+func (app *App) copySelf(ctx context.Context) {
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+
+ status, ok := ctxutil.Recv(ctx, app.poller.GetIPN())
+ if !ok {
+ return
+ }
+
+ addr := status.SelfAddr()
+ if !addr.IsValid() {
+ slog.Error("self address was invalid")
+ return
+ }
+
+ err := clipboard.WriteAll(addr.String())
+ if err != nil {
+ slog.Error("failed to copy self address", "err", err)
+ app.app.Notify("Copy address to clipboard", err.Error())
+ return
+ }
+
+ app.app.Notify("Trayscale", "Copied address to clipboard")
+}
+
+func (app *App) Poller() *tsutil.Poller {
+ return app.poller
+}
+
+func (app *App) Tray() *tray.Tray {
+ return app.tray
+}
diff --git a/internal/ui.old/app.go b/internal/ui.old/app.go
new file mode 100644
index 00000000..24cf005c
--- /dev/null
+++ b/internal/ui.old/app.go
@@ -0,0 +1,436 @@
+package ui
+
+import (
+ "cmp"
+ "context"
+ _ "embed"
+ "fmt"
+ "log/slog"
+ "os"
+ "slices"
+ "time"
+
+ "deedles.dev/trayscale/internal/metadata"
+ "deedles.dev/trayscale/internal/tray"
+ "deedles.dev/trayscale/internal/tsutil"
+ "github.com/diamondburned/gotk4-adwaita/pkg/adw"
+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
+ "github.com/diamondburned/gotk4/pkg/gio/v2"
+ "github.com/diamondburned/gotk4/pkg/glib/v2"
+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
+ "github.com/inhies/go-bytesize"
+ "tailscale.com/client/tailscale/apitype"
+ "tailscale.com/tailcfg"
+)
+
+//go:embed app.css
+var appCSS string
+
+// App is the main type for the app, containing all of the state
+// necessary to run it.
+type App struct {
+ poller *tsutil.Poller
+ online bool
+
+ app *adw.Application
+ win *MainWindow
+ settings *gio.Settings
+ tray *tray.Tray
+
+ spinnum int
+ operatorCheck bool
+ files *[]apitype.WaitingFile
+}
+
+func (a *App) clip(v *glib.Value) {
+ gdk.DisplayGetDefault().Clipboard().Set(v)
+}
+
+func (a *App) notify(title, body string) {
+ icon, iconerr := gio.NewIconForString(metadata.AppID)
+
+ n := gio.NewNotification(title)
+ n.SetBody(body)
+ if iconerr == nil {
+ n.SetIcon(icon)
+ }
+
+ a.app.SendNotification("tailscale-status", n)
+}
+
+func (a *App) spin() {
+ glib.IdleAdd(func() {
+ a.spinnum++
+ if a.win != nil {
+ a.win.WorkSpinner.SetVisible(a.spinnum > 0)
+ }
+ })
+}
+
+func (a *App) stopSpin() {
+ glib.IdleAdd(func() {
+ a.spinnum--
+ if a.win != nil {
+ a.win.WorkSpinner.SetVisible(a.spinnum > 0)
+ }
+ })
+}
+
+func (a *App) update(status tsutil.Status) {
+ switch status := status.(type) {
+ case *tsutil.IPNStatus:
+ 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?
+ }
+
+ if 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)
+ }
+ }
+
+ if !online {
+ a.files = nil
+ }
+
+ if a.win != nil {
+ a.win.Update(status)
+ }
+
+ 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
+
+ if a.win != nil {
+ a.win.Update(status)
+ }
+
+ case *tsutil.ProfileStatus:
+ if a.win != nil {
+ a.win.Update(status)
+ }
+ }
+}
+
+func (a *App) init(ctx context.Context) {
+ gtk.Init()
+
+ a.app = adw.NewApplication(metadata.AppID, gio.ApplicationHandlesOpen)
+
+ css := gtk.NewCSSProvider()
+ css.LoadFromString(appCSS)
+ gtk.StyleContextAddProviderForDisplay(gdk.DisplayGetDefault(), css, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
+
+ var hideWindow bool
+ a.app.AddMainOption("hide-window", 0, glib.OptionFlagNone, glib.OptionArgNone, "Hide window on initial start", "")
+ a.app.ConnectHandleLocalOptions(func(options *glib.VariantDict) int {
+ if options.Contains("hide-window") {
+ hideWindow = true
+ }
+
+ return -1
+ })
+
+ a.app.ConnectOpen(func(files []gio.Filer, hint string) {
+ a.onAppOpen(ctx, files)
+ })
+
+ a.app.ConnectStartup(func() {
+ a.app.Hold()
+ })
+
+ a.app.ConnectActivate(func() {
+ if hideWindow {
+ hideWindow = false
+ return
+ }
+ a.onAppActivate(ctx)
+ })
+
+ a.initSettings(ctx)
+}
+
+func (a *App) startTS(ctx context.Context) error {
+ status := <-a.poller.GetIPN()
+ if status.NeedsAuth() {
+ Confirmation{
+ Heading: "Login Required",
+ Body: "Open a browser to authenticate with Tailscale?",
+ Accept: "_Open Browser",
+ Reject: "_Cancel",
+ }.Show(a, func(accept bool) {
+ if accept {
+ a.app.ActivateAction("login", nil)
+ }
+ })
+ return nil
+ }
+
+ err := tsutil.Start(ctx)
+ if err != nil {
+ return err
+ }
+ <-a.poller.Poll()
+ return nil
+}
+
+func (a *App) stopTS(ctx context.Context) error {
+ err := tsutil.Stop(ctx)
+ if err != nil {
+ return err
+ }
+ <-a.poller.Poll()
+ return nil
+}
+
+func (a *App) onAppOpen(ctx context.Context, files []gio.Filer) {
+ type selectOption = SelectOption[tailcfg.NodeView]
+
+ s := <-a.poller.GetIPN()
+ if !s.Online() {
+ return
+ }
+ options := func(yield func(selectOption) bool) {
+ for _, peer := range s.Peers {
+ if !s.FileTargets.Contains(peer.StableID()) || tsutil.IsMullvad(peer) {
+ continue
+ }
+
+ option := selectOption{
+ Title: peer.DisplayName(true),
+ Value: peer,
+ }
+ if !yield(option) {
+ return
+ }
+ }
+ }
+
+ Select[tailcfg.NodeView]{
+ Heading: "Send file(s) to...",
+ Options: slices.SortedFunc(options, func(o1, o2 selectOption) int {
+ return cmp.Compare(o1.Title, o2.Title)
+ }),
+ }.Show(a, func(options []selectOption) {
+ 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.StableID(), file)
+ }
+ }
+ })
+}
+
+func (a *App) onAppActivate(ctx context.Context) {
+ if a.win != nil {
+ a.win.MainWindow.Present()
+ return
+ }
+
+ changeControlServerAction := gio.NewSimpleAction("change_control_server", nil)
+ changeControlServerAction.ConnectActivate(func(p *glib.Variant) { a.showChangeControlServer() })
+ a.app.AddAction(changeControlServerAction)
+
+ preferencesAction := gio.NewSimpleAction("preferences", nil)
+ preferencesAction.ConnectActivate(func(p *glib.Variant) { a.showPreferences() })
+ a.app.AddAction(preferencesAction)
+
+ aboutAction := gio.NewSimpleAction("about", nil)
+ aboutAction.ConnectActivate(func(p *glib.Variant) { a.showAbout() })
+ a.app.AddAction(aboutAction)
+
+ quitAction := gio.NewSimpleAction("quit", nil)
+ quitAction.ConnectActivate(func(p *glib.Variant) { a.Quit() })
+ a.app.AddAction(quitAction)
+ a.app.SetAccelsForAction("app.quit", []string{"q"})
+
+ 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()
+
+ 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 != "" {
+ gtk.NewURILauncher(status.BrowseToURL).Launch(ctx, a.window(), nil)
+ return
+ }
+ }
+ }
+ })
+ a.app.AddAction(loginAction)
+
+ a.win = NewMainWindow(a)
+ a.win.MainWindow.ConnectCloseRequest(func() bool {
+ a.win = nil
+ return false
+ })
+
+ <-a.poller.Poll()
+ a.win.MainWindow.Present()
+
+ glib.IdleAdd(func() {
+ a.update(<-a.poller.GetIPN())
+ })
+}
+
+func (a *App) initTray(ctx context.Context) {
+ if a.tray != nil {
+ err := a.tray.Start(<-a.poller.GetIPN())
+ if err != nil {
+ slog.Error("failed to start tray icon", "err", err)
+ }
+ return
+ }
+
+ a.tray = &tray.Tray{
+ OnShow: func() {
+ glib.IdleAdd(func() {
+ if a.app != nil {
+ a.app.Activate()
+ }
+ })
+ },
+
+ OnConnToggle: func() {
+ glib.IdleAdd(func() {
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+
+ f := a.stopTS
+ if !a.online {
+ f = a.startTS
+ }
+
+ err := f(ctx)
+ if err != nil {
+ slog.Error("set Tailscale status from tray", "err", err)
+ return
+ }
+ })
+ },
+
+ OnExitToggle: func() {
+ glib.IdleAdd(func() {
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+
+ s := <-a.poller.GetIPN()
+ toggle := !s.ExitNodeActive()
+ err := tsutil.SetUseExitNode(ctx, toggle)
+ if err != nil {
+ a.notify("Toggle exit node", err.Error())
+ slog.Error("toggle exit node from tray", "err", err)
+ return
+ }
+
+ if toggle {
+ a.notify("Exit node", "Enabled")
+ return
+ }
+ a.notify("Exit node", "Disabled")
+ })
+ },
+
+ OnSelfNode: func() {
+ glib.IdleAdd(func() {
+ s := <-a.poller.GetIPN()
+ addr := s.SelfAddr()
+ if !addr.IsValid() {
+ return
+ }
+ a.clip(glib.NewValue(addr.String()))
+ if a.win != nil {
+ a.notify("Trayscale", "Copied address to clipboard")
+ }
+ })
+ },
+
+ OnQuit: func() {
+ a.Quit()
+ },
+ }
+
+ err := a.tray.Start(<-a.poller.GetIPN())
+ if err != nil {
+ slog.Error("failed to start tray icon", "err", err)
+ }
+}
+
+// Quit exits the app completely, causing Run to return.
+func (a *App) Quit() {
+ a.tray.Close()
+ a.app.Quit()
+}
+
+// Run runs the app, initializing everything and then entering the
+// main loop. It will return if either ctx is cancelled or Quit is
+// called.
+func (a *App) Run(ctx context.Context) {
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ a.init(ctx)
+
+ err := a.app.Register(ctx)
+ if err != nil {
+ slog.Error("register application", "err", err)
+ return
+ }
+
+ a.poller = &tsutil.Poller{
+ Interval: a.getInterval(),
+ New: func(s tsutil.Status) { glib.IdleAdd(func() { a.update(s) }) },
+ }
+ go a.poller.Run(ctx)
+
+ go func() {
+ <-ctx.Done()
+ a.Quit()
+ }()
+
+ a.app.Run(os.Args)
+}
diff --git a/internal/ui/dialogs.go b/internal/ui.old/dialogs.go
similarity index 100%
rename from internal/ui/dialogs.go
rename to internal/ui.old/dialogs.go
diff --git a/internal/ui/io.go b/internal/ui.old/io.go
similarity index 100%
rename from internal/ui/io.go
rename to internal/ui.old/io.go
diff --git a/internal/ui/mainwindow.go b/internal/ui.old/mainwindow.go
similarity index 100%
rename from internal/ui/mainwindow.go
rename to internal/ui.old/mainwindow.go
diff --git a/internal/ui/mullvadpage.go b/internal/ui.old/mullvadpage.go
similarity index 100%
rename from internal/ui/mullvadpage.go
rename to internal/ui.old/mullvadpage.go
diff --git a/internal/ui/offlinepage.go b/internal/ui.old/offlinepage.go
similarity index 100%
rename from internal/ui/offlinepage.go
rename to internal/ui.old/offlinepage.go
diff --git a/internal/ui/peerpage.go b/internal/ui.old/peerpage.go
similarity index 100%
rename from internal/ui/peerpage.go
rename to internal/ui.old/peerpage.go
diff --git a/internal/ui/preferences.go b/internal/ui.old/preferences.go
similarity index 100%
rename from internal/ui/preferences.go
rename to internal/ui.old/preferences.go
diff --git a/internal/ui/rowmanager.go b/internal/ui.old/rowmanager.go
similarity index 100%
rename from internal/ui/rowmanager.go
rename to internal/ui.old/rowmanager.go
diff --git a/internal/ui/selfpage.go b/internal/ui.old/selfpage.go
similarity index 100%
rename from internal/ui/selfpage.go
rename to internal/ui.old/selfpage.go
diff --git a/internal/ui/settings.go b/internal/ui.old/settings.go
similarity index 100%
rename from internal/ui/settings.go
rename to internal/ui.old/settings.go
diff --git a/internal/ui.old/ui.go b/internal/ui.old/ui.go
new file mode 100644
index 00000000..dd443091
--- /dev/null
+++ b/internal/ui.old/ui.go
@@ -0,0 +1,227 @@
+package ui
+
+import (
+ "cmp"
+ "errors"
+ "iter"
+ "net/netip"
+ "reflect"
+ "time"
+
+ "deedles.dev/trayscale/internal/listmodels"
+ "deedles.dev/trayscale/internal/tsutil"
+ "deedles.dev/trayscale/internal/xnetip"
+ "github.com/diamondburned/gotk4-adwaita/pkg/adw"
+ "github.com/diamondburned/gotk4/pkg/core/gerror"
+ "github.com/diamondburned/gotk4/pkg/gio/v2"
+ "github.com/diamondburned/gotk4/pkg/glib/v2"
+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
+ "tailscale.com/client/tailscale/apitype"
+ "tailscale.com/types/opt"
+)
+
+var (
+ addrSorter = gtk.NewCustomSorter(NewObjectComparer(netip.Addr.Compare))
+ prefixSorter = gtk.NewCustomSorter(NewObjectComparer(xnetip.ComparePrefixes))
+ waitingFileSorter = gtk.NewCustomSorter(NewObjectComparer(func(f1, f2 apitype.WaitingFile) int {
+ return cmp.Or(
+ cmp.Compare(f1.Name, f2.Name),
+ cmp.Compare(f1.Size, f2.Size),
+ )
+ }))
+
+ stringListSorter = gtk.NewCustomSorter(glib.NewObjectComparer(func(s1, s2 *gtk.StringObject) int {
+ return cmp.Compare(s1.String(), s2.String())
+ }))
+)
+
+func prioritize[T comparable](target, v1, v2 T) (int, bool) {
+ if v1 == target {
+ if v1 == v2 {
+ return 0, true
+ }
+ return -1, true
+ }
+ if v2 == target {
+ return 1, true
+ }
+ return 0, false
+}
+
+func formatTime(t time.Time) string {
+ if t.IsZero() {
+ return ""
+ }
+ return t.Format(time.StampMilli)
+}
+
+func boolIcon(v bool) string {
+ if v {
+ return "emblem-ok-symbolic"
+ }
+ return "window-close-symbolic"
+}
+
+func optBoolIcon(v opt.Bool) string {
+ b, ok := v.Get()
+ if !ok {
+ return "dialog-question-symbolic"
+ }
+ return boolIcon(b)
+}
+
+func fillObjects(dst any, builder *gtk.Builder) {
+ v := reflect.ValueOf(dst).Elem()
+ t := v.Type()
+
+ for i := range t.NumField() {
+ fv := v.Field(i)
+ ft := t.Field(i)
+
+ name := ft.Name
+ if tag, ok := ft.Tag.Lookup("gtk"); ok {
+ if tag == "-" {
+ continue
+ }
+ name = tag
+ }
+ obj := builder.GetObject(name)
+ if obj == nil {
+ continue
+ }
+
+ fv.Set(reflect.ValueOf(obj.Cast()))
+ }
+}
+
+func fillFromBuilder(into any, xml ...string) {
+ builder := gtk.NewBuilder()
+ for _, v := range xml {
+ builder.AddFromString(v)
+ }
+
+ fillObjects(into, builder)
+}
+
+func errHasCode(err error, code int) bool {
+ var gerr *gerror.GError
+ if !errors.As(err, &gerr) {
+ return false
+ }
+ return gerr.ErrorCode() == code
+}
+
+type widgetParent interface {
+ FirstChild() gtk.Widgetter
+}
+
+func widgetChildren(w widgetParent) iter.Seq[gtk.Widgetter] {
+ return func(yield func(gtk.Widgetter) bool) {
+ widgetChildrenPush(yield, w)
+ }
+}
+
+func widgetChildrenPush(yield func(gtk.Widgetter) bool, w widgetParent) bool {
+ type siblingNexter interface{ NextSibling() gtk.Widgetter }
+
+ cur := w.FirstChild()
+ for cur != nil {
+ if !yield(cur) {
+ return false
+ }
+ if !widgetChildrenPush(yield, cur.(widgetParent)) {
+ return false
+ }
+
+ cur = cur.(siblingNexter).NextSibling()
+ }
+
+ return true
+}
+
+func expanderRowListBox(row *adw.ExpanderRow) *gtk.ListBox {
+ type caster interface{ Cast() glib.Objector }
+ for child := range widgetChildren(row) {
+ if r, ok := child.(caster).Cast().(*gtk.Revealer); ok {
+ for child := range widgetChildren(r) {
+ if box, ok := child.(caster).Cast().(*gtk.ListBox); ok {
+ return box
+ }
+ }
+ }
+ }
+ 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)
+ v2 := listmodels.Convert[T](o2)
+ return f(v1, v2)
+ })
+}
+
+// Page represents the UI for a single page of the app. This usually
+// corresponds to information about a specific peer in the tailnet.
+type Page interface {
+ Widget() gtk.Widgetter
+ Actions() gio.ActionGrouper
+
+ Init(*PageRow)
+ Update(tsutil.Status) bool
+}
+
+type PageRow struct {
+ page *adw.ViewStackPage
+ row *adw.ActionRow
+ icon *gtk.Image
+}
+
+func NewPageRow(page *adw.ViewStackPage) *PageRow {
+ icon := gtk.NewImage()
+ icon.NotifyProperty("icon-name", func() {
+ page.SetIconName(icon.IconName())
+ })
+
+ row := adw.NewActionRow()
+ row.AddPrefix(icon)
+ row.NotifyProperty("title", func() {
+ page.SetTitle(row.Title())
+ })
+
+ return &PageRow{
+ page: page,
+ row: row,
+ icon: icon,
+ }
+}
+
+func (row *PageRow) Page() *adw.ViewStackPage {
+ return row.page
+}
+
+func (row *PageRow) Row() *adw.ActionRow {
+ return row.row
+}
+
+func (row *PageRow) SetTitle(title string) {
+ row.row.SetTitle(title)
+}
+
+func (row *PageRow) SetSubtitle(subtitle string) {
+ row.row.SetSubtitle(subtitle)
+}
+
+func (row *PageRow) SetIconName(icon string) {
+ row.icon.SetFromIconName(icon)
+}
diff --git a/internal/ui/app.c b/internal/ui/app.c
new file mode 100644
index 00000000..78e04fac
--- /dev/null
+++ b/internal/ui/app.c
@@ -0,0 +1,165 @@
+//#include
+//#include "ui.h"
+//
+//static GIcon *notification_icon = NULL;
+//
+//static guint signal_update_id;
+//static gboolean g_settings_schema_found = FALSE;
+//
+//void ui_app_update(UiApp *ui_app, TsutilStatus tsutil_status) {
+// g_signal_emit(ui_app, signal_update_id, 0, tsutil_status);
+//}
+//
+//void ui_app_g_settings_changed(GSettings *g_settings, const char *key, UiApp *ui_app) {
+// if (strcmp(key, "tray-icon") == 0) {
+// gboolean trayIcon = g_settings_get_boolean(g_settings, key);
+// if (trayIcon) {
+// ui_app_start_tray(ui_app);
+// return;
+// }
+// ui_app_stop_tray(ui_app);
+// return;
+// }
+//
+// if (strcmp(key, "polling-interval") == 0) {
+// g_print("polling-interval: %f\n", g_settings_get_double(g_settings, "polling-interval"));
+// ui_app_set_polling_interval(ui_app, g_settings_get_double(g_settings, "polling-interval"));
+// return;
+// }
+//}
+//
+//void ui_app_init_css_provider(UiApp *ui_app) {
+// char *app_css;
+//
+// app_css = ui_get_file("app.css");
+// ui_app->css_provider = gtk_css_provider_new();
+// gtk_css_provider_load_from_string(ui_app->css_provider, app_css);
+// gtk_style_context_add_provider_for_display(gdk_display_get_default(),
+// GTK_STYLE_PROVIDER(ui_app->css_provider),
+// GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
+//
+// free(app_css);
+//}
+//
+//void ui_app_init_g_settings(UiApp *ui_app) {
+// if (g_settings_schema_found) {
+// ui_app->g_settings = g_settings_new(APP_ID);
+// g_signal_connect(ui_app->g_settings, "changed", G_CALLBACK(ui_app_g_settings_changed), ui_app);
+// }
+//}
+//
+//void ui_app_action_quit(GSimpleAction *g_simple_action, GVariant *p, UiApp *ui_app) {
+// ts_app_quit(ui_app->ts_app);
+//}
+//
+//void ui_app_init_actions(UiApp *ui_app) {
+// GSimpleAction *g_simple_action;
+// const char *quit_accels[] = {"q", NULL};
+//
+// g_simple_action = g_simple_action_new("quit", NULL);
+// g_signal_connect(g_simple_action, "activate", G_CALLBACK(ui_app_action_quit), ui_app);
+// g_action_map_add_action(G_ACTION_MAP(ui_app), G_ACTION(g_simple_action));
+// g_clear_object(&g_simple_action);
+//
+// gtk_application_set_accels_for_action(GTK_APPLICATION(ui_app), "app.quit", quit_accels);
+//}
+//
+//void ui_app_startup(GApplication *g_application) {
+// G_APPLICATION_CLASS(ui_app_parent_class)->startup(g_application);
+//
+// UiApp *ui_app = UI_APP(g_application);
+//
+// ui_app_init_css_provider(ui_app);
+// ui_app_init_g_settings(ui_app);
+// ui_app_init_actions(ui_app);
+//
+// g_application_hold(g_application);
+//}
+//
+//void ui_app_shutdown(GApplication *g_application) {
+// G_APPLICATION_CLASS(ui_app_parent_class)->shutdown(g_application);
+//
+// GtkApplication *gtk_application = GTK_APPLICATION(g_application);
+//
+// GtkWindow *gtk_window;
+//
+// gtk_window = gtk_application_get_active_window(gtk_application);
+// if (gtk_window != NULL) {
+// gtk_window_close(gtk_window);
+// }
+//}
+//
+//void ui_app_open(GApplication *g_application, GFile *files[], int nfiles, const char *hint) {
+// G_APPLICATION_CLASS(ui_app_parent_class)->open(g_application, files, nfiles, hint);
+//
+// printf("app open\n");
+//}
+//
+//void ui_app_activate(GApplication *g_application) {
+// G_APPLICATION_CLASS(ui_app_parent_class)->activate(g_application);
+//
+// UiMainWindow *ui_main_window;
+//
+// UiApp *ui_app = UI_APP(g_application);
+// GSettings *g_settings = ui_app->g_settings;
+//
+// gdouble interval = g_settings != NULL ? g_settings_get_double(g_settings, "polling-interval") : 5;
+// ui_app_set_polling_interval(ui_app, interval);
+//
+// if (g_settings == NULL || g_settings_get_boolean(g_settings, "tray-icon")) {
+// ui_app_start_tray(ui_app);
+// }
+//
+// ui_main_window = ui_main_window_new(ui_app);
+// gtk_window_present(GTK_WINDOW(ui_main_window));
+//}
+//
+//void ui_app_dispose(GObject *g_object) {
+// G_OBJECT_CLASS(ui_app_parent_class)->dispose(g_object);
+//
+// UiApp *ui_app = UI_APP(g_object);
+//
+// cgo_handle_delete(ui_app->ts_app);
+// g_clear_object(&ui_app->css_provider);
+// g_clear_object(&ui_app->g_settings);
+//}
+//
+//void ui_app_init(UiApp *ui_app) {
+//}
+//
+//void ui_app_class_init_g_settings_schema_found() {
+// GSettingsSchemaSource *g_settings_schema_source;
+// GSettingsSchema *g_settings_schema;
+//
+// g_settings_schema_source = g_settings_schema_source_get_default();
+// g_settings_schema = g_settings_schema_source_lookup(g_settings_schema_source, APP_ID, TRUE);
+// g_settings_schema_found = g_settings_schema != NULL;
+// if (g_settings_schema_found) {
+// g_settings_schema_unref(g_settings_schema);
+// }
+//}
+//
+//void ui_app_class_init(UiAppClass *ui_app_class) {
+// GApplicationClass *g_application_class = G_APPLICATION_CLASS(ui_app_class);
+// GObjectClass *g_object_class = G_OBJECT_CLASS(ui_app_class);
+//
+// g_application_class->startup = ui_app_startup;
+// g_application_class->shutdown = ui_app_shutdown;
+// g_application_class->open = ui_app_open;
+// g_application_class->activate = ui_app_activate;
+//
+// g_object_class->dispose = ui_app_dispose;
+//
+// signal_update_id = g_signal_new("update",
+// G_TYPE_FROM_CLASS(ui_app_class),
+// G_SIGNAL_RUN_LAST | G_SIGNAL_NO_RECURSE | G_SIGNAL_NO_HOOKS,
+// 0,
+// NULL,
+// NULL,
+// NULL,
+// G_TYPE_NONE,
+// 1,
+// G_TYPE_POINTER);
+//
+// ui_app_class_init_g_settings_schema_found();
+//}
diff --git a/internal/ui/app.go b/internal/ui/app.go
index 24cf005c..d0f6116c 100644
--- a/internal/ui/app.go
+++ b/internal/ui/app.go
@@ -1,436 +1,143 @@
package ui
+/*
+#include
+#include "ui.h"
+*/
+import "C"
+
import (
- "cmp"
- "context"
- _ "embed"
- "fmt"
"log/slog"
- "os"
- "slices"
- "time"
+ "runtime"
"deedles.dev/trayscale/internal/metadata"
"deedles.dev/trayscale/internal/tray"
"deedles.dev/trayscale/internal/tsutil"
- "github.com/diamondburned/gotk4-adwaita/pkg/adw"
- "github.com/diamondburned/gotk4/pkg/gdk/v4"
- "github.com/diamondburned/gotk4/pkg/gio/v2"
- "github.com/diamondburned/gotk4/pkg/glib/v2"
- "github.com/diamondburned/gotk4/pkg/gtk/v4"
- "github.com/inhies/go-bytesize"
- "tailscale.com/client/tailscale/apitype"
- "tailscale.com/tailcfg"
)
-//go:embed app.css
-var appCSS string
-
-// App is the main type for the app, containing all of the state
-// necessary to run it.
-type App struct {
- poller *tsutil.Poller
- online bool
+var TypeApp = DefineType[AppClass, App](TypeAdwApplication, "UiApp")
- app *adw.Application
- win *MainWindow
- settings *gio.Settings
- tray *tray.Tray
-
- spinnum int
- operatorCheck bool
- files *[]apitype.WaitingFile
+type AppClass struct {
+ AdwApplicationClass
}
-func (a *App) clip(v *glib.Value) {
- gdk.DisplayGetDefault().Clipboard().Set(v)
+func (class *AppClass) Init() {
+ class.SetDispose(func(obj *GObject) {
+ app := TypeApp.Cast(obj.AsGTypeInstance())
+ app.unpin()
+ })
}
-func (a *App) notify(title, body string) {
- icon, iconerr := gio.NewIconForString(metadata.AppID)
-
- n := gio.NewNotification(title)
- n.SetBody(body)
- if iconerr == nil {
- n.SetIcon(icon)
- }
+type App struct {
+ AdwApplication
- a.app.SendNotification("tailscale-status", n)
+ *appData
}
-func (a *App) spin() {
- glib.IdleAdd(func() {
- a.spinnum++
- if a.win != nil {
- a.win.WorkSpinner.SetVisible(a.spinnum > 0)
- }
- })
+func (app *App) Init() {
}
-func (a *App) stopSpin() {
- glib.IdleAdd(func() {
- a.spinnum--
- if a.win != nil {
- a.win.WorkSpinner.SetVisible(a.spinnum > 0)
- }
- })
-}
-
-func (a *App) update(status tsutil.Status) {
- switch status := status.(type) {
- case *tsutil.IPNStatus:
- 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?
- }
-
- if 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)
- }
- }
-
- if !online {
- a.files = nil
- }
-
- if a.win != nil {
- a.win.Update(status)
- }
-
- 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
-
- if a.win != nil {
- a.win.Update(status)
- }
-
- case *tsutil.ProfileStatus:
- if a.win != nil {
- a.win.Update(status)
- }
- }
+type appData struct {
+ p runtime.Pinner
+ tsApp TSApp
+ online bool
}
-func (a *App) init(ctx context.Context) {
- gtk.Init()
-
- a.app = adw.NewApplication(metadata.AppID, gio.ApplicationHandlesOpen)
-
- css := gtk.NewCSSProvider()
- css.LoadFromString(appCSS)
- gtk.StyleContextAddProviderForDisplay(gdk.DisplayGetDefault(), css, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
-
- var hideWindow bool
- a.app.AddMainOption("hide-window", 0, glib.OptionFlagNone, glib.OptionArgNone, "Hide window on initial start", "")
- a.app.ConnectHandleLocalOptions(func(options *glib.VariantDict) int {
- if options.Contains("hide-window") {
- hideWindow = true
- }
-
- return -1
- })
-
- a.app.ConnectOpen(func(files []gio.Filer, hint string) {
- a.onAppOpen(ctx, files)
- })
-
- a.app.ConnectStartup(func() {
- a.app.Hold()
- })
-
- a.app.ConnectActivate(func() {
- if hideWindow {
- hideWindow = false
- return
- }
- a.onAppActivate(ctx)
- })
-
- a.initSettings(ctx)
+func (data *appData) pin() {
+ data.p.Pin(data)
}
-func (a *App) startTS(ctx context.Context) error {
- status := <-a.poller.GetIPN()
- if status.NeedsAuth() {
- Confirmation{
- Heading: "Login Required",
- Body: "Open a browser to authenticate with Tailscale?",
- Accept: "_Open Browser",
- Reject: "_Cancel",
- }.Show(a, func(accept bool) {
- if accept {
- a.app.ActivateAction("login", nil)
- }
- })
- return nil
- }
-
- err := tsutil.Start(ctx)
- if err != nil {
- return err
- }
- <-a.poller.Poll()
- return nil
+func (data *appData) unpin() {
+ data.p.Unpin()
}
-func (a *App) stopTS(ctx context.Context) error {
- err := tsutil.Stop(ctx)
- if err != nil {
- return err
+func NewApp(tsApp TSApp) *App {
+ app := TypeApp.New(
+ "application-id", GValueFromString(metadata.AppID),
+ )
+ app.appData = &appData{
+ tsApp: tsApp,
}
- <-a.poller.Poll()
- return nil
+ app.pin()
+ return app
}
-func (a *App) onAppOpen(ctx context.Context, files []gio.Filer) {
- type selectOption = SelectOption[tailcfg.NodeView]
-
- s := <-a.poller.GetIPN()
- if !s.Online() {
- return
- }
- options := func(yield func(selectOption) bool) {
- for _, peer := range s.Peers {
- if !s.FileTargets.Contains(peer.StableID()) || tsutil.IsMullvad(peer) {
- continue
- }
+func (app *App) Update(status tsutil.Status) {
+ switch status := status.(type) {
+ case *tsutil.IPNStatus:
+ if app.online != status.Online() {
+ app.online = status.Online()
- option := selectOption{
- Title: peer.DisplayName(true),
- Value: peer,
- }
- if !yield(option) {
- return
+ body := "Disconnected"
+ if status.Online() {
+ body = "Connected"
}
+ app.Notify("Tailscale", body) // TODO: Notify on startup if not connected?
}
}
-
- Select[tailcfg.NodeView]{
- Heading: "Send file(s) to...",
- Options: slices.SortedFunc(options, func(o1, o2 selectOption) int {
- return cmp.Compare(o1.Title, o2.Title)
- }),
- }.Show(a, func(options []selectOption) {
- 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.StableID(), file)
- }
- }
- })
}
-func (a *App) onAppActivate(ctx context.Context) {
- if a.win != nil {
- a.win.MainWindow.Present()
- return
- }
-
- changeControlServerAction := gio.NewSimpleAction("change_control_server", nil)
- changeControlServerAction.ConnectActivate(func(p *glib.Variant) { a.showChangeControlServer() })
- a.app.AddAction(changeControlServerAction)
-
- preferencesAction := gio.NewSimpleAction("preferences", nil)
- preferencesAction.ConnectActivate(func(p *glib.Variant) { a.showPreferences() })
- a.app.AddAction(preferencesAction)
-
- aboutAction := gio.NewSimpleAction("about", nil)
- aboutAction.ConnectActivate(func(p *glib.Variant) { a.showAbout() })
- a.app.AddAction(aboutAction)
-
- quitAction := gio.NewSimpleAction("quit", nil)
- quitAction.ConnectActivate(func(p *glib.Variant) { a.Quit() })
- a.app.AddAction(quitAction)
- a.app.SetAccelsForAction("app.quit", []string{"q"})
-
- 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()
-
- 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 != "" {
- gtk.NewURILauncher(status.BrowseToURL).Launch(ctx, a.window(), nil)
- return
- }
- }
- }
- })
- a.app.AddAction(loginAction)
-
- a.win = NewMainWindow(a)
- a.win.MainWindow.ConnectCloseRequest(func() bool {
- a.win = nil
- return false
- })
-
- <-a.poller.Poll()
- a.win.MainWindow.Present()
-
- glib.IdleAdd(func() {
- a.update(<-a.poller.GetIPN())
- })
+func (app *App) ShowWindow() {
+ slog.Info("show window")
}
-func (a *App) initTray(ctx context.Context) {
- if a.tray != nil {
- err := a.tray.Start(<-a.poller.GetIPN())
- if err != nil {
- slog.Error("failed to start tray icon", "err", err)
- }
- return
- }
-
- a.tray = &tray.Tray{
- OnShow: func() {
- glib.IdleAdd(func() {
- if a.app != nil {
- a.app.Activate()
- }
- })
- },
-
- OnConnToggle: func() {
- glib.IdleAdd(func() {
- ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
- defer cancel()
-
- f := a.stopTS
- if !a.online {
- f = a.startTS
- }
-
- err := f(ctx)
- if err != nil {
- slog.Error("set Tailscale status from tray", "err", err)
- return
- }
- })
- },
-
- OnExitToggle: func() {
- glib.IdleAdd(func() {
- ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
- defer cancel()
-
- s := <-a.poller.GetIPN()
- toggle := !s.ExitNodeActive()
- err := tsutil.SetUseExitNode(ctx, toggle)
- if err != nil {
- a.notify("Toggle exit node", err.Error())
- slog.Error("toggle exit node from tray", "err", err)
- return
- }
-
- if toggle {
- a.notify("Exit node", "Enabled")
- return
- }
- a.notify("Exit node", "Disabled")
- })
- },
+func (app *App) Notify(title, body string) {
+ notification := GNotificationNew(title)
+ defer notification.Unref()
+ notification.SetBody(body)
- OnSelfNode: func() {
- glib.IdleAdd(func() {
- s := <-a.poller.GetIPN()
- addr := s.SelfAddr()
- if !addr.IsValid() {
- return
- }
- a.clip(glib.NewValue(addr.String()))
- if a.win != nil {
- a.notify("Trayscale", "Copied address to clipboard")
- }
- })
- },
-
- OnQuit: func() {
- a.Quit()
- },
- }
-
- err := a.tray.Start(<-a.poller.GetIPN())
- if err != nil {
- slog.Error("failed to start tray icon", "err", err)
- }
+ app.SendNotification("tailscale-status", notification)
}
-// Quit exits the app completely, causing Run to return.
-func (a *App) Quit() {
- a.tray.Close()
- a.app.Quit()
-}
-
-// Run runs the app, initializing everything and then entering the
-// main loop. It will return if either ctx is cancelled or Quit is
-// called.
-func (a *App) Run(ctx context.Context) {
- ctx, cancel := context.WithCancel(ctx)
- defer cancel()
-
- a.init(ctx)
-
- err := a.app.Register(ctx)
- if err != nil {
- slog.Error("register application", "err", err)
- return
- }
-
- a.poller = &tsutil.Poller{
- Interval: a.getInterval(),
- New: func(s tsutil.Status) { glib.IdleAdd(func() { a.update(s) }) },
- }
- go a.poller.Run(ctx)
-
- go func() {
- <-ctx.Done()
- a.Quit()
- }()
-
- a.app.Run(os.Args)
+////export ui_app_start_tray
+//func ui_app_start_tray(ui_app *C.UiApp) C.gboolean {
+// tsApp := (*App)(ui_app).tsApp()
+//
+// ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second)
+// defer cancel()
+//
+// status, ok := ctxutil.Recv(ctx, tsApp.Poller().GetIPN())
+// if !ok {
+// return C.FALSE
+// }
+//
+// err := tsApp.Tray().Start(status)
+// if err != nil {
+// slog.Error("failed to start tray icon", "err", err)
+// return C.FALSE
+// }
+//
+// return C.TRUE
+//}
+//
+////export ui_app_stop_tray
+//func ui_app_stop_tray(ui_app *C.UiApp) C.gboolean {
+// tsApp := (*App)(ui_app).tsApp()
+//
+// err := tsApp.Tray().Close()
+// if err != nil {
+// slog.Error("failed to stop tray icon", "err", err)
+// return C.FALSE
+// }
+//
+// return C.TRUE
+//}
+//
+////export ui_app_set_polling_interval
+//func ui_app_set_polling_interval(ui_app *C.UiApp, interval C.gdouble) {
+// tsApp := (*App)(ui_app).tsApp()
+//
+// ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second)
+// defer cancel()
+//
+// select {
+// case <-ctx.Done():
+// case tsApp.Poller().SetInterval() <- time.Duration(interval * C.gdouble(time.Second)):
+// }
+//}
+
+type TSApp interface {
+ Poller() *tsutil.Poller
+ Tray() *tray.Tray
+ Quit()
}
diff --git a/internal/ui/gtype.go b/internal/ui/gtype.go
new file mode 100644
index 00000000..11515069
--- /dev/null
+++ b/internal/ui/gtype.go
@@ -0,0 +1,226 @@
+package ui
+
+/*
+#include
+#include "ui.h"
+
+void _g_object_dispose(GObject *cobj);
+*/
+import "C"
+
+import (
+ "runtime/cgo"
+ "slices"
+ "unsafe"
+)
+
+func GValueFromString(str string) C.GValue {
+ cstr := C.CString(str)
+ defer C.free(unsafe.Pointer(cstr))
+
+ var val C.GValue
+ C.g_value_init(&val, 16<<2)
+ C.g_value_set_string(&val, cstr)
+ return val
+}
+
+type GType[T any] struct {
+ _ [unsafe.Sizeof(*new(C.GType))]byte
+}
+
+func ToGType[T any](c C.GType) GType[T] {
+ return *(*GType[T])(unsafe.Pointer(&c))
+}
+
+func (t *GType[T]) c() C.GType {
+ return *(*C.GType)(unsafe.Pointer(t))
+}
+
+func (t GType[T]) New(props ...any) *T {
+ names := make([]*C.char, 0, len(props)/2)
+ vals := make([]C.GValue, 0, len(props)/2)
+ for n, v := range pairs(slices.Values(props)) {
+ names = append(names, C.CString(n.(string)))
+ vals = append(vals, v.(C.GValue))
+ }
+
+ return (*T)(unsafe.Pointer(C.g_object_new_with_properties(
+ t.c(),
+ C.guint(len(names)),
+ (**C.char)(unsafe.SliceData(names)),
+ (*C.GValue)(unsafe.SliceData(vals)),
+ )))
+}
+
+func (t GType[T]) Cast(obj *GTypeInstance) *T {
+ target := C.g_type_from_name(C.g_type_name_from_instance(obj.c()))
+ switch {
+ case C.g_type_is_a(t.c(), target) == 0, C.g_type_is_a(target, t.c()) == 0:
+ panic("type is not convertible")
+ default:
+ return (*T)(unsafe.Pointer(obj))
+ }
+}
+
+type GTypeClass struct {
+ _ [unsafe.Sizeof(*new(C.GTypeClass))]byte
+}
+
+func (tc *GTypeClass) c() *C.GTypeClass {
+ return (*C.GTypeClass)(unsafe.Pointer(tc))
+}
+
+func (tc *GTypeClass) AsGTypeClass() *GTypeClass { return tc }
+
+type GTypeInstance struct {
+ _ [unsafe.Sizeof(*new(C.GTypeInstance))]byte
+}
+
+func (ti *GTypeInstance) c() *C.GTypeInstance {
+ return (*C.GTypeInstance)(unsafe.Pointer(ti))
+}
+
+func (ti *GTypeInstance) AsGTypeInstance() *GTypeInstance { return ti }
+
+type GObjectClass struct {
+ GTypeClass
+ _ [unsafe.Sizeof(*new(C.GObjectClass)) - unsafe.Sizeof(*new(C.GTypeClass))]byte
+}
+
+func (class *GObjectClass) c() *C.GObjectClass {
+ return (*C.GObjectClass)(unsafe.Pointer(class))
+}
+
+func (class *GObjectClass) AsGObjectClass() *GObjectClass { return class }
+
+var _g_object_dispose_quark C.GQuark = C.g_quark_from_static_string(C.CString("_g_object_dispose"))
+
+//export _g_object_dispose
+func _g_object_dispose(obj *C.GObject) {
+ t := C.g_type_from_name(C.g_type_name_from_instance((*C.GTypeInstance)(unsafe.Pointer(obj))))
+ f := cgo.Handle(C.g_type_get_qdata(t, _g_object_dispose_quark)).Value().(func(*GObject))
+ f((*GObject)(unsafe.Pointer(obj)))
+}
+
+func (class *GObjectClass) SetDispose(dispose func(*GObject)) {
+ t := C.g_type_from_name(C.g_type_name_from_class(class.AsGTypeClass().c()))
+ h := cgo.Handle(C.g_type_get_qdata(t, _g_object_dispose_quark))
+ if h != 0 {
+ h.Delete()
+ }
+
+ C.g_type_set_qdata(t, _g_object_dispose_quark, C.gpointer(cgo.NewHandle(dispose)))
+ class.c().dispose = cfunc(C._g_object_dispose)
+}
+
+type GObject struct {
+ GTypeInstance
+ _ [unsafe.Sizeof(*new(C.GObject)) - unsafe.Sizeof(*new(C.GTypeInstance))]byte
+}
+
+func (obj *GObject) c() *C.GObject {
+ return (*C.GObject)(unsafe.Pointer(obj))
+}
+
+func (obj *GObject) AsGObject() *GObject { return obj }
+
+func (obj *GObject) Ref() {
+ C.g_object_ref(C.gpointer(obj.c()))
+}
+
+func (obj *GObject) Unref() {
+ C.g_object_unref(C.gpointer(obj.c()))
+}
+
+var TypeGApplication = ToGType[GApplication](C.g_application_get_type())
+
+type GApplicationClass struct {
+ GObjectClass
+ _ [unsafe.Sizeof(*new(C.GApplicationClass)) - unsafe.Sizeof(*new(C.GObjectClass))]byte
+}
+
+func (class *GApplicationClass) c() *C.GApplicationClass {
+ return (*C.GApplicationClass)(unsafe.Pointer(class))
+}
+
+func (class *GApplicationClass) AsGApplicationClass() *GApplicationClass { return class }
+
+type GApplication struct {
+ GObject
+ _ [unsafe.Sizeof(*new(C.GApplication)) - unsafe.Sizeof(*new(C.GObject))]byte
+}
+
+func (app *GApplication) c() *C.GApplication {
+ return (*C.GApplication)(unsafe.Pointer(app))
+}
+
+func (app *GApplication) AsGApplication() *GApplication { return app }
+
+func (app *GApplication) Run(args []string) {
+ cargs := cstrings(args)
+ defer freeAll(cargs)
+
+ C.g_application_run(app.c(), C.int(len(cargs)), unsafe.SliceData(cargs))
+}
+
+func (app *GApplication) Quit() {
+ C.g_application_quit(app.c())
+}
+
+func (app *GApplication) SendNotification(id string, notification *GNotification) {
+ cid := C.CString(id)
+ defer C.free(unsafe.Pointer(cid))
+
+ C.g_application_send_notification(app.c(), cid, notification.c())
+}
+
+var TypeAdwApplication = ToGType[AdwApplication](C.adw_application_get_type())
+
+type AdwApplicationClass struct {
+ GApplicationClass
+ _ [unsafe.Sizeof(*new(C.AdwApplicationClass)) - unsafe.Sizeof(*new(C.GApplicationClass))]byte
+}
+
+func (class *AdwApplicationClass) c() *C.AdwApplicationClass {
+ return (*C.AdwApplicationClass)(unsafe.Pointer(class))
+}
+
+func (class *AdwApplicationClass) AsAdwApplicationClass() *AdwApplicationClass { return class }
+
+type AdwApplication struct {
+ GApplication
+ _ [unsafe.Sizeof(*new(C.AdwApplication)) - unsafe.Sizeof(*new(C.GApplication))]byte
+}
+
+func (app *AdwApplication) c() *C.AdwApplication {
+ return (*C.AdwApplication)(unsafe.Pointer(app))
+}
+
+func (app *AdwApplication) AsAdwApplication() *AdwApplication { return app }
+
+var TypeGNotification = ToGType[GNotification](C.g_notification_get_type())
+
+type GNotification struct {
+ GObject
+ //_ [unsafe.Sizeof(*new(C.GNotification)) - unsafe.Sizeof(*new(C.GObject))]byte
+}
+
+func GNotificationNew(title string) *GNotification {
+ ctitle := C.CString(title)
+ defer C.free(unsafe.Pointer(ctitle))
+
+ return (*GNotification)(unsafe.Pointer(C.g_notification_new(ctitle)))
+}
+
+func (n *GNotification) c() *C.GNotification {
+ return (*C.GNotification)(unsafe.Pointer(n))
+}
+
+func (n *GNotification) AsGNotification() *GNotification { return n }
+
+func (n *GNotification) SetBody(body string) {
+ cbody := C.CString(body)
+ defer C.free(unsafe.Pointer(cbody))
+
+ C.g_notification_set_body(n.c(), cbody)
+}
diff --git a/internal/ui/main_window.c b/internal/ui/main_window.c
new file mode 100644
index 00000000..63a6ad4a
--- /dev/null
+++ b/internal/ui/main_window.c
@@ -0,0 +1,60 @@
+//#include
+//#include "ui.h"
+//
+//G_DEFINE_TYPE(UiMainWindow, ui_main_window, ADW_TYPE_APPLICATION_WINDOW);
+//
+//void ui_main_window_status_switch_state_set(GtkSwitch *gtk_switch, gboolean state, UiMainWindow *ui_main_window);
+//void ui_main_window_update(UiApp *ui_app, TsutilStatus tsutil_status, UiMainWindow *ui_main_window);
+//
+//GMenuModel *menu_model_main, *menu_model_page;
+//
+//UiMainWindow *ui_main_window_new(UiApp *ui_app) {
+// UiMainWindow *ui_main_window;
+//
+// ui_main_window = g_object_new(UI_TYPE_MAIN_WINDOW,
+// "application", ui_app,
+// NULL);
+//
+// // TODO: Put this somewhere that makes more sense.
+// ui_main_window->ui_app = ui_app;
+// g_signal_connect(ui_app, "update", G_CALLBACK(ui_main_window_update), ui_main_window);
+//
+// return ui_main_window;
+//}
+//
+//void ui_main_window_dispose(GObject *g_object) {
+// G_OBJECT_CLASS(ui_main_window_parent_class)->dispose(g_object);
+//
+// GtkWidget *gtk_widget = GTK_WIDGET(g_object);
+//
+// gtk_widget_dispose_template(gtk_widget, UI_TYPE_MAIN_WINDOW);
+//}
+//
+//void ui_main_window_init(UiMainWindow *ui_main_window) {
+// gtk_widget_init_template(GTK_WIDGET(ui_main_window));
+//
+// gtk_menu_button_set_menu_model(ui_main_window->main_menu_button, menu_model_main);
+// gtk_menu_button_set_menu_model(ui_main_window->page_menu_button, menu_model_page);
+//}
+//
+//void ui_main_window_class_init(UiMainWindowClass *ui_main_window_class) {
+// GBytes *template;
+// char *menu_ui;
+// GtkBuilder *gtk_builder;
+//
+// GtkWidgetClass *gtk_widget_class = GTK_WIDGET_CLASS(ui_main_window_class);
+// GObjectClass *g_object_class = G_OBJECT_CLASS(ui_main_window_class);
+//
+// g_object_class->dispose = ui_main_window_dispose;
+//
+// template = ui_get_file_bytes("main_window.ui");
+// gtk_widget_class_set_template(gtk_widget_class, template);
+//
+// gtk_widget_class_bind_template_child(gtk_widget_class, UiMainWindow, main_menu_button);
+// gtk_widget_class_bind_template_child(gtk_widget_class, UiMainWindow, page_menu_button);
+// gtk_widget_class_bind_template_child(gtk_widget_class, UiMainWindow, status_switch);
+//
+// gtk_widget_class_bind_template_callback(gtk_widget_class, ui_main_window_status_switch_state_set);
+//
+// g_bytes_unref(template);
+//}
diff --git a/internal/ui/main_window.go b/internal/ui/main_window.go
new file mode 100644
index 00000000..12e47b64
--- /dev/null
+++ b/internal/ui/main_window.go
@@ -0,0 +1,64 @@
+package ui
+
+/*
+#include
+#include "ui.h"
+*/
+import "C"
+
+//var (
+// str_main_menu = C.CString("main_menu")
+// str_page_menu = C.CString("page_menu")
+//)
+//
+//func init() {
+// menu_ui := C.CString(string(getFile("menu.ui")))
+// defer C.free(unsafe.Pointer(menu_ui))
+//
+// gtk_builder := C.gtk_builder_new_from_string(menu_ui, -1)
+// defer C.g_object_unref(C.gpointer(gtk_builder))
+//
+// C.menu_model_main = (*C.GMenuModel)(unsafe.Pointer(C.gtk_builder_get_object(gtk_builder, str_main_menu)))
+// C.menu_model_page = (*C.GMenuModel)(unsafe.Pointer(C.gtk_builder_get_object(gtk_builder, str_page_menu)))
+//
+// C.g_object_ref(C.gpointer(C.menu_model_main))
+// C.g_object_ref(C.gpointer(C.menu_model_page))
+//}
+//
+////export ui_main_window_status_switch_state_set
+//func ui_main_window_status_switch_state_set(gtk_switch *C.GtkSwitch, state C.gboolean, ui_main_window *C.UiMainWindow) C.gboolean {
+// if state == C.gtk_switch_get_state(gtk_switch) {
+// return C.FALSE
+// }
+//
+// ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second)
+// defer cancel()
+//
+// f := tsutil.Stop
+// if state != 0 {
+// f = tsutil.Start
+// }
+//
+// err := f(ctx)
+// if err != nil {
+// slog.Error("failed to set Tailscale status", "err", err)
+// C.gtk_switch_set_active(gtk_switch, ^state)
+// return C.TRUE
+// }
+//
+// tsApp := (*App)(ui_main_window.ui_app).tsApp()
+// ctxutil.Recv(ctx, tsApp.Poller().Poll())
+// return C.TRUE
+//}
+//
+////export ui_main_window_update
+//func ui_main_window_update(ui_app *App, tsutil_status C.TsutilStatus, ui_main_window *C.UiMainWindow) {
+// switch status := cgo.Handle(tsutil_status).Value().(type) {
+// case *tsutil.IPNStatus:
+// online := status.Online()
+// C.gtk_switch_set_state(ui_main_window.status_switch, cbool(online))
+// C.gtk_switch_set_active(ui_main_window.status_switch, cbool(online))
+//
+// //ui_main_window_update_peers(ui_main_window, status)
+// }
+//}
diff --git a/internal/ui/mainwindow.ui b/internal/ui/main_window.ui
similarity index 77%
rename from internal/ui/mainwindow.ui
rename to internal/ui/main_window.ui
index fd2a1054..4f60c2a7 100644
--- a/internal/ui/mainwindow.ui
+++ b/internal/ui/main_window.ui
@@ -4,36 +4,35 @@
-