diff --git a/assets/assets.go b/assets/assets.go new file mode 100644 index 0000000..3ae2383 --- /dev/null +++ b/assets/assets.go @@ -0,0 +1,23 @@ +// Package assets embeds the TeleportFling application icon. +// +// The PNGs live in the repository assets/ directory and are generated by +// cmd/teleportfling-icon. Embedding keeps the icon with the binary so the GUI +// and tray never depend on a runtime file path. +package assets + +import _ "embed" + +// AppIcon256 is the 256px application icon. +// +//go:embed teleportfling-256.png +var AppIcon256 []byte + +// AppIcon64 is the 64px icon used by the system tray. +// +//go:embed teleportfling-64.png +var AppIcon64 []byte + +// AppIcon is the full-resolution (512px) application icon. +// +//go:embed teleportfling.png +var AppIcon []byte diff --git a/assets/teleportfling-256.png b/assets/teleportfling-256.png new file mode 100644 index 0000000..03d9966 Binary files /dev/null and b/assets/teleportfling-256.png differ diff --git a/assets/teleportfling-64.png b/assets/teleportfling-64.png new file mode 100644 index 0000000..fd0b7a0 Binary files /dev/null and b/assets/teleportfling-64.png differ diff --git a/assets/teleportfling.desktop b/assets/teleportfling.desktop new file mode 100644 index 0000000..ed1fbfd --- /dev/null +++ b/assets/teleportfling.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=TeleportFling +GenericName=Screen and audio stream sender +Comment=Stream this screen and audio to an OBS Teleport receiver +Exec=teleportfling-gui +Icon=teleportfling +Terminal=false +Categories=AudioVideo;Video;Network; +StartupNotify=false +StartupWMClass=io.teleportfling \ No newline at end of file diff --git a/assets/teleportfling.png b/assets/teleportfling.png new file mode 100644 index 0000000..bc9530b Binary files /dev/null and b/assets/teleportfling.png differ diff --git a/cmd/teleportfling-icon/main.go b/cmd/teleportfling-icon/main.go new file mode 100644 index 0000000..d4890d1 --- /dev/null +++ b/cmd/teleportfling-icon/main.go @@ -0,0 +1,115 @@ +// Command teleportfling-icon regenerates the application icon assets in +// assets/ from scratch. It draws a rounded green square with two lighter +// "fling" dots suggesting motion. +// +// Usage: +// +// teleportfling-icon +// +// The generated PNGs are committed to the repo and embedded into the binary +// via the assets package; this tool exists so the icon can be tweaked and +// regenerated without a design tool. +package main + +import ( + "bytes" + "image" + "image/color" + "image/png" + "os" +) + +// sizes are the icon sizes to emit. Hosts scale from these; 512 is used for +// the app icon, 256 and 64 for smaller contexts. +var sizes = []int{512, 256, 64} + +// outputDir is where the PNGs are written (the repo assets directory). +const outputDir = "assets" + +// inRoundedRect reports whether (x,y) lies inside a square with rounded +// corners of the given radius. +func inRoundedRect(x, y, radius, size int) bool { + m := size - 1 + switch { + case x >= radius && x <= m-radius: + return true + case y >= radius && y <= m-radius: + return true + } + var cx, cy int + if x < radius { + cx = radius + } else { + cx = m - radius + } + if y < radius { + cy = radius + } else { + cy = m - radius + } + dx, dy := x-cx, y-cy + return dx*dx+dy*dy <= radius*radius +} + +// render draws the icon at the given size into a PNG. +func render(size int, bg, dot color.NRGBA) []byte { + img := image.NewNRGBA(image.Rect(0, 0, size, size)) + radius := size / 5 + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + if inRoundedRect(x, y, radius, size) { + img.SetNRGBA(x, y, bg) + } + } + } + // Two dots suggesting a flung stream, offset diagonally. + for i, off := range []int{3, 9} { + cx, cy, r := size/2, size/2+off*size/32, size/8-i + for y := cy - r; y <= cy+r; y++ { + for x := cx - r; x <= cx+r; x++ { + if (x-cx)*(x-cx)+(y-cy)*(y-cy) <= r*r { + img.SetNRGBA(x, y, dot) + } + } + } + } + var buf bytes.Buffer + _ = png.Encode(&buf, img) + return buf.Bytes() +} + +func main() { + bg := color.NRGBA{R: 46, G: 125, B: 50, A: 255} // teleport green + dot := color.NRGBA{R: 150, G: 220, B: 140, A: 255} + + //nolint:gosec // writing generated PNG assets into the repo, not secrets + if err := os.MkdirAll(outputDir, 0o755); err != nil { + panic(err) + } + + for _, size := range sizes { + name := "teleportfling" + if size != 512 { + name += "-" + itoa(size) + } + path := outputDir + "/" + name + ".png" + //nolint:gosec // generated PNG assets, world-readable for installers + if err := os.WriteFile(path, render(size, bg, dot), 0o644); err != nil { + panic(err) + } + } +} + +func itoa(v int) string { + if v == 0 { + return "0" + } + var buf [10]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + return string(buf[i:]) +} diff --git a/internal/gui/gui.go b/internal/gui/gui.go index 6d93fa2..77477ec 100644 --- a/internal/gui/gui.go +++ b/internal/gui/gui.go @@ -8,6 +8,7 @@ package gui import ( "errors" + "image/color" "log" "strconv" "time" @@ -18,7 +19,9 @@ import ( "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" ) @@ -38,11 +41,15 @@ const appID = "io.teleportfling" type App struct { fyneApp fyne.App win fyne.Window + desk desktop.App 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 @@ -62,6 +69,9 @@ func Run() { 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 = mustLoadConfig() @@ -207,6 +217,7 @@ func (g *App) start() { 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() } @@ -225,7 +236,13 @@ func (g *App) watchStats() { return } st := g.eng.Status() - g.statusLab.SetText(formatStatus(st)) + 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 } @@ -256,6 +273,7 @@ func (g *App) stop() { g.startBtn.Importance = widget.HighImportance g.statusLab.SetText("Stopped") g.statusLab.Importance = widget.MediumImportance + g.setTrayState(false, "TeleportFling") g.refresh() } @@ -274,6 +292,7 @@ func (g *App) setupTray() { log.Printf("gui: system tray not supported on this desktop") return } + g.desk = desk m := fyne.NewMenu("TeleportFling", fyne.NewMenuItem("Show", func() { @@ -301,4 +320,21 @@ func (g *App) setupTray() { // 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) } diff --git a/internal/gui/icon.go b/internal/gui/icon.go new file mode 100644 index 0000000..51a4d1d --- /dev/null +++ b/internal/gui/icon.go @@ -0,0 +1,83 @@ +// 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, the tray menu and the +// tray status icon. +package gui + +import ( + "bytes" + "image" + "image/color" + "image/png" +) + +// traySize is the icon size in pixels. Tray hosts typically request 22px; +// we render larger and let the host scale down for crispness. +const traySize = 64 + +// newTrayResource renders a rounded square with a status dot into a +// fyne.Resource that SetSystemTrayIcon accepts. +func newTrayResource(bg, dot color.NRGBA) *staticResource { + img := image.NewRGBA(image.Rect(0, 0, traySize, traySize)) + + // Rounded-square background. + radius := 14 + for y := 0; y < traySize; y++ { + for x := 0; x < traySize; x++ { + if inRoundedRect(x, y, radius) { + img.Set(x, y, bg) + } + } + } + + // Center status dot. + cx, cy, r := traySize/2, traySize/2, 16 + for y := cy - r; y <= cy+r; y++ { + for x := cx - r; x <= cx+r; x++ { + if (x-cx)*(x-cx)+(y-cy)*(y-cy) <= r*r { + img.Set(x, y, dot) + } + } + } + + var buf bytes.Buffer + _ = png.Encode(&buf, img) + return &staticResource{name: "teleportfling-tray", data: buf.Bytes()} +} + +// inRoundedRect reports whether (x,y) falls inside the rounded square. +func inRoundedRect(x, y, radius int) bool { + const m = traySize - 1 + switch { + case x >= radius && x <= m-radius: + return true + case y >= radius && y <= m-radius: + return true + } + // Corner: within radius of the nearest corner centre. + var cx, cy int + if x < radius { + cx = radius + } else { + cx = m - radius + } + if y < radius { + cy = radius + } else { + cy = m - radius + } + dx, dy := x-cx, y-cy + return dx*dx+dy*dy <= radius*radius +} + +// staticResource is a simple in-memory fyne.Resource. +type staticResource struct { + name string + data []byte +} + +func (r *staticResource) Name() string { return r.name } +func (r *staticResource) Content() []byte { return r.data }