Skip to content

Commit c8cd986

Browse files
authored
Mirror connection status dot onto lobster tray and desktop icons (#945)
Mirror connection status onto tray and HubWindow icons using the updated badge policy: neutral/error attention states are badged, connected/connecting uses the plain lobster. Validation: local build; GitHub Build and Test, CodeQL, Socket checks all passed. Live proof: current head launched against the maintainer normal profile and tray/taskbar icon behavior was visually confirmed.
1 parent c1bd9ae commit c8cd986

9 files changed

Lines changed: 545 additions & 7 deletions

File tree

src/OpenClaw.Tray.WinUI/App.xaml.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,10 @@ private void InitializeTrayIcon()
766766
// Pre-create tray menu window at startup to avoid creation crashes later
767767
InitializeTrayMenuWindow();
768768

769-
var iconPath = IconHelper.GetStatusIconPath(ConnectionStatus.Disconnected);
769+
// Start with the status-badged lobster (neutral/gray dot) so the tray icon
770+
// mirrors the companion-app status from first paint, even before the first
771+
// connection-state update arrives.
772+
var iconPath = StatusBadgeIconFactory.GetBadgedIconPath(ConnectionStatusAccent.Neutral);
770773
_trayIcon = new TrayIcon(1, iconPath, BuildTrayTooltip());
771774
_trayIconCoordinator = new TrayIconCoordinator(
772775
_trayIcon,
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
using OpenClawTray.Services;
2+
using System;
3+
using System.Collections.Concurrent;
4+
using System.Collections.Generic;
5+
using System.Drawing;
6+
using System.Drawing.Drawing2D;
7+
using System.Drawing.Imaging;
8+
using System.IO;
9+
using System.Runtime.Versioning;
10+
11+
namespace OpenClawTray.Helpers;
12+
13+
/// <summary>
14+
/// Builds application icons that surface the connection status on the lobster
15+
/// mascot as a small badge in the bottom-right corner. To avoid a distracting
16+
/// always-on indicator, only attention states are badged:
17+
/// <list type="bullet">
18+
/// <item>disconnected / neutral → a grey dot,</item>
19+
/// <item>error → a red dot with a white "-" (minus) glyph.</item>
20+
/// </list>
21+
/// Healthy states (connected, connecting) render the plain lobster with no badge.
22+
/// Composed icons are cached per <see cref="ConnectionStatusAccent"/> and written
23+
/// to a temp folder as multi-resolution .ico files so both the tray icon
24+
/// (<see cref="WinUIEx.TrayIcon.SetIcon(string)"/>) and the desktop/taskbar window
25+
/// icon (<c>Window.SetIcon(string)</c>) can consume them.
26+
/// </summary>
27+
[SupportedOSPlatform("windows")]
28+
internal static class StatusBadgeIconFactory
29+
{
30+
private static readonly string AssetsPath = Path.Combine(AppContext.BaseDirectory, "Assets");
31+
private static readonly string OutputDir = Path.Combine(Path.GetTempPath(), "OpenClawTray", "StatusIcons");
32+
private static readonly object Gate = new();
33+
private static readonly ConcurrentDictionary<ConnectionStatusAccent, string> Cache = new();
34+
35+
// Cover the sizes the shell requests for the tray (16-32 by DPI) and the
36+
// taskbar/window (up to 256), so LoadImage always finds a crisp match.
37+
private static readonly int[] Sizes = { 16, 20, 24, 32, 40, 48, 64, 128, 256 };
38+
39+
/// <summary>
40+
/// Returns a filesystem path to a lobster icon badged with the status dot for
41+
/// <paramref name="accent"/>. Falls back to the plain app icon on failure.
42+
/// </summary>
43+
public static string GetBadgedIconPath(ConnectionStatusAccent accent)
44+
{
45+
if (Cache.TryGetValue(accent, out var cached) && File.Exists(cached))
46+
return cached;
47+
48+
lock (Gate)
49+
{
50+
if (Cache.TryGetValue(accent, out cached) && File.Exists(cached))
51+
return cached;
52+
53+
var (path, isFallback) = Build(accent);
54+
55+
// Only cache successfully composed icons. Caching the fallback would
56+
// permanently pin the un-badged icon after a transient GDI+ failure.
57+
if (!isFallback)
58+
Cache[accent] = path;
59+
60+
return path;
61+
}
62+
}
63+
64+
/// <summary>Maps a status accent to its dot colour (mirrors the companion-app dot).</summary>
65+
public static Color DotColor(ConnectionStatusAccent accent) => accent switch
66+
{
67+
ConnectionStatusAccent.Success => Color.FromArgb(76, 175, 80), // Green – connected
68+
ConnectionStatusAccent.Caution => Color.FromArgb(255, 193, 7), // Amber – connecting / attention
69+
ConnectionStatusAccent.Critical => Color.FromArgb(244, 67, 54), // Red – error
70+
_ => Color.FromArgb(158, 158, 158), // Gray – disconnected / neutral
71+
};
72+
73+
/// <summary>
74+
/// Whether <paramref name="accent"/> gets a status badge. Only attention states
75+
/// are badged so the icon is quiet when everything is healthy: disconnected
76+
/// (neutral) and error (critical). Connected/connecting render the plain lobster.
77+
/// </summary>
78+
public static bool ShouldBadge(ConnectionStatusAccent accent) =>
79+
accent is ConnectionStatusAccent.Neutral or ConnectionStatusAccent.Critical;
80+
81+
/// <summary>Whether the badge carries the "-" (minus) glyph. Only the error state does.</summary>
82+
public static bool HasDash(ConnectionStatusAccent accent) =>
83+
accent is ConnectionStatusAccent.Critical;
84+
85+
private static (string Path, bool IsFallback) Build(ConnectionStatusAccent accent)
86+
{
87+
var fallback = Path.Combine(AssetsPath, "openclaw.ico");
88+
try
89+
{
90+
Directory.CreateDirectory(OutputDir);
91+
var outputPath = Path.Combine(OutputDir, $"openclaw-{accent}".ToLowerInvariant() + ".ico");
92+
93+
// Only attention states (neutral/critical) get a dot; healthy states
94+
// render the plain lobster (null dot colour).
95+
Color? dotColor = ShouldBadge(accent) ? DotColor(accent) : null;
96+
97+
using var baseImage = LoadBaseImage();
98+
File.WriteAllBytes(outputPath, CreateIcoBytes(baseImage, dotColor, HasDash(accent), Sizes));
99+
return (outputPath, false);
100+
}
101+
catch (Exception ex)
102+
{
103+
Logger.Warn($"Failed to build status-badged icon for {accent}: {ex.Message}");
104+
return (fallback, true);
105+
}
106+
}
107+
108+
/// <summary>
109+
/// Composes the (optionally badged) lobster at every requested size and packs
110+
/// the frames into a single multi-resolution .ico byte array. A null
111+
/// <paramref name="dotColor"/> renders the plain lobster. Exposed for testing.
112+
/// </summary>
113+
internal static byte[] CreateIcoBytes(Bitmap baseImage, Color? dotColor, bool withDash, IReadOnlyList<int> sizes)
114+
{
115+
var frames = new List<byte[]>(sizes.Count);
116+
foreach (var size in sizes)
117+
{
118+
using var composed = Compose(baseImage, size, dotColor, withDash);
119+
using var ms = new MemoryStream();
120+
composed.Save(ms, ImageFormat.Png);
121+
frames.Add(ms.ToArray());
122+
}
123+
124+
return BuildIco(frames, sizes);
125+
}
126+
127+
/// <summary>The icon sizes baked into every composed .ico container.</summary>
128+
internal static IReadOnlyList<int> IconSizes => Sizes;
129+
130+
private static Bitmap LoadBaseImage()
131+
{
132+
// Prefer the high-resolution unplated mascot PNG (transparent background)
133+
// for crisp scaling; fall back to the multi-size app .ico.
134+
var png = Path.Combine(AssetsPath, "Square44x44Logo.targetsize-256_altform-unplated.png");
135+
if (File.Exists(png))
136+
return new Bitmap(png);
137+
138+
var ico = Path.Combine(AssetsPath, "openclaw.ico");
139+
using var icon = new Icon(ico, 256, 256);
140+
return icon.ToBitmap();
141+
}
142+
143+
/// <summary>
144+
/// The dot diameter as a fraction of the icon size. Tiny tray icons need a
145+
/// proportionally larger dot to stay legible at 16-32px, while large taskbar
146+
/// / alt-tab icons look cleaner with a subtler corner dot. Interpolates
147+
/// between the two extremes. Exposed for testing.
148+
/// </summary>
149+
internal static double DotFraction(int size)
150+
{
151+
const double maxFraction = 0.44; // <= 32px (tray legibility)
152+
const double minFraction = 0.26; // >= 256px (subtle corner dot)
153+
const int minSize = 32;
154+
const int maxSize = 256;
155+
156+
if (size <= minSize)
157+
return maxFraction;
158+
if (size >= maxSize)
159+
return minFraction;
160+
161+
var t = (double)(size - minSize) / (maxSize - minSize);
162+
return maxFraction + (t * (minFraction - maxFraction));
163+
}
164+
165+
internal static Bitmap Compose(Bitmap baseImage, int size, Color? dotColor, bool withDash = false)
166+
{
167+
var bmp = new Bitmap(size, size, PixelFormat.Format32bppArgb);
168+
bmp.SetResolution(96, 96);
169+
170+
using var g = Graphics.FromImage(bmp);
171+
g.SmoothingMode = SmoothingMode.AntiAlias;
172+
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
173+
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
174+
g.CompositingQuality = CompositingQuality.HighQuality;
175+
g.Clear(Color.Transparent);
176+
177+
g.DrawImage(baseImage, new Rectangle(0, 0, size, size));
178+
179+
// No badge for healthy states: render the plain lobster.
180+
if (dotColor is not Color color)
181+
return bmp;
182+
183+
// Dot geometry: bottom-right corner with a white ring for contrast against
184+
// both the red mascot and arbitrary taskbar backgrounds. The dot scales by
185+
// size so it is legible on tiny tray icons yet subtle on large icons; the
186+
// ring is tied to the dot so it shrinks proportionally too.
187+
float dot = size * (float)DotFraction(size);
188+
float ring = Math.Max(1f, dot * 0.14f);
189+
float pad = size * 0.02f;
190+
float outer = dot + (ring * 2f);
191+
float outerX = size - outer - pad;
192+
float outerY = size - outer - pad;
193+
194+
using (var ringBrush = new SolidBrush(Color.White))
195+
g.FillEllipse(ringBrush, outerX, outerY, outer, outer);
196+
197+
using (var dotBrush = new SolidBrush(color))
198+
g.FillEllipse(dotBrush, outerX + ring, outerY + ring, dot, dot);
199+
200+
// Error badge carries a white "-" (minus) glyph centred on the dot.
201+
if (withDash)
202+
{
203+
float cx = outerX + ring + (dot / 2f);
204+
float cy = outerY + ring + (dot / 2f);
205+
float dashW = dot * 0.52f;
206+
float dashH = Math.Max(2f, dot * 0.18f);
207+
using var dashBrush = new SolidBrush(Color.White);
208+
g.FillRectangle(dashBrush, cx - (dashW / 2f), cy - (dashH / 2f), dashW, dashH);
209+
}
210+
211+
return bmp;
212+
}
213+
214+
/// <summary>
215+
/// Packs PNG frames into a Vista-style .ico container (PNG-compressed entries,
216+
/// supported by LoadImage on Windows 10+).
217+
/// </summary>
218+
private static byte[] BuildIco(IReadOnlyList<byte[]> pngFrames, IReadOnlyList<int> sizes)
219+
{
220+
using var ms = new MemoryStream();
221+
using var writer = new BinaryWriter(ms);
222+
223+
var count = (ushort)pngFrames.Count;
224+
writer.Write((ushort)0); // idReserved
225+
writer.Write((ushort)1); // idType = icon
226+
writer.Write(count); // idCount
227+
228+
var offset = 6 + (16 * count);
229+
for (var i = 0; i < pngFrames.Count; i++)
230+
{
231+
var dim = (byte)(sizes[i] >= 256 ? 0 : sizes[i]); // 0 encodes 256
232+
writer.Write(dim); // bWidth
233+
writer.Write(dim); // bHeight
234+
writer.Write((byte)0); // bColorCount
235+
writer.Write((byte)0); // bReserved
236+
writer.Write((ushort)1); // wPlanes
237+
writer.Write((ushort)32); // wBitCount
238+
writer.Write((uint)pngFrames[i].Length); // dwBytesInRes
239+
writer.Write((uint)offset); // dwImageOffset
240+
offset += pngFrames[i].Length;
241+
}
242+
243+
foreach (var frame in pngFrames)
244+
writer.Write(frame);
245+
246+
writer.Flush();
247+
return ms.ToArray();
248+
}
249+
}

src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,26 @@ public static bool IsOperatorChannelLive(GatewayConnectionSnapshot snapshot) =>
6464
},
6565
};
6666

67+
/// <summary>
68+
/// Resolves the status accent (Success/Caution/Critical/Neutral) used for the
69+
/// companion-app status dot, so the tray and desktop icons can mirror it.
70+
/// Prefers the richer <see cref="OverallConnectionState"/> when available and
71+
/// falls back to the legacy <see cref="ConnectionStatus"/>.
72+
/// </summary>
73+
public static ConnectionStatusAccent Accent(OverallConnectionState? overall, ConnectionStatus fallback)
74+
{
75+
if (overall is OverallConnectionState state)
76+
return Pill(state).Accent;
77+
78+
return fallback switch
79+
{
80+
ConnectionStatus.Connected => ConnectionStatusAccent.Success,
81+
ConnectionStatus.Connecting => ConnectionStatusAccent.Caution,
82+
ConnectionStatus.Error => ConnectionStatusAccent.Critical,
83+
_ => ConnectionStatusAccent.Neutral,
84+
};
85+
}
86+
6787
public static (string LabelKey, ConnectionStatusAccent Accent) Pill(OverallConnectionState overall) => overall switch
6888
{
6989
OverallConnectionState.Connected or OverallConnectionState.Ready =>

src/OpenClaw.Tray.WinUI/Services/TrayIconCoordinator.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.UI.Dispatching;
2+
using OpenClawTray.Helpers;
23
using System;
34
using WinUIEx;
45

@@ -39,8 +40,10 @@ internal void UpdateTrayIcon()
3940
if (!_isAlive())
4041
return;
4142

42-
var iconPath = System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "openclaw.ico");
43-
var tooltip = BuildTrayTooltip();
43+
var snapshot = _captureSnapshot();
44+
var accent = ConnectionStatusPresenter.Accent(snapshot.OverallState, snapshot.Status);
45+
var iconPath = StatusBadgeIconFactory.GetBadgedIconPath(accent);
46+
var tooltip = new TrayTooltipBuilder(snapshot).Build();
4447

4548
try
4649
{
@@ -60,7 +63,4 @@ internal void ApplyTrayTooltip(string tooltip)
6063

6164
_trayIcon.Tooltip = tooltip;
6265
}
63-
64-
private string BuildTrayTooltip() =>
65-
new TrayTooltipBuilder(_captureSnapshot()).Build();
6666
}

src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ public HubWindow()
114114

115115
this.SetWindowSize(1000, 650);
116116
this.CenterOnScreen();
117-
this.SetIcon(IconHelper.GetStatusIconPath(ConnectionStatus.Connected));
117+
ApplyWindowStatusIcon(ConnectionStatusAccent.Neutral);
118118

119119
RootGrid.SizeChanged += OnRootGridSizeChanged;
120120

@@ -771,13 +771,29 @@ private void UpdateTitleBarStatus(ConnectionStatus status)
771771
var (text, accent) = ComputePillState(status, snapshot);
772772
StatusPillText.Text = text;
773773
StatusPillDot.Fill = AccentBrush(accent);
774+
ApplyWindowStatusIcon(accent);
774775
}
775776

776777
internal void UpdateTitleBarStatus(GatewayConnectionSnapshot snapshot, ConnectionStatus status)
777778
{
778779
var (text, accent) = ComputePillState(status, snapshot);
779780
StatusPillText.Text = text;
780781
StatusPillDot.Fill = AccentBrush(accent);
782+
ApplyWindowStatusIcon(accent);
783+
}
784+
785+
// Mirrors the companion-app status dot onto the desktop/taskbar window icon:
786+
// the lobster with a coloured dot in the bottom-right corner.
787+
private void ApplyWindowStatusIcon(ConnectionStatusAccent accent)
788+
{
789+
try
790+
{
791+
this.SetIcon(StatusBadgeIconFactory.GetBadgedIconPath(accent));
792+
}
793+
catch (Exception ex)
794+
{
795+
Logger.Warn($"Failed to update window status icon: {ex.Message}");
796+
}
781797
}
782798

783799
private static (string Text, ConnectionStatusAccent Accent) ComputePillState(

tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,34 @@ public void TrayCoordinator_UpdateGuardsLivenessBeforeTouchingIcon()
852852
Assert.True(guardIndex < setIconIndex, "Liveness guard must run before SetIcon");
853853
}
854854

855+
[Fact]
856+
public void TrayCoordinator_UsesStatusBadgedLobsterIcon()
857+
{
858+
var method = ExtractMethod(ReadCoordinatorSource(), "UpdateTrayIcon");
859+
860+
// The tray lobster mirrors the companion-app status dot instead of the
861+
// static openclaw.ico, so it must resolve the accent and the badged icon.
862+
Assert.Contains("ConnectionStatusPresenter.Accent(", method);
863+
Assert.Contains("StatusBadgeIconFactory.GetBadgedIconPath(", method);
864+
Assert.DoesNotContain("\"openclaw.ico\"", method);
865+
}
866+
867+
[Fact]
868+
public void HubWindow_DesktopIconMirrorsStatusAccent()
869+
{
870+
var root = TestRepositoryPaths.GetRepositoryRoot();
871+
var source = File.ReadAllText(Path.Combine(
872+
root, "src", "OpenClaw.Tray.WinUI", "Windows", "HubWindow.xaml.cs"));
873+
874+
// The desktop/taskbar icon is refreshed from the same accent as the pill.
875+
var apply = ExtractMethod(source, "ApplyWindowStatusIcon");
876+
Assert.Contains("StatusBadgeIconFactory.GetBadgedIconPath(accent)", apply);
877+
878+
// Both status-update paths must repaint the window icon.
879+
var update = ExtractMethod(source, "UpdateTitleBarStatus");
880+
Assert.Contains("ApplyWindowStatusIcon(accent)", update);
881+
}
882+
855883
[Fact]
856884
public void AppNotifications_ConnectionIssueUsesStableDedupeKey()
857885
{

0 commit comments

Comments
 (0)