From cb27a0d63da0b5dfe2f44269337cb2fae1e8707c Mon Sep 17 00:00:00 2001 From: Peter Edley Date: Fri, 18 Sep 2026 20:32:49 +0100 Subject: [PATCH] feat: add Fyne desktop GUI with system tray (M3) - internal/gui: settings form (name, port, source, quality, fps, audio) wired to the flinger engine, config save/load, start/stop toggle - system tray via Fyne's StatusNotifierItem: Show (raises window), Start/Stop, Quit; left-click shows the settings window - cmd/teleportfling-gui entry point Verified on Hyprland: tray icon registers, start/stop streaming works, window shows and focuses from the tray. --- cmd/teleportfling-gui/main.go | 14 ++ internal/gui/gui.go | 254 ++++++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 cmd/teleportfling-gui/main.go create mode 100644 internal/gui/gui.go diff --git a/cmd/teleportfling-gui/main.go b/cmd/teleportfling-gui/main.go new file mode 100644 index 0000000..53ef864 --- /dev/null +++ b/cmd/teleportfling-gui/main.go @@ -0,0 +1,14 @@ +// Command teleportfling-gui is the desktop front-end for TeleportFling: a +// Fyne settings window plus a system tray to start/stop streaming. +// +// The streaming engine lives in internal/flinger; this command only wires the +// GUI and tray around it. Headless use is handled by cmd/teleportfling. +package main + +import ( + "teleportfling/internal/gui" +) + +func main() { + gui.Run() +} diff --git a/internal/gui/gui.go b/internal/gui/gui.go new file mode 100644 index 0000000..2e6826e --- /dev/null +++ b/internal/gui/gui.go @@ -0,0 +1,254 @@ +// Package gui provides the Fyne desktop app: a settings window and a system +// tray that start/stop the flinger engine. +// +// The engine itself lives in internal/flinger and is GUI-free, so the tray +// can control streaming without any UI dependency. This package wires the two +// together: config persistence, the settings form, and the tray menu. +package gui + +import ( + "errors" + "log" + "strconv" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/widget" + + "teleportfling/internal/config" + "teleportfling/internal/flinger" +) + +// errInvalidPort is returned when the port field contains a non-numeric or +// out-of-range value. +var errInvalidPort = errors.New("port must be 1–65535") + +func itoa(v int) string { return strconv.Itoa(v) } + +func atoi(s string) (int, error) { return strconv.Atoi(s) } + +// appID is the Fyne application ID used for preferences/settings storage. +const appID = "io.teleportfling" + +// App is the desktop application shell. +type App struct { + fyneApp fyne.App + win fyne.Window + + cfg config.Config + eng *flinger.Engine + lock bool // serialises start/stop against UI actions + + // UI state. + startBtn *widget.Button + statusLab *widget.Label + portEnt *widget.Entry + qualitySel *widget.Select + fpsSel *widget.Select + nameEnt *widget.Entry + audioChk *widget.Check + srcSel *widget.Select +} + +// Run starts the GUI and blocks until the app exits. +func Run() { + g := &App{} + + g.fyneApp = app.NewWithID(appID) + g.win = g.fyneApp.NewWindow("TeleportFling") + + // Load persisted settings (falling back to defaults). + g.cfg = mustLoadConfig() + + g.buildUI() + + // Closing the window hides it (tray keeps the app alive) rather than + // quitting, which is the expected behaviour for a tray app. + g.win.SetCloseIntercept(func() { + g.win.Hide() + }) + + g.setupTray() + + g.win.ShowAndRun() +} + +// mustLoadConfig loads the config, logging and falling back to defaults. +func mustLoadConfig() config.Config { + c, err := config.Load() + if err != nil { + log.Printf("gui: config load: %v (using defaults)", err) + return config.Default() + } + return c +} + +// buildUI creates the settings form and wires its widgets to cfg. +func (g *App) buildUI() { + // Stream identity. + g.nameEnt = widget.NewEntry() + g.nameEnt.SetPlaceHolder("Stream name (default: hostname)") + g.nameEnt.SetText(g.cfg.Name) + + // Port. + g.portEnt = widget.NewEntry() + g.portEnt.SetText(itoa(g.cfg.Port)) + g.portEnt.Validator = func(s string) error { + n, err := atoi(s) + if err != nil || n < 1 || n > 65535 { + return errInvalidPort + } + return nil + } + + // Source. + g.srcSel = widget.NewSelect([]string{"screen", "pattern"}, func(string) {}) + g.srcSel.SetSelected(g.cfg.Source) + + // Quality. + g.qualitySel = widget.NewSelect([]string{"50", "60", "70", "80", "90", "100"}, func(string) {}) + g.qualitySel.SetSelected(itoa(g.cfg.Quality)) + + // FPS. + g.fpsSel = widget.NewSelect([]string{"15", "30", "60"}, func(string) {}) + g.fpsSel.SetSelected(itoa(g.cfg.FPS)) + + // Audio. + g.audioChk = widget.NewCheck("Capture system audio", nil) + g.audioChk.SetChecked(g.cfg.Audio) + + // Status label. + g.statusLab = widget.NewLabel("Stopped") + g.statusLab.Importance = widget.MediumImportance + + // Start/stop toggle. + g.startBtn = widget.NewButton("Start", g.toggleStream) + g.startBtn.Importance = widget.HighImportance + + form := &widget.Form{ + Items: []*widget.FormItem{ + {Text: "Name", Widget: g.nameEnt}, + {Text: "Port", Widget: g.portEnt}, + {Text: "Source", Widget: g.srcSel}, + {Text: "Quality", Widget: g.qualitySel}, + {Text: "Frame rate", Widget: g.fpsSel}, + {Text: "", Widget: g.audioChk}, + }, + } + + content := container.NewVBox( + widget.NewLabelWithStyle("TeleportFling", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), + form, + g.statusLab, + g.startBtn, + ) + + g.win.SetContent(container.NewBorder(nil, content, nil, nil, nil)) + g.win.Resize(fyne.NewSize(380, 0)) +} + +// toggleStream starts or stops the engine based on current UI state. +func (g *App) toggleStream() { + if g.lock { + return + } + if g.eng != nil { + g.stop() + return + } + g.start() +} + +// start reads the form into cfg, saves it, and boots the engine. +func (g *App) start() { + port, _ := atoi(g.portEnt.Text) + quality, _ := atoi(g.qualitySel.Selected) + fps, _ := atoi(g.fpsSel.Selected) + + g.cfg = config.Config{ + Name: g.nameEnt.Text, + Port: port, + Quality: quality, + FPS: fps, + Source: g.srcSel.Selected, + Audio: g.audioChk.Checked, + StreamIndex: g.cfg.StreamIndex, + } + if err := config.Save(g.cfg); err != nil { + log.Printf("gui: config save: %v", err) + } + + eng, err := flinger.New(g.cfg.ToFlinger()) + if err != nil { + dialog.ShowError(err, g.win) + return + } + g.eng = eng + g.eng.Start() + + g.startBtn.SetText("Stop") + g.startBtn.Importance = widget.DangerImportance + g.statusLab.SetText("Streaming (port " + itoa(g.cfg.Port) + ")") + g.statusLab.Importance = widget.SuccessImportance + g.refresh() +} + +// stop halts the engine and returns the UI to the stopped state. +func (g *App) stop() { + if g.eng != nil { + g.eng.Stop() + g.eng = nil + } + g.startBtn.SetText("Start") + g.startBtn.Importance = widget.HighImportance + g.statusLab.SetText("Stopped") + g.statusLab.Importance = widget.MediumImportance + g.refresh() +} + +// refresh forces the window to repaint (state/importance changed). +func (g *App) refresh() { + g.startBtn.Refresh() + g.statusLab.Refresh() +} + +// setupTray registers the system tray menu and window. If the platform driver +// does not support a tray (older Fyne/desktops), this is a no-op and the +// window close intercept simply won't hide. +func (g *App) setupTray() { + desk, ok := g.fyneApp.(desktop.App) + if !ok { + log.Printf("gui: system tray not supported on this desktop") + return + } + + m := fyne.NewMenu("TeleportFling", + fyne.NewMenuItem("Show", func() { + // Show is a no-op if the window is already visible, so also raise + // and focus it to bring it to the foreground. + g.win.Show() + g.win.RequestFocus() + }), + fyne.NewMenuItemSeparator(), + fyne.NewMenuItem("Start", g.toggleStream), + fyne.NewMenuItem("Stop", func() { + if g.eng != nil { + g.stop() + } + }), + fyne.NewMenuItemSeparator(), + fyne.NewMenuItem("Quit", func() { + if g.eng != nil { + g.eng.Stop() + } + g.fyneApp.Quit() + }), + ) + desk.SetSystemTrayMenu(m) + // Left-clicking the tray icon shows the settings window. + desk.SetSystemTrayWindow(g.win) + g.win.SetOnClosed(func() { g.fyneApp.Quit() }) +}