Files
TeleportFling/internal/gui/gui.go
T
petere 34efbaf26d feat: support custom --config path in CLI and GUI
Add config.LoadFrom/SaveTo for arbitrary paths and thread a --config
flag through both entry points:
- CLI: --config loads a file first; explicit flags override file values
- GUI: --config selects the settings file used for load and save
- tests for LoadFrom/SaveTo and default fallback
2026-09-19 08:35:07 +01:00

358 lines
9.3 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"
"image/color"
"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"
"fyne.io/systray"
"teleportfling/assets"
"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
desk desktop.App
// configPath overrides the default config location ("" = default).
configPath string
cfg config.Config
eng *flinger.Engine
lock bool // serialises start/stop against UI actions
// Tray state icons, built once at startup.
iconIdle, iconActive *staticResource
// 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. configPath selects a
// non-default settings file ("" uses the default location).
func Run(configPath string) {
g := &App{configPath: configPath}
g.fyneApp = app.NewWithID(appID)
g.win = g.fyneApp.NewWindow("TeleportFling")
g.fyneApp.SetIcon(fyne.NewStaticResource("teleportfling", assets.AppIcon))
g.iconIdle = newTrayResource(color.NRGBA{R: 90, G: 90, B: 95, A: 255}, color.NRGBA{R: 60, G: 60, B: 65, A: 255})
g.iconActive = newTrayResource(color.NRGBA{R: 46, G: 125, B: 50, A: 255}, color.NRGBA{R: 150, G: 220, B: 140, A: 255})
// Load persisted settings (falling back to defaults).
g.cfg = g.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 (from the configured path, or the default),
// logging and falling back to defaults on error.
func (g *App) mustLoadConfig() config.Config {
var (
c config.Config
err error
)
if g.configPath != "" {
c, err = config.LoadFrom(g.configPath)
} else {
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 g.configPath != "" {
if err := config.SaveTo(g.configPath, g.cfg); err != nil {
log.Printf("gui: config save: %v", err)
}
} else 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.setTrayState(true, "TeleportFling · Streaming")
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()
frames := itoa(int(st.Frames))
dropped := itoa(int(st.Dropped))
// Fyne UI calls must run on the main thread.
fyne.Do(func() {
g.statusLab.SetText(formatStatus(st))
g.setTrayState(true, "TeleportFling · "+frames+" frames, "+dropped+" dropped")
})
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.setTrayState(false, "TeleportFling")
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
}
g.desk = desk
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() })
// Show the "stopped" state icon initially.
g.setTrayState(false, "TeleportFling")
}
// setTrayState swaps the tray icon and tooltip to reflect the streaming state.
// It is a no-op if the tray is unavailable.
func (g *App) setTrayState(streaming bool, tooltip string) {
if g.desk == nil || g.iconIdle == nil || g.iconActive == nil {
return
}
icon := fyne.Resource(g.iconIdle)
if streaming {
icon = g.iconActive
}
g.desk.SetSystemTrayIcon(icon)
systray.SetTooltip(tooltip)
}