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 @@ - - ToastOverlay + diff --git a/internal/ui/menu.ui b/internal/ui/menu.ui index a450f901..3720afdc 100644 --- a/internal/ui/menu.ui +++ b/internal/ui/menu.ui @@ -3,7 +3,7 @@ - +
app.change_control_server @@ -25,7 +25,7 @@
- +
peer.copyFQDN diff --git a/internal/ui/mullvadpage.ui b/internal/ui/mullvad_page.ui similarity index 85% rename from internal/ui/mullvadpage.ui rename to internal/ui/mullvad_page.ui index 0afdaf0c..a6a17ab7 100644 --- a/internal/ui/mullvadpage.ui +++ b/internal/ui/mullvad_page.ui @@ -4,7 +4,7 @@ - + diff --git a/internal/ui/offlinepage.ui b/internal/ui/offline_page.ui similarity index 83% rename from internal/ui/offlinepage.ui rename to internal/ui/offline_page.ui index 075b7599..86ae692f 100644 --- a/internal/ui/offlinepage.ui +++ b/internal/ui/offline_page.ui @@ -1,12 +1,13 @@ + - + diff --git a/internal/ui/peerpage.ui b/internal/ui/peer_page.ui similarity index 92% rename from internal/ui/peerpage.ui rename to internal/ui/peer_page.ui index 71f0aee8..14e2fe7e 100644 --- a/internal/ui/peerpage.ui +++ b/internal/ui/peer_page.ui @@ -4,7 +4,7 @@ - + + copy diff --git a/internal/ui/preferences.ui b/internal/ui/preferences.ui index f598f393..cb157533 100644 --- a/internal/ui/preferences.ui +++ b/internal/ui/preferences.ui @@ -4,7 +4,7 @@ - + diff --git a/internal/ui/selfpage.ui b/internal/ui/self_page.ui similarity index 98% rename from internal/ui/selfpage.ui rename to internal/ui/self_page.ui index 8ff3df58..36ac1213 100644 --- a/internal/ui/selfpage.ui +++ b/internal/ui/self_page.ui @@ -1,10 +1,10 @@ - + - + diff --git a/internal/ui/trayscale.cmb b/internal/ui/trayscale.cmb index 9617871d..053b85f9 100644 --- a/internal/ui/trayscale.cmb +++ b/internal/ui/trayscale.cmb @@ -2,11 +2,11 @@ - - - - - - - + + + + + + + diff --git a/internal/ui/ui.c b/internal/ui/ui.c new file mode 100644 index 00000000..68b04f2d --- /dev/null +++ b/internal/ui/ui.c @@ -0,0 +1,4 @@ +#include +#include "ui.h" + +char *APP_ID = NULL; diff --git a/internal/ui/ui.go b/internal/ui/ui.go index dd443091..b522087d 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1,227 +1,221 @@ package ui +/* +#cgo pkg-config: libadwaita-1 + +#include + +#include "ui.h" + +gboolean _idle(gpointer h); + +void _class_init(gpointer p); +void _instance_init(gpointer p); +*/ +import "C" + import ( - "cmp" - "errors" + "embed" + "io/fs" "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" -) + "runtime/cgo" + "sync" + "unsafe" -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()) - })) + "deedles.dev/trayscale/internal/metadata" ) -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 init() { + C.APP_ID = C.CString(metadata.AppID) } -func formatTime(t time.Time) string { - if t.IsZero() { - return "" - } - return t.Format(time.StampMilli) -} +//go:embed *.ui *.css +var files embed.FS -func boolIcon(v bool) string { - if v { - return "emblem-ok-symbolic" - } - return "window-close-symbolic" +func getFile(name string) []byte { + return must(fs.ReadFile(files, name)) } -func optBoolIcon(v opt.Bool) string { - b, ok := v.Get() - if !ok { - return "dialog-question-symbolic" - } - return boolIcon(b) +//export ui_get_file +func ui_get_file(name *C.char) *C.char { + return C.CString(string(getFile(C.GoString(name)))) } -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 - } +//export ui_get_file_bytes +func ui_get_file_bytes(name *C.char) *C.GBytes { + data := getFile(C.GoString(name)) + return newGBytes(data) +} - fv.Set(reflect.ValueOf(obj.Cast())) +func must[T any](v T, err error) T { + if err != nil { + panic(err) } + return v } -func fillFromBuilder(into any, xml ...string) { - builder := gtk.NewBuilder() - for _, v := range xml { - builder.AddFromString(v) +func get[T any](v T, ok bool) T { + if !ok { + panic("!ok") } + return v +} - fillObjects(into, builder) +func newGBytes(data []byte) *C.GBytes { + return C.g_bytes_new(C.gconstpointer(unsafe.SliceData(data)), C.gsize(len(data))) } -func errHasCode(err error, code int) bool { - var gerr *gerror.GError - if !errors.As(err, &gerr) { - return false +func cbool(v bool) C.gboolean { + if v { + return C.TRUE } - return gerr.ErrorCode() == code + return C.FALSE } -type widgetParent interface { - FirstChild() gtk.Widgetter +func cfunc(f unsafe.Pointer) *[0]byte { + return (*[0]byte)(f) } -func widgetChildren(w widgetParent) iter.Seq[gtk.Widgetter] { - return func(yield func(gtk.Widgetter) bool) { - widgetChildrenPush(yield, w) +func cstrings(str []string) []*C.char { + cstr := make([]*C.char, 0, len(str)) + for _, s := range str { + cstr = append(cstr, C.CString(s)) } + return cstr } -func widgetChildrenPush(yield func(gtk.Widgetter) bool, w widgetParent) bool { - type siblingNexter interface{ NextSibling() gtk.Widgetter } +func freeAll[T any, P *T](cstr []P) { + for _, s := range cstr { + C.free(unsafe.Pointer(s)) + } +} - cur := w.FirstChild() - for cur != nil { - if !yield(cur) { - return false - } - if !widgetChildrenPush(yield, cur.(widgetParent)) { - return false +func to[T any](val any) *T { + target := reflect.TypeFor[*T]() + + v := reflect.ValueOf(val) + for { + t := v.Type() + if t == target { + return (*T)(v.UnsafePointer()) } - cur = cur.(siblingNexter).NextSibling() + v = v.Elem().Field(0).Addr() } +} + +func gtk_widget_class_bind_template_child[T any](gtk_widget_class *C.GtkWidgetClass, name string) { + cname := C.CString(name) + defer C.free(unsafe.Pointer(cname)) + + offset := get(reflect.TypeFor[T]().FieldByName(name)).Offset + + C.gtk_widget_class_bind_template_child_full(gtk_widget_class, cname, C.FALSE, C.gssize(offset)) +} + +func idle(f func()) { + C.g_idle_add(cfunc(C._idle), C.gpointer(cgo.NewHandle(f))) +} + +//export _idle +func _idle(p C.gpointer) C.gboolean { + h := cgo.Handle(p) + defer h.Delete() - return true + h.Value().(func())() + return C.G_SOURCE_REMOVE } -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 - } +func pairs[T any](seq iter.Seq[T]) iter.Seq2[T, T] { + return func(yield func(T, T) bool) { + var prev *T + for v := range seq { + if prev == nil { + prev = &v + continue + } + + if !yield(*prev, v) { + return } + prev = nil + } + if prev != nil { + panic("odd length of paris") } } - 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 +var types sync.Map + +type typeDefinition[Class, Instance any, ClassP interface { + *Class + Initter +}, InstanceP interface { + *Instance + Initter +}] struct { + once func() GType[Instance] } -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) - }) +func (d *typeDefinition[Class, Instance, ClassP, InstanceP]) init() GType[Instance] { + return d.once() } -// 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 +func (d *typeDefinition[Class, Instance, ClassP, InstanceP]) initClass(p C.gpointer) { + (ClassP)(p).Init() +} - Init(*PageRow) - Update(tsutil.Status) bool +func (d *typeDefinition[Class, Instance, ClassP, InstanceP]) initInstance(p C.gpointer) { + (InstanceP)(p).Init() } -type PageRow struct { - page *adw.ViewStackPage - row *adw.ActionRow - icon *gtk.Image +type Initter interface { + Init() } -func NewPageRow(page *adw.ViewStackPage) *PageRow { - icon := gtk.NewImage() - icon.NotifyProperty("icon-name", func() { - page.SetIconName(icon.IconName()) - }) +func DefineType[Class, Instance any, ClassP interface { + *Class + Initter +}, InstanceP interface { + *Instance + Initter +}, ParentType any](parent GType[ParentType], name string) GType[Instance] { + definition := typeDefinition[Class, Instance, ClassP, InstanceP]{ + once: sync.OnceValue(func() GType[Instance] { + cname := C.CString(name) + defer C.free(unsafe.Pointer(cname)) - row := adw.NewActionRow() - row.AddPrefix(icon) - row.NotifyProperty("title", func() { - page.SetTitle(row.Title()) - }) + var c Class + var i Instance - return &PageRow{ - page: page, - row: row, - icon: icon, + return ToGType[Instance](C.g_type_register_static_simple( + parent.c(), + cname, + C.guint(unsafe.Sizeof(c)), + (*[0]byte)(C._class_init), + C.guint(unsafe.Sizeof(i)), + (*[0]byte)(C._instance_init), + 0, + )) + }), } -} - -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) + once, _ := types.LoadOrStore(name, &definition) + return once.(interface{ init() GType[Instance] }).init() } -func (row *PageRow) SetSubtitle(subtitle string) { - row.row.SetSubtitle(subtitle) +//export _class_init +func _class_init(p C.gpointer) { + cname := C.g_type_name_from_class((*C.GTypeClass)(p)) + d, _ := types.Load(C.GoString(cname)) + d.(interface{ initClass(C.gpointer) }).initClass(p) } -func (row *PageRow) SetIconName(icon string) { - row.icon.SetFromIconName(icon) +//export _instance_init +func _instance_init(p C.gpointer) { + cname := C.g_type_name_from_instance((*C.GTypeInstance)(p)) + d, _ := types.Load(C.GoString(cname)) + d.(interface{ initInstance(C.gpointer) }).initInstance(p) } diff --git a/internal/ui/ui.h b/internal/ui/ui.h new file mode 100644 index 00000000..6705dbf9 --- /dev/null +++ b/internal/ui/ui.h @@ -0,0 +1,6 @@ +#pragma once + +extern char *APP_ID; + +char *ui_get_file(char *name); +GBytes *ui_get_file_bytes(char *name);