// 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" "fmt" "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/capture" "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) } // scaleLabel renders a scale factor as a percentage option. func scaleLabel(scale float64) string { switch { case scale >= 0.95: return "100%" case scale >= 0.7: return "75%" case scale >= 0.45: return "50%" default: return "25%" } } // parseScale converts a scale option label back to a factor. func parseScale(label string) float64 { switch label { case "75%": return 0.75 case "50%": return 0.5 case "25%": return 0.25 default: return 1.0 } } // 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 presetSel *widget.Select scaleSel *widget.Select nameEnt *widget.Entry audioChk *widget.Check audioSel *widget.Select audioDevs []capture.AudioDevice announceChk *widget.Check srcSel *widget.Select monSel *widget.Select monitors []capture.Monitor statsDone chan struct{} // lastStart records the capture-affecting config the current engine was // created with, so Start can resume instead of reopening the portal. lastStart config.Config } // 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) g.setupMonitorPicker() // Quality. g.qualitySel = widget.NewSelect([]string{"50", "60", "70", "80", "90", "100"}, g.applyLiveSettings) g.qualitySel.SetSelected(itoa(g.cfg.Quality)) // FPS. g.fpsSel = widget.NewSelect([]string{"15", "30", "60"}, g.applyLiveSettings) g.fpsSel.SetSelected(itoa(g.cfg.FPS)) // Scale: downsampling factor (1.0 = native, 0.5 = half, etc). g.scaleSel = widget.NewSelect([]string{"100%", "75%", "50%", "25%"}, g.applyLiveSettings) g.scaleSel.SetSelected(scaleLabel(g.cfg.Scale)) // Preset: one-click quality/fps combos. Choosing one sets the Quality and // Frame rate selectors and applies them (live if running). Created after // the quality/fps selects so applyPreset's references are valid. g.presetSel = widget.NewSelect([]string{"Low", "Medium", "High", "Ultra"}, g.applyPreset) g.presetSel.SetSelected("High") // Audio. g.audioChk = widget.NewCheck("Capture system audio", func(bool) { g.applyLiveSettings("") }) g.audioChk.SetChecked(g.cfg.Audio) g.setupAudioPicker() // 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: "Monitor", Widget: g.monSel}, {Text: "Preset", Widget: g.presetSel}, {Text: "Quality", Widget: g.qualitySel}, {Text: "Frame rate", Widget: g.fpsSel}, {Text: "Scale", Widget: g.scaleSel}, {Text: "Audio", Widget: g.audioChk}, {Text: "Audio source", Widget: g.audioSel}, {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)) } // setupMonitorPicker populates the monitor dropdown and ties its visibility // to the source selector. When monitor enumeration is unavailable (non- // Hyprland), it falls back to a raw index entry driven by the saved config. func (g *App) setupMonitorPicker() { // Populate monitor names for the picker. g.monitors, _ = capture.ListMonitors() names := make([]string, 0, len(g.monitors)) for _, m := range g.monitors { names = append(names, fmt.Sprintf("%s (%dx%d)", m.Name, m.Width, m.Height)) } // If we could not enumerate, present the saved index as a single option. if len(names) == 0 { names = []string{fmt.Sprintf("Monitor %d", g.cfg.StreamIndex)} g.monitors = []capture.Monitor{{Index: g.cfg.StreamIndex, Name: fmt.Sprintf("Monitor %d", g.cfg.StreamIndex)}} } g.monSel = widget.NewSelect(names, func(string) {}) if len(g.monitors) > 0 { // Preselect the configured index if it's within range. for i, m := range g.monitors { if m.Index == g.cfg.StreamIndex { g.monSel.SetSelectedIndex(i) break } } } // Monitor picker only applies to the "screen" source. g.srcSel.OnChanged = func(string) { g.monSel.Disable() if g.srcSel.Selected == "screen" { g.monSel.Enable() } } g.monSel.Disable() if g.cfg.Source == "screen" { g.monSel.Enable() } } // selectedMonitorIndex returns the monitor index chosen in the picker, or the // saved config value when the picker is unavailable/disabled. func (g *App) selectedMonitorIndex() int { if g.monSel != nil && g.monSel.SelectedIndex() >= 0 && g.monSel.SelectedIndex() < len(g.monitors) { return g.monitors[g.monSel.SelectedIndex()].Index } return g.cfg.StreamIndex } // setupAudioPicker populates the audio source dropdown from PipeWire. The // first option is "Default output"; the rest are the enumerated sinks and // microphones. A no-op if enumeration is unavailable. func (g *App) setupAudioPicker() { g.audioDevs, _ = capture.ListAudioSources() names := []string{"Default output"} for _, d := range g.audioDevs { label := d.Desc if label == "" { label = d.Name } if d.IsOutput { label = "Output: " + label } else { label = "Input: " + label } names = append(names, label) } g.audioSel = widget.NewSelect(names, func(string) { g.applyLiveSettings("") }) // Preselect the configured serial if it matches an enumerated device. if g.cfg.AudioSource > 0 { for i, d := range g.audioDevs { if d.Serial == g.cfg.AudioSource { g.audioSel.SetSelectedIndex(i + 1) break } } } else { g.audioSel.SetSelectedIndex(0) } } // selectedAudioSerial returns the PipeWire serial chosen in the picker, or 0 // for the default output. func (g *App) selectedAudioSerial() uint64 { if g.audioSel == nil || g.audioSel.SelectedIndex() <= 0 { return 0 } idx := g.audioSel.SelectedIndex() - 1 if idx >= 0 && idx < len(g.audioDevs) { return g.audioDevs[idx].Serial } return 0 } // applyPreset applies a named quality/fps preset to the Quality and Frame // rate selectors, then pushes it live if the engine is running. func (g *App) applyPreset(string) { var q, f int switch g.presetSel.Selected { case "Low": q, f = 50, 15 case "Medium": q, f = 70, 30 case "Ultra": q, f = 100, 60 default: // High q, f = 85, 30 } g.qualitySel.SetSelected(itoa(q)) g.fpsSel.SetSelected(itoa(f)) g.applyLiveSettings("") } // applyLiveSettings pushes the current form values (quality, fps, name, // audio, announce) into a running engine via SetConfig so changes take // effect without restarting the stream. When the engine is not running it is // a no-op; the values are still captured on the next Start. func (g *App) applyLiveSettings(string) { if g.eng == nil { return } quality, _ := atoi(g.qualitySel.Selected) fps, _ := atoi(g.fpsSel.Selected) announce := g.announceChk.Checked cfg := flinger.Config{ Name: g.nameEnt.Text, Port: g.cfg.Port, Quality: quality, FPS: fps, Source: g.cfg.Source, Audio: g.audioChk.Checked, AudioSource: g.selectedAudioSerial(), StreamIndex: g.cfg.StreamIndex, Scale: parseScale(g.scaleSel.Selected), Announce: announce, } if err := g.eng.SetConfig(cfg); err != nil { log.Printf("gui: live settings: %v", err) return } // Keep the persisted config in sync with what we just applied. g.cfg.Quality = quality g.cfg.FPS = fps g.cfg.Audio = cfg.Audio g.cfg.AudioSource = cfg.AudioSource g.cfg.Name = cfg.Name g.cfg.Scale = cfg.Scale ann := announce g.cfg.Announce = &ann } // toggleStream starts or stops the engine based on current UI state. func (g *App) toggleStream() { if g.lock { return } // Engine exists and is currently running → pause it. if g.eng != nil && g.eng.Status().Running { g.stop() return } g.start() } // start reads the form into cfg, saves it, and boots the engine. If an engine // already exists and the capture-affecting settings are unchanged, it resumes // the paused engine instead of reopening the portal session (which cannot be // reliably re-created in-process). func (g *App) start() { port, _ := atoi(g.portEnt.Text) quality, _ := atoi(g.qualitySel.Selected) fps, _ := atoi(g.fpsSel.Selected) announce := g.announceChk.Checked newCfg := config.Config{ Name: g.nameEnt.Text, Port: port, Quality: quality, FPS: fps, Source: g.srcSel.Selected, Audio: g.audioChk.Checked, AudioSource: g.selectedAudioSerial(), StreamIndex: g.selectedMonitorIndex(), Scale: parseScale(g.scaleSel.Selected), Announce: &announce, } g.cfg = newCfg 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) } // Resume the existing engine if capture-affecting fields are unchanged. if g.eng != nil && captureConfigEqual(g.lastStart, newCfg) { // Push live-applicable changes, then resume. g.applyLiveSettings("") g.eng.Resume() g.startedUI(newCfg) return } // Otherwise close any existing engine and create a fresh one. if g.eng != nil { g.eng.Close() g.eng = nil } eng, err := flinger.New(g.cfg.ToFlinger()) if err != nil { dialog.ShowError(err, g.win) return } g.eng = eng g.lastStart = newCfg g.eng.Start() g.startedUI(newCfg) } // captureConfigEqual reports whether two configs agree on the fields that // require reopening the capture session (source, port, monitor, audio // source). Live-applicable fields (quality, fps, scale, name, announce, // audio-on) are ignored. func captureConfigEqual(a, b config.Config) bool { return a.Source == b.Source && a.Port == b.Port && a.StreamIndex == b.StreamIndex && a.AudioSource == b.AudioSource } // startedUI updates the UI to the streaming state after a start/resume. func (g *App) startedUI(cfg config.Config) { g.startBtn.SetText("Stop") g.startBtn.Importance = widget.DangerImportance g.statusLab.SetText("Streaming (port " + itoa(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)) if st.Err != nil { g.statusLab.Importance = widget.DangerImportance } else { g.statusLab.Importance = widget.SuccessImportance } tip := "TeleportFling · " + frames + " frames, " + dropped + " dropped" if st.Bitrate > 0 { tip += " · " + formatBitrate(st.Bitrate) } if st.Err != nil { tip += " · error" } g.setTrayState(true, tip) }) 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" } if st.Bitrate > 0 { base += " · " + formatBitrate(st.Bitrate) } if st.Err != nil { base += "\nError: " + st.Err.Error() } return base } // formatBitrate renders a bits/sec value in a human-readable form. func formatBitrate(bps int64) string { switch { case bps >= 1_000_000: return fmt.Sprintf("%.1f Mbps", float64(bps)/1_000_000) case bps >= 1_000: return fmt.Sprintf("%.0f kbps", float64(bps)/1_000) default: return fmt.Sprintf("%d bps", bps) } } // stop pauses the engine (keeping the portal session open) 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.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) }