|
| 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 | +} |
0 commit comments