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.
This commit is contained in:
2026-09-18 20:32:49 +01:00
parent ba423eb944
commit cb27a0d63d
2 changed files with 268 additions and 0 deletions
+14
View File
@@ -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()
}
+254
View File
@@ -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 165535")
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() })
}