diff --git a/cmd/teleportfling/main.go b/cmd/teleportfling/main.go index 500ebbd..d1b7b89 100644 --- a/cmd/teleportfling/main.go +++ b/cmd/teleportfling/main.go @@ -39,6 +39,7 @@ func main() { fps = flag.Int("fps", 30, "video frames per second") source = flag.String("source", "screen", "capture source: screen or pattern") withAudio = flag.Bool("audio", true, "capture and stream system audio") + noAnnounce = flag.Bool("no-announce", false, "do not announce on the LAN (receiver must connect by IP)") streamIndex = flag.Int("stream-index", 0, "monitor index to capture (screen source)") duration = flag.Duration("duration", 0, "stream duration (0 = run until interrupted)") ) @@ -52,6 +53,7 @@ func main() { Source: *source, Audio: *withAudio, StreamIndex: *streamIndex, + Announce: !*noAnnounce, } eng, err := flinger.New(cfg) diff --git a/internal/config/config.go b/internal/config/config.go index f7690e1..978e9d7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,11 +23,15 @@ type Config struct { Source string `json:"source"` Audio bool `json:"audio"` StreamIndex int `json:"stream_index"` + // Announce is a *bool so an absent JSON key (older config files) keeps + // the default instead of silently disabling announcements. + Announce *bool `json:"announce"` } // Default returns the default configuration. func Default() Config { c := flinger.DefaultConfig() + announce := c.Announce return Config{ Name: c.Name, Port: c.Port, @@ -36,11 +40,16 @@ func Default() Config { Source: c.Source, Audio: c.Audio, StreamIndex: c.StreamIndex, + Announce: &announce, } } // ToFlinger converts a persisted config to the engine config. func (c Config) ToFlinger() flinger.Config { + announce := true + if c.Announce != nil { + announce = *c.Announce + } return flinger.Config{ Name: c.Name, Port: c.Port, @@ -49,6 +58,7 @@ func (c Config) ToFlinger() flinger.Config { Source: c.Source, Audio: c.Audio, StreamIndex: c.StreamIndex, + Announce: announce, } } @@ -96,6 +106,10 @@ func Load() (Config, error) { if c.Source == "" { c.Source = d.Source } + if c.Announce == nil { + announce := true + c.Announce = &announce + } return c, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5c146a3..5fc65e5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -29,6 +29,7 @@ func TestSaveLoadRoundTrip(t *testing.T) { pathVar = filepath.Join(t.TempDir(), "config.json") defer func() { pathVar = old }() + announce := false want := Config{ Name: "Studio", Port: 9898, @@ -37,6 +38,7 @@ func TestSaveLoadRoundTrip(t *testing.T) { Source: "pattern", Audio: false, StreamIndex: 1, + Announce: &announce, } if err := Save(want); err != nil { t.Fatalf("Save: %v", err) @@ -46,7 +48,10 @@ func TestSaveLoadRoundTrip(t *testing.T) { if err != nil { t.Fatalf("Load: %v", err) } - if got != want { + if got.Name != want.Name || got.Port != want.Port || got.Quality != want.Quality || + got.FPS != want.FPS || got.Source != want.Source || got.Audio != want.Audio || + got.StreamIndex != want.StreamIndex || got.Announce == nil || want.Announce == nil || + *got.Announce != *want.Announce { t.Errorf("round trip mismatch:\n got %+v\nwant %+v", got, want) } } @@ -74,4 +79,16 @@ func TestLoadFillsZeroValues(t *testing.T) { if c.Quality != 80 { t.Errorf("quality = %d, want filled default 80", c.Quality) } + // Absent "announce" key must default to true (not false). + if c.Announce == nil || !*c.Announce { + t.Errorf("announce = %v, want default true", c.Announce) + } +} + +// TestToFlingerAnnounceDefault checks the announce default survives conversion. +func TestToFlingerAnnounceDefault(t *testing.T) { + c := Config{} + if !c.ToFlinger().Announce { + t.Error("ToFlinger announce default should be true") + } } diff --git a/internal/flinger/flinger.go b/internal/flinger/flinger.go index 5346cfa..bf9cdc5 100644 --- a/internal/flinger/flinger.go +++ b/internal/flinger/flinger.go @@ -48,23 +48,48 @@ type Config struct { Audio bool // StreamIndex selects which monitor to capture (screen source only). StreamIndex int + // Announce controls whether the stream is advertised via UDP multicast. + // When disabled, receivers must connect by IP manually. + Announce bool } // DefaultConfig returns the recommended defaults. func DefaultConfig() Config { return Config{ - Port: 9756, - Quality: 80, - FPS: 30, - Source: "screen", - Audio: true, + Port: 9756, + Quality: 80, + FPS: 30, + Source: "screen", + Audio: true, + Announce: true, } } +// Validate checks the config for out-of-range or unsupported values. +func (c Config) Validate() error { + if c.Port < 1 || c.Port > 65535 { + return errors.New("port must be 1–65535") + } + if c.Quality < 1 || c.Quality > 100 { + return errors.New("quality must be 1–100") + } + if c.FPS < 1 || c.FPS > 240 { + return errors.New("fps must be 1–240") + } + if c.Source != "screen" && c.Source != "pattern" { + return errors.New("source must be screen or pattern") + } + if c.StreamIndex < 0 { + return errors.New("stream index must be >= 0") + } + return nil +} + // Status is a point-in-time snapshot of the running engine. type Status struct { Running bool Frames int64 + Dropped int64 Conns int } @@ -87,6 +112,10 @@ type Engine struct { // New creates an engine from cfg. Capture is opened eagerly so that // misconfiguration (e.g. no screen-share permission) surfaces before Start. func New(cfg Config) (*Engine, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + e := &Engine{cfg: cfg} sender := output.New() @@ -124,8 +153,8 @@ func New(cfg Config) (*Engine, error) { return e, nil } -// Start begins the audio, video and stats loops and starts announcing the -// stream. It is idempotent. +// Start begins the audio, video and stats loops and (unless disabled in the +// config) starts announcing the stream. It is idempotent. func (e *Engine) Start() { if e.stop != nil { return @@ -134,7 +163,9 @@ func (e *Engine) Start() { e.start = time.Now() e.stop = make(chan struct{}) - e.announcer = discovery.Start(e.cfg.Name, e.sender.Port()) + if e.cfg.Announce { + e.announcer = discovery.Start(e.cfg.Name, e.sender.Port()) + } var src io.ReadCloser switch { @@ -180,6 +211,7 @@ func (e *Engine) Status() Status { return Status{ Running: e.stop != nil, Frames: e.frames.Load(), + Dropped: e.sender.Dropped(), Conns: e.sender.NumConns(), } } @@ -276,7 +308,11 @@ func (e *Engine) statsLoop() { select { case <-tick.C: st := e.Status() - log.Printf("flinger: %d frames, %d conns", st.Frames, st.Conns) + if st.Dropped > 0 { + log.Printf("flinger: %d frames, %d dropped, %d conns", st.Frames, st.Dropped, st.Conns) + } else { + log.Printf("flinger: %d frames, %d conns", st.Frames, st.Conns) + } case <-e.stop: return } diff --git a/internal/flinger/flinger_test.go b/internal/flinger/flinger_test.go index 77a21cd..c0ba4ea 100644 --- a/internal/flinger/flinger_test.go +++ b/internal/flinger/flinger_test.go @@ -11,7 +11,7 @@ import ( func TestEnginePatternStartStop(t *testing.T) { cfg := DefaultConfig() cfg.Source = "pattern" - cfg.Port = 0 // ephemeral + cfg.Port = 19756 // fixed high port for the test eng, err := New(cfg) if err != nil { @@ -77,6 +77,29 @@ func TestNewRejectsBadSource(t *testing.T) { } } +// TestValidate rejects out-of-range values. +func TestValidate(t *testing.T) { + bad := []func(*Config){ + func(c *Config) { c.Port = 0 }, + func(c *Config) { c.Port = 70000 }, + func(c *Config) { c.Quality = 0 }, + func(c *Config) { c.Quality = 101 }, + func(c *Config) { c.FPS = 0 }, + func(c *Config) { c.StreamIndex = -1 }, + } + for i, mutate := range bad { + c := DefaultConfig() + mutate(&c) + if err := c.Validate(); err == nil { + t.Errorf("case %d: expected validation error", i) + } + } + + if err := DefaultConfig().Validate(); err != nil { + t.Errorf("default config should validate: %v", err) + } +} + func itoa(v int) string { if v == 0 { return "0" diff --git a/internal/gui/gui.go b/internal/gui/gui.go index 2e6826e..6d93fa2 100644 --- a/internal/gui/gui.go +++ b/internal/gui/gui.go @@ -10,6 +10,7 @@ import ( "errors" "log" "strconv" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" @@ -43,14 +44,16 @@ type App struct { 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 + 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. @@ -120,6 +123,14 @@ func (g *App) buildUI() { 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 @@ -136,6 +147,7 @@ func (g *App) buildUI() { {Text: "Quality", Widget: g.qualitySel}, {Text: "Frame rate", Widget: g.fpsSel}, {Text: "", Widget: g.audioChk}, + {Text: "", Widget: g.announceChk}, }, } @@ -168,6 +180,7 @@ func (g *App) start() { quality, _ := atoi(g.qualitySel.Selected) fps, _ := atoi(g.fpsSel.Selected) + announce := g.announceChk.Checked g.cfg = config.Config{ Name: g.nameEnt.Text, Port: port, @@ -176,6 +189,7 @@ func (g *App) start() { 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) @@ -194,10 +208,46 @@ func (g *App) start() { 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 diff --git a/internal/output/sender.go b/internal/output/sender.go index 02c5630..6227d07 100644 --- a/internal/output/sender.go +++ b/internal/output/sender.go @@ -16,6 +16,7 @@ import ( "log" "net" "sync" + "sync/atomic" ) const ( @@ -34,6 +35,8 @@ type Sender struct { listener net.Listener port int + + dropped atomic.Int64 } // New creates an unconnected Sender. @@ -86,7 +89,8 @@ func (s *Sender) NumConns() int { // Send broadcasts a serialised packet to all connected receivers. If a // receiver's buffered channel is full (> dropAt) the frame is silently -// dropped; a warning is logged at warnAt. +// dropped; a warning is logged at warnAt. Dropped frames are counted and +// exposed via Dropped. func (s *Sender) Send(b []byte) { s.mu.Lock() defer s.mu.Unlock() @@ -101,6 +105,7 @@ func (s *Sender) Send(b []byte) { } if len(ch) > dropAt { + s.dropped.Add(1) continue } @@ -109,11 +114,17 @@ func (s *Sender) Send(b []byte) { select { case ch <- b: default: + s.dropped.Add(1) log.Printf("output: drop [%s] (queue full)", c.RemoteAddr()) } } } +// Dropped returns the total number of frames dropped due to backpressure. +func (s *Sender) Dropped() int64 { + return s.dropped.Load() +} + // Close shuts down the listener and waits for all writer goroutines to // drain. After Close returns the Sender must not be reused. func (s *Sender) Close() { diff --git a/project.md b/project.md index 5768ddb..571c462 100644 --- a/project.md +++ b/project.md @@ -148,4 +148,25 @@ Components: 5. **End-to-end testing**: OBS + `obs-teleport` are available locally on the dev machine for now (loopback/LAN testing). A remote receiver machine can be added later for further testing. -6. **License**: GPL-2.0, matching the `obs-teleport` project whose protocol we implement. \ No newline at end of file +6. **License**: GPL-2.0, matching the `obs-teleport` project whose protocol we implement. + +## Decisions Made (2026-09-18, M3/M4) + +7. **Screen capture backend**: `go2tv.app/screencast` (MIT, GPL-2.0-compatible) via + `xdg-desktop-portal` + PipeWire. Chosen over hand-rolled cgo bindings. The portal + path is compositor-agnostic: Wayland (hyprland/gnome portals) and X11 + (`xdg-desktop-portal-gtk`) both work, so a separate native X11/XShm fallback is not + required for the supported path and was not implemented (project.md M4 "if feasible"). +8. **GUI toolkit**: Fyne v2 — provides both the settings window and a system tray + (StatusNotifierItem over DBus), so no separate systray dependency was needed. +9. **Engine decoupling**: streaming core lives in `internal/flinger` (GUI-free) so the + CLI, GUI and any future daemon share one implementation. + +## M4 Hardening Status + +- Config persistence + validation: done (internal/config, flinger.Validate). +- Backpressure/queue stats: done (sender tracks dropped frames; exposed in engine + Status and the GUI status label). +- Discovery on/off: done (flinger.Config.Announce, GUI checkbox). +- X11 fallback: not implemented — the portal backend already covers X11 sessions. +- Packaging (Nix package / AppImage): deferred (see AGENTS.md / roadmap). \ No newline at end of file