Files
TeleportFling/internal/gui/gui.go
T
petere 2b3fd54934 feat: harden engine config, discovery and backpressure stats
- config: validate port/quality/fps/source/stream-index ranges on load
  and before engine start (flinger.Config.Validate)
- discovery: add Announce option to disable multicast (GUI checkbox,
  CLI --no-announce); absent JSON key keeps the default true
- backpressure: count dropped frames in the TCP sender, expose via
  engine Status and a live counter in the GUI status label
- docs: record M3/M4 decisions and X11 coverage via the portal backend
2026-09-18 20:44:39 +01:00

305 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"time"
"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
announceChk *widget.Check
srcSel *widget.Select
statsDone chan struct{}
}
// 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)
// Announce over multicast.
g.announceChk = widget.NewCheck("Announce on LAN", nil)
announce := true
if g.cfg.Announce != nil {
announce = *g.cfg.Announce
}
g.announceChk.SetChecked(announce)
// 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},
{Text: "", Widget: g.announceChk},
},
}
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)
announce := g.announceChk.Checked
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,
Announce: &announce,
}
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()
g.watchStats()
}
// watchStats refreshes the status label with live counters while streaming.
func (g *App) watchStats() {
done := make(chan struct{})
g.statsDone = done
go func() {
tick := time.NewTicker(2 * time.Second)
defer tick.Stop()
for {
select {
case <-tick.C:
if g.eng == nil {
return
}
st := g.eng.Status()
g.statusLab.SetText(formatStatus(st))
case <-done:
return
}
}
}()
}
// formatStatus renders the live status line.
func formatStatus(st flinger.Status) string {
base := "Streaming · " + itoa(int(st.Frames)) + " frames"
if st.Dropped > 0 {
base += " · " + itoa(int(st.Dropped)) + " dropped"
}
return base
}
// stop halts the engine and returns the UI to the stopped state.
func (g *App) stop() {
if g.statsDone != nil {
close(g.statsDone)
g.statsDone = nil
}
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() })
}