feat: add tray streaming status and app icon

- tray icon and tooltip now reflect engine state: grey when stopped,
  green + live frame/drop counters in the tooltip while streaming
- generate a proper app icon (assets/, via cmd/teleportfling-icon) and
  embed it so the window and tray carry the icon without runtime files
- add a .desktop entry template for launching from an app menu
- run Fyne UI updates (status label, tray icon/tooltip) on the main
  thread via fyne.Do to satisfy the threading model
This commit is contained in:
2026-09-18 21:20:52 +01:00
parent 2b3fd54934
commit bb4073f8a3
8 changed files with 269 additions and 1 deletions
+37 -1
View File
@@ -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)
}
+83
View File
@@ -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 }