From bb4073f8a3e6e3bf063b15d51fefca771cd92803 Mon Sep 17 00:00:00 2001 From: Peter Edley Date: Fri, 18 Sep 2026 21:20:52 +0100 Subject: [PATCH] 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 --- assets/assets.go | 23 +++++++ assets/teleportfling-256.png | Bin 0 -> 1719 bytes assets/teleportfling-64.png | Bin 0 -> 431 bytes assets/teleportfling.desktop | 11 ++++ assets/teleportfling.png | Bin 0 -> 3855 bytes cmd/teleportfling-icon/main.go | 115 +++++++++++++++++++++++++++++++++ internal/gui/gui.go | 38 ++++++++++- internal/gui/icon.go | 83 ++++++++++++++++++++++++ 8 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 assets/assets.go create mode 100644 assets/teleportfling-256.png create mode 100644 assets/teleportfling-64.png create mode 100644 assets/teleportfling.desktop create mode 100644 assets/teleportfling.png create mode 100644 cmd/teleportfling-icon/main.go create mode 100644 internal/gui/icon.go 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 0000000000000000000000000000000000000000..03d9966ccb14e5ef94587ff01e2d026db66977a5 GIT binary patch literal 1719 zcmeAS@N?(olHy`uVBq!ia0y~yU}OMc4mJh`hM1xiX$%Z(b)GJcAr*7p+}%Aux+d6=}KGgiZck`8TtIS8NGC66bGDe=%j3M2*Eg z$=e4WN;f=gFP>u<-OhI4!{MEM^ENt%aW(T_w|+D=;X6omuX0*`I0KLUnyuw*@A(=Y zwo83G^C*_l;7`Emy~dR{{Tmyr^7tB-+g~X;pL0)y;V}Q!yH<;5Gbj95*dCpBHJw2q zCz!$J@51S8jen;x@YsLZl3KQx$zek{^MjZCd-s|i)(6?kv;OR+a0ZTVISe*`6Q@m2 zyT!(EkcFY?uPDQ7`!iqe1zerTnP9mdhaE!=NyO z!C}LE#t;AaBjwWXL3DF6un047sKKNS7#k9p7!Jz);Swmi3Dy7=VF8|321?RhK^O*RGJ;CfLxniZA+V@kgWe480{%2J2aDZ!pYyVe;p_ z)I9U43?>&!XY871-N_(wy|!c5{CX=;;0D~7vi$j!Y9AE_S6}H_ncrtRF>uYY(g}Wl zbtZ#@0i#CRui&Cze|HIjBBgbSjco9{n~h)zrMD|*l-*y26yB^$_VF*jw_6BgEI273 zMW&ede=VKA|5kGzNP79d(e2mYaBOk6pyA5;LqYFjJ3%&pW1eL}$CmF8xBL!M1tkeC z&q}RjcQ!MFYzR2>W%=VTze| zX?<0h{r?iC3l7&6Usb(RVY=Y5J@Hl9y%44g9@7`TD)M7lFjen=x8L+)C5GG*=~a2p zeN`EFOJtX=d+w*iaI{o@(YxpK{TR4r8`%WEGn@!=o<`QK;Nn}g%LE#lmhqjwQe6s8 zv|Tg*O_@`-KMxu#4NgJV9#5$*Qvf+qVFrW9#BE~B?{3!uc>q0#xIF%P>3qF6mBWE? z<^R6AS9`U=fey+jH-6{G@3UKD$5OCYesexEJY?1%x%4i2D#$er2}}VVr=sQ*o#%sD zowRKI@yqY8pFPj;!&D)qa!#eRgJbagN#K|jG0lxwb~l}iu_0kU(}UuMs%18&%kLz2 z-A}*E77+9O;mf*zFIg5eu5-Tf^^WI1j)vC>V zw}#5Ed-dG=8z{R=t+Gl9J%9AH-5NWF@0Bv!xc=|%JNW+fP2L094)Uwp^S|CtpA9M| z3=VYV9(Zv6!>h^v9zXZLzuub5-Ab9QZQt_iX1~nCYXx8A`}6SB=I8G{SIx@t;g-Pv zj%LO4TVEcwi)Q$;`RU<5zWbR!tY!GJsWk1c07p|UUxO|~)sED#{96nz3|YHN-SX>L z1sGSnoOkn!Bm>KCZH8?OE1uq)xu=?0fN@3Qd7XDvtOAS?+3$JY{o`?9N$6Ehdw=2K z*U5<%_b2k-b>7dqf%gEYNIkrtO@z_nxa8*2R~xES8~&cFSuyXIv%+nLH^tw0V$Bw8 zW4!ThRr~W)d)95d2h76Hotm_83-5vZVKyq$?WZ1XNLg+E_$gzB3X4jl?T>yhnMaz2 z35&Fx|L-nhuj*5{5O}n)e$^kwd_|F%g@+UJzxp#UF#P|2c*fpUKU9{eGcYhPc)I$z JtaD0e0sv}fva|pI literal 0 HcmV?d00001 diff --git a/assets/teleportfling-64.png b/assets/teleportfling-64.png new file mode 100644 index 0000000000000000000000000000000000000000..fd0b7a0fcee1e17be52537000f352ee3de54d495 GIT binary patch literal 431 zcmeAS@N?(olHy`uVBq!ia0y~yU~m9o4mJh`hE&2izDpux$DVCeg%wumc)F^4f{7JI?A zo@oB&XN+@V&qe9COEKjz<|r`A=q4Y@3lMF{I&o|EyK_vF4~l$p;oyC6%T)GfkHd0D z4{h1S3{-tOo-I!Q+&y`_g6U43(ii#7 zL3&knQ{wxUi^_Y6@7eTi$4nO6_6eV-#j@J81$C Ua=x%K^{?9AE*S}}j;ukJ?fBma1RdrQ~9`pCt z{k-;DlELf8@9q2TzSgnXZPJ`7`Gny(NBb_FJViEJ2kAo#c)BYlH1Zp;ITrM|-ac`F zeMYmwjblo-2``w>`10IIPrPUNh4GpS+r3Q^ZFUYah5~X7Z1xV@o(OdOdBA4Ra9zk* z;Tf~^FNU0nLd>V@J(}ekl*9NNQ|;#*~=$D9s^H3gPZx~HM5i$(xL(wHk~!v+_rDkj8s9!2h%3TbMjP!we6mmescn& z!KWt119FScZF$I)aHW;0VX@ns+{1fjOB5C|EZ7pbPu$=LFUaPuz_hsU3=*k|3=5_T zro~@pkVs@?SkQjYctbS9mwyi!E<~S?yrBxRxZ-NIni)UCVR?pouMDMUvo$>AXV|`V z-o&#^2R^Vj9FID8>fL`{0fvnAo(vX$nGcw)O-uXAVDN`AVZPYr%~=c*{}>8<-bzO4 zg3Pj5{!HhqssjT@h$_RJe~b%yqKvmzfRqG;_1=s!0V!Fr_)E`MSq27<5FN0Rb(1!4 z^8hKi;I?w+Yf%OU7Asi>zyAyu!a8qOYk_pUc>IRv8y5oulPn*@@_L3Bug~a|F*7hU z^078twrA*h_`>I&EJK?;L(kI_Ibd~fxj^b9F5i=U%f`UKlnd4-aeH1Qn9Iq)z_CS> zA?82x0hw**a?BYR1YTrCikDU;roAv4clHvz6ZG!E-9^`uD5sSpxjdF8udf zzCP^t_Qq{23_k0gYsLv2*u%o$llCOL+L7V20E0o}#P)x-Nesu-7!neU`Tl)xVd(K> zIFJw~@#}jBqeMGHgX0>TANPfsF3i8r*ubd5`+xd@^W4fs%nY)UEqTlf#2Fc6CH}~B zGG?6CX2>}6VgK1(4$*lG3j~?-thX};%$H`!IPl^A@hZn!CI&6zGkqKL0;Y#E1avZn z^@TG9%#&tVao~gg=_;p6CI&9!HLo_T3z){x(8RNH*BbpP$_xtx4zJ{W&6CQ(pm87| z>vYxON+t$Bqn=+I-WBvQGzjx7+_%PFMxEh-!r^qDZ;X9{3=9Gu3X!<*ta+!+q=wVk=~hCfFgoV^=*=jc68n4cic$RI7DRlG6&!yHI)Ft1?NoXNG# z$03>cLqqVBJ8$Igs53m^JDkk>O)yUwVgavTZ}!IdA0|RHF)(p5R2(n;IO~n}9#x3> z+J2qcoA-ZcMkx4k?rQ*~gi!jZQhv_KeUtr2DHksO zk;yUp?Qg{Bu;u>$ADh1`Uzqq;eI~+8q=F4pcwv-goD7eCTHf&aHn#*#`hD}`8~FkZ z3<91A=hQski{_UDqSp^2dH7zTSIp$v*6j$-9^hTqeLczkp)#b1yz}tN4z}MM+c_C3 z4t$s~`)~7ah|&)&!B6JAk+xBT1S9VpwdYCuA6POnNK0xJZ}8vH3-M7|!7iEEe;ePj zFuX}Q(Q|U&0XrrJK7*WNN%jf$5DR3UT(M;P&2XH9!Q$YDm#2RnE(9f<7HGm@;#s_H zjrkmPh6Mu7u=I7JVAbhgPL`l#xCELExp-D@TBAPYJ1je`d2&6z?%iLG(?U!EebNjX zCqAr)YB8L1Ys0yKHiiZ!o~00zK{ZUnNMPJ~aAk+sZ%%oLO%G(3A2q!hUg3?1(3nEs zJ!ZeT?KzMuKqiKLpX)4|Yumz^7(huJo`l}FVkQYl9gCwHa%hf;ZATXW=;yn*TlOIWk6B-j;-N3!-lz+Gor6aZ>uypeV4&Ou`NjYn&c?~ zhJXnVL#BUiEt(hix⋙r_rQPl79-$U7KBP&TwJE!*4Ub zHkPq4n53P!n-;rZZEXC9&r%GhK6xE@&_3x~P2vCLyZV`IiX`hg6%#%<$M62%JwKXZ zg}NdOPu+^-_w~2SnHiWPf4Uub(7sXT*F^d43>S`ZfmGJI*KK+KVf~*@hK%%M0tP=8 z_AcIkfuq^Z%p{>4xmZx&jPyOdsV)>oLT!#cD8E zBwd+lmcp=yXHNpdhN~ww=iFf5{j8di;Y`YtgY|}Qcn@rq6cb{IQ=1hT%yQtg3pba; zx|wNLP57(MYBMsp8C`y`f5tPm2e*2-c@NA=o9h{D_r-KOBf}Si*AMp3_{8?$)*ESt z12thYY%kY0Walw6tjKIVSikWM`%X0$293<62kQ<0YBxlO-O*t96;ZlO=jXz8J9!x% zbcC}1KYB_2-7Eoygzl^C|6Px?*ZA`=6zHbDi0}S=;Cc&#!?q&_>ji%wT+hkapmVbM zzfv9JJz-{sY@;vtH%ah!FgEOz{PjI@M(+`Z12Q~+b9F8`HZdgdu>CU>TPxtmaG|X+ zzRh)wj|78E@`dZEh3u*_4WSZGl7H1RFfjc8e`sBXtMs<3%?u0-44$rjF6*2UngFk- B1|a|d literal 0 HcmV?d00001 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 }