From 9372d43e404be9cb61fcfa1122400874c3253a52 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:15:16 +0200 Subject: [PATCH 01/32] Phase 1: TFM migration to net48/net8.0/net9.0, internal graphics abstraction, v7.0 - Migrate TargetFrameworks from netstandard2.0;net8.0 to net48;net8.0;net9.0 - Add internal IImageCodec abstraction with thread-safe SystemDrawingImageCodec - Route Tools.ImageToBase64, Base64StringToImage, ImageToSHA512 through codec - Consolidate SDConnection.SetImageAsync(Image) to use string-based path - Remove StreamDeckConnection.SetImageAsync(Image) overload - Update CI to .NET 9 SDK, SamplePlugin to net48 - Bump version to 7.0 - Add migration guide, pre-existing issues tracker, and API inventory Made-with: Cursor --- .github/workflows/dotnet.yml | 6 +- README.md | 3 + SamplePlugin/App.config | 6 +- SamplePlugin/SamplePlugin.csproj | 6 +- barraider-sdtools/Backend/SDConnection.cs | 7 +- .../Communication/StreamDeckConnection.cs | 26 +--- barraider-sdtools/Internal/IImageCodec.cs | 13 ++ .../Internal/ImageCodecProvider.cs | 12 ++ .../Internal/SystemDrawingImageCodec.cs | 52 +++++++ barraider-sdtools/Tools/Tools.cs | 37 +++-- barraider-sdtools/barraider-sdtools.csproj | 30 ++-- barraider-sdtools/streamdeck-tools.xml | 13 ++ docs/API_INVENTORY.md | 134 ++++++++++++++++++ docs/MIGRATION.md | 64 +++++++++ docs/PRE_EXISTING_ISSUES.md | 76 ++++++++++ 15 files changed, 419 insertions(+), 66 deletions(-) create mode 100644 barraider-sdtools/Internal/IImageCodec.cs create mode 100644 barraider-sdtools/Internal/ImageCodecProvider.cs create mode 100644 barraider-sdtools/Internal/SystemDrawingImageCodec.cs create mode 100644 docs/API_INVENTORY.md create mode 100644 docs/MIGRATION.md create mode 100644 docs/PRE_EXISTING_ISSUES.md diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 00a067a..6995517 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -15,11 +15,11 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Restore dependencies run: dotnet restore - name: Build diff --git a/README.md b/README.md index b7943db..714f1e2 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ Instead of `PluginBase`, Derive from either `KeypadBase` (if you don't support d # Getting Started Introducing our new [wiki](https://github.com/BarRaider/streamdeck-tools/wiki) packed with usage instructions, examples and more. +## Migration +If you are upgrading from legacy `System.Drawing`-heavy usage, see the [migration guide](docs/MIGRATION.md). + # Dev Discussions / Support **Discord:** Discuss in #developers-chat in [Bar Raiders](http://discord.barraider.com) diff --git a/SamplePlugin/App.config b/SamplePlugin/App.config index 9395f24..948bea0 100644 --- a/SamplePlugin/App.config +++ b/SamplePlugin/App.config @@ -1,13 +1,13 @@ - + - + - + diff --git a/SamplePlugin/SamplePlugin.csproj b/SamplePlugin/SamplePlugin.csproj index ac5cff0..270478c 100644 --- a/SamplePlugin/SamplePlugin.csproj +++ b/SamplePlugin/SamplePlugin.csproj @@ -1,10 +1,12 @@ - + Exe - net472 + net48 false com.test.sdtools AnyCPU;x64 + true + true bin\Release\com.test.sdtools.sdPlugin\ diff --git a/barraider-sdtools/Backend/SDConnection.cs b/barraider-sdtools/Backend/SDConnection.cs index 16c706b..231fdec 100644 --- a/barraider-sdtools/Backend/SDConnection.cs +++ b/barraider-sdtools/Backend/SDConnection.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; using System.Drawing; @@ -254,11 +254,12 @@ public async Task SetImageAsync(string base64Image, int? state = null, bool forc /// public async Task SetImageAsync(Image image, int? state = null, bool forceSendToStreamdeck = false) { - string hash = Tools.ImageToSHA512(image); + string base64Image = Tools.ImageToBase64(image, true); + string hash = Tools.StringToSHA512(base64Image); if (forceSendToStreamdeck || hash != previousImageHash) { previousImageHash = hash; - await StreamDeckConnection.SetImageAsync(image, ContextId, SDKTarget.HardwareAndSoftware, state); + await StreamDeckConnection.SetImageAsync(base64Image, ContextId, SDKTarget.HardwareAndSoftware, state); } } diff --git a/barraider-sdtools/Communication/StreamDeckConnection.cs b/barraider-sdtools/Communication/StreamDeckConnection.cs index a465798..23fc09d 100644 --- a/barraider-sdtools/Communication/StreamDeckConnection.cs +++ b/barraider-sdtools/Communication/StreamDeckConnection.cs @@ -1,13 +1,10 @@ -using BarRaider.SdTools.Communication.Messages; +using BarRaider.SdTools.Communication.Messages; using BarRaider.SdTools.Communication.SDEvents; using BarRaider.SdTools.Wrappers; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; -using System.Drawing; -using System.Drawing.Imaging; -using System.IO; using System.Net.WebSockets; using System.Text; using System.Threading; @@ -192,27 +189,6 @@ internal Task LogMessageAsync(string message) return SendAsync(new LogMessage(message)); } - internal Task SetImageAsync(Image image, string context, SDKTarget target, int? state) - { - try - { - using (MemoryStream memoryStream = new MemoryStream()) - { - image.Save(memoryStream, ImageFormat.Png); - byte[] imageBytes = memoryStream.ToArray(); - - // Convert byte[] to Base64 String - string base64String = $"data:image/png;base64,{Convert.ToBase64String(imageBytes)}"; - return SetImageAsync(base64String, context, target, state); - } - } - catch (Exception ex) - { - Logger.Instance.LogMessage(TracingLevel.ERROR, $"{this.GetType()} SetImageAsync Exception: {ex}"); - } - return null; - } - internal Task SetImageAsync(string base64Image, string context, SDKTarget target, int? state) { return SendAsync(new SetImageMessage(base64Image, context, target, state)); diff --git a/barraider-sdtools/Internal/IImageCodec.cs b/barraider-sdtools/Internal/IImageCodec.cs new file mode 100644 index 0000000..2b481d9 --- /dev/null +++ b/barraider-sdtools/Internal/IImageCodec.cs @@ -0,0 +1,13 @@ +using System.Drawing; + +namespace BarRaider.SdTools.Internal +{ + /// + /// Internal image codec abstraction used by compatibility adapters. + /// + internal interface IImageCodec + { + byte[] EncodeToPngBytes(Image image); + Image DecodeFromBytes(byte[] imageBytes); + } +} diff --git a/barraider-sdtools/Internal/ImageCodecProvider.cs b/barraider-sdtools/Internal/ImageCodecProvider.cs new file mode 100644 index 0000000..c8c229e --- /dev/null +++ b/barraider-sdtools/Internal/ImageCodecProvider.cs @@ -0,0 +1,12 @@ +using System; + +namespace BarRaider.SdTools.Internal +{ + internal static class ImageCodecProvider + { + private static readonly Lazy lazyInstance = + new Lazy(() => new SystemDrawingImageCodec()); + + internal static IImageCodec Instance => lazyInstance.Value; + } +} diff --git a/barraider-sdtools/Internal/SystemDrawingImageCodec.cs b/barraider-sdtools/Internal/SystemDrawingImageCodec.cs new file mode 100644 index 0000000..b517db5 --- /dev/null +++ b/barraider-sdtools/Internal/SystemDrawingImageCodec.cs @@ -0,0 +1,52 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; + +namespace BarRaider.SdTools.Internal +{ + /// + /// Compatibility image codec implementation backed by System.Drawing. + /// Image.FromStream requires the backing stream to remain open for the + /// lifetime of the Image, so DecodeFromBytes copies pixel data into a + /// new Bitmap and disposes the intermediate resources. + /// + internal sealed class SystemDrawingImageCodec : IImageCodec + { + public byte[] EncodeToPngBytes(Image image) + { + if (image == null) + { + return null; + } + + using (var memoryStream = new MemoryStream()) + { + image.Save(memoryStream, ImageFormat.Png); + return memoryStream.ToArray(); + } + } + + public Image DecodeFromBytes(byte[] imageBytes) + { + if (imageBytes == null || imageBytes.Length == 0) + { + return null; + } + + var memoryStream = new MemoryStream(imageBytes); + try + { + Image original = Image.FromStream(memoryStream); + var copy = new Bitmap(original); + original.Dispose(); + memoryStream.Dispose(); + return copy; + } + catch + { + memoryStream.Dispose(); + throw; + } + } + } +} diff --git a/barraider-sdtools/Tools/Tools.cs b/barraider-sdtools/Tools/Tools.cs index 2a62b4a..1c14395 100644 --- a/barraider-sdtools/Tools/Tools.cs +++ b/barraider-sdtools/Tools/Tools.cs @@ -1,11 +1,11 @@ -using BarRaider.SdTools.Wrappers; +using BarRaider.SdTools.Wrappers; +using BarRaider.SdTools.Internal; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.Drawing; using System.Drawing.Drawing2D; -using System.Drawing.Imaging; using System.Drawing.Text; using System.IO; using System.Linq; @@ -65,15 +65,15 @@ public static string ImageToBase64(Image image, bool addHeaderPrefix) return null; } - using (MemoryStream m = new MemoryStream()) + byte[] imageBytes = ImageCodecProvider.Instance.EncodeToPngBytes(image); + if (imageBytes == null) { - image.Save(m, ImageFormat.Png); - byte[] imageBytes = m.ToArray(); - - // Convert byte[] to Base64 String - string base64String = Convert.ToBase64String(imageBytes); - return addHeaderPrefix ? HEADER_PREFIX + base64String : base64String; + return null; } + + // Convert byte[] to Base64 String + string base64String = Convert.ToBase64String(imageBytes); + return addHeaderPrefix ? HEADER_PREFIX + base64String : base64String; } /// @@ -91,16 +91,13 @@ public static Image Base64StringToImage(string base64String) } // Remove header - if (base64String.Substring(0, HEADER_PREFIX.Length) == HEADER_PREFIX) + if (base64String.StartsWith(HEADER_PREFIX, StringComparison.Ordinal)) { base64String = base64String.Substring(HEADER_PREFIX.Length); } byte[] imageBytes = Convert.FromBase64String(base64String); - using (MemoryStream m = new MemoryStream(imageBytes)) - { - return Image.FromStream(m); - } + return ImageCodecProvider.Instance.DecodeFromBytes(imageBytes); } catch (Exception ex) { @@ -311,11 +308,8 @@ public static string ImageToSHA512(Image image) try { - using (MemoryStream ms = new MemoryStream()) - { - image.Save(ms, ImageFormat.Png); - return BytesToSHA512(ms.ToArray()); - } + byte[] imageBytes = ImageCodecProvider.Instance.EncodeToPngBytes(image); + return imageBytes == null ? null : BytesToSHA512(imageBytes); } catch (Exception ex) { @@ -345,6 +339,11 @@ public static string StringToSHA512(string str) /// public static string BytesToSHA512(byte[] byteStream) { + if (byteStream == null) + { + return null; + } + try { using (SHA512 sha512 = SHA512.Create()) diff --git a/barraider-sdtools/barraider-sdtools.csproj b/barraider-sdtools/barraider-sdtools.csproj index 1aabfc5..7d53682 100644 --- a/barraider-sdtools/barraider-sdtools.csproj +++ b/barraider-sdtools/barraider-sdtools.csproj @@ -1,6 +1,6 @@ - + - netstandard2.0;net8.0 + net48;net8.0;net9.0 true BarRaider Stream Deck Tools by BarRaider @@ -15,29 +15,29 @@ Feel free to contact me for more information: https://barraider.comStreamDeck Elgato Library Plugin Stream Deck Toolkit StreamDeck-Tools - 6.4 - 6.4 - 6.4 - 6.4 - Support for newest Stream Deck devices -NOTE: Final version with .netstandard support. Moving to .NET 8/9 in next versions! + 7.0 + 7.0 + 7.0 + 7.0 - Major migration: net48/net8.0/net9.0 multi-targeting, internal graphics abstraction layer, System.Drawing compatibility adapters. +NOTE: See docs/MIGRATION.md for upgrade guidance. BarRaider.SdTools StreamDeckTools BRLogo_460.png README.md LICENSE - + 1701;1702;CA1416 - + - + streamdeck-tools.xml 1701;1702;CA1416 - + streamdeck-tools.xml @@ -46,10 +46,18 @@ NOTE: Final version with .netstandard support. Moving to .NET 8/9 in next versio 1701;1702;CA1416 + + 1701;1702;CA1416 + + + 1701;1702;CA1416 + + + diff --git a/barraider-sdtools/streamdeck-tools.xml b/barraider-sdtools/streamdeck-tools.xml index 006aa82..2994bc6 100644 --- a/barraider-sdtools/streamdeck-tools.xml +++ b/barraider-sdtools/streamdeck-tools.xml @@ -1625,6 +1625,19 @@ + + + Internal image codec abstraction used by compatibility adapters. + + + + + Compatibility image codec implementation backed by System.Drawing. + Image.FromStream requires the backing stream to remain open for the + lifetime of the Image, so DecodeFromBytes copies pixel data into a + new Bitmap and disposes the intermediate resources. + + Payload for Apperance settings diff --git a/docs/API_INVENTORY.md b/docs/API_INVENTORY.md new file mode 100644 index 0000000..c29446d --- /dev/null +++ b/docs/API_INVENTORY.md @@ -0,0 +1,134 @@ +# System.Drawing Public API Inventory + +Complete inventory of every public API in `barraider-sdtools` that exposes `System.Drawing` types. +Each entry is classified as: **ADAPTED** (Phase 1), **NEEDS ADAPTER**, or **HIGH-RISK**. + +--- + +## Summary + +| Classification | Count | Description | +| --- | --- | --- | +| ADAPTED | 5 | Already routed through internal codec abstraction in Phase 1 | +| NEEDS ADAPTER | 15 | Can be wrapped/adapted without breaking existing callers | +| HIGH-RISK | 10 | Deep entanglement with System.Drawing types in public signatures | + +**Files with System.Drawing exposure:** +- `Tools/Tools.cs` (5 APIs) +- `Tools/GraphicsTools.cs` (6 APIs) +- `Tools/ExtensionMethods.cs` (10 APIs) +- `Backend/SDConnection.cs` (1 API) +- `Backend/ISDConnection.cs` (1 API) +- `Wrappers/TitleParameters.cs` (3 properties + 2 constructors) + +--- + +## ADAPTED (Phase 1 -- complete) + +### Tools.cs + +| # | API | Signature | System.Drawing types | Notes | +| --- | --- | --- | --- | --- | +| 1 | `Tools.ImageToBase64` | `public static string ImageToBase64(Image image, bool addHeaderPrefix)` | `Image` (param) | Routes through `ImageCodecProvider.Instance.EncodeToPngBytes` | +| 2 | `Tools.Base64StringToImage` | `public static Image Base64StringToImage(string base64String)` | `Image` (return) | Routes through `ImageCodecProvider.Instance.DecodeFromBytes` | +| 3 | `Tools.ImageToSHA512` | `public static string ImageToSHA512(Image image)` | `Image` (param) | Routes through `ImageCodecProvider.Instance.EncodeToPngBytes` | + +### SDConnection.cs / ISDConnection.cs + +| # | API | Signature | System.Drawing types | Notes | +| --- | --- | --- | --- | --- | +| 4 | `SDConnection.SetImageAsync` | `public async Task SetImageAsync(Image image, int? state, bool forceSendToStreamdeck)` | `Image` (param) | Converts to base64 via `Tools.ImageToBase64`, then calls string overload | +| 5 | `ISDConnection.SetImageAsync` | `Task SetImageAsync(Image image, int? state, bool forceSendToStreamdeck)` | `Image` (param) | Interface declaration matching #4 | + +--- + +## NEEDS ADAPTER (moderate complexity) + +### Tools.cs + +| # | API | Signature | System.Drawing types | Notes | +| --- | --- | --- | --- | --- | +| 6 | `Tools.FileToBase64` | `public static string FileToBase64(string fileName, bool addHeaderPrefix)` | `Image` (internal only) | Uses `Image.FromFile` internally. Return type is `string` -- no public SD exposure. Low effort to adapt. | +| 7 | `Tools.GenerateKeyImage` | `public static Bitmap GenerateKeyImage(DeviceType streamDeckType, out Graphics graphics)` | `Bitmap` (return), `Graphics` (out) | Core drawing entry point for plugins. | +| 8 | `Tools.GenerateGenericKeyImage` | `public static Bitmap GenerateGenericKeyImage(out Graphics graphics)` | `Bitmap` (return), `Graphics` (out) | Delegates to #7. | + +### GraphicsTools.cs + +| # | API | Signature | System.Drawing types | Notes | +| --- | --- | --- | --- | --- | +| 9 | `GraphicsTools.ResizeImage` | `public static Image ResizeImage(Image original, int newWidth, int newHeight)` | `Image` (param + return) | Uses `Bitmap`, `Graphics`, `InterpolationMode` internally. | +| 10 | `GraphicsTools.ExtractRectangle` | `public static Bitmap ExtractRectangle(Image image, int startX, int startY, int width, int height)` | `Image` (param), `Bitmap` (return) | Uses `Rectangle`, `Bitmap.Clone`. | +| 11 | `GraphicsTools.CreateOpacityImage` | `public static Image CreateOpacityImage(Image image, float opacity)` | `Image` (param + return) | Uses `Bitmap`, `Graphics`, `ColorMatrix`, `ImageAttributes`. | +| 12 | `GraphicsTools.DrawMultiLinedText` | `public static Image[] DrawMultiLinedText(string text, int currentTextPosition, int lettersPerLine, int numberOfLines, Font font, Color backgroundColor, Color textColor, bool expandToNextImage, PointF keyDrawStartingPosition)` | `Font`, `Color` (params), `Image[]` (return), `PointF` | Heavy usage of `Graphics`, `SolidBrush`. | +| 13 | `GraphicsTools.WrapStringToFitImage` | `public static string WrapStringToFitImage(string str, TitleParameters titleParameters, int leftPaddingPixels, int rightPaddingPixels, int imageWidthPixels)` | Uses `Font`, `Bitmap`, `Graphics` internally | Return type is `string` -- no direct SD exposure in signature, but depends on `TitleParameters` (which uses SD types). | + +### ExtensionMethods.cs + +| # | API | Signature | System.Drawing types | Notes | +| --- | --- | --- | --- | --- | +| 14 | `Image.ToByteArray` | `public static byte[] ToByteArray(this Image image)` | `Image` (this) | Uses `ImageFormat.Bmp` directly. | +| 15 | `Image.ToBase64` | `public static string ToBase64(this Image image, bool addHeaderPrefix)` | `Image` (this) | Delegates to `Tools.ImageToBase64` -- effectively already adapted. | +| 16 | `string.SplitToFitKey` | `public static string SplitToFitKey(this string str, TitleParameters titleParameters, ...)` | Uses `Font`, `Bitmap`, `Graphics` internally | Return type is `string`, but internal SD usage. | + +--- + +## HIGH-RISK (deep System.Drawing entanglement in public surface) + +### GraphicsTools.cs + +| # | API | Signature | System.Drawing types | Notes | +| --- | --- | --- | --- | --- | +| 17 | `GraphicsTools.ColorFromHex` | `public static Color ColorFromHex(string hexColor)` | `Color` (return) | Uses `ColorTranslator.FromHtml`. | +| 18 | `GraphicsTools.GenerateColorShades` | `public static Color GenerateColorShades(string initialColor, int currentShade, int totalAmountOfShades)` | `Color` (return) | Uses `Color.FromArgb`. | + +### ExtensionMethods.cs + +| # | API | Signature | System.Drawing types | Notes | +| --- | --- | --- | --- | --- | +| 19 | `Color.ToHex` | `public static string ToHex(this Color color)` | `Color` (this) | Extension on `Color`. | +| 20 | `Brush.ToHex` | `public static string ToHex(this Brush brush)` | `Brush` (this), `SolidBrush` | Extension on `Brush`. | +| 21 | `Graphics.DrawAndMeasureString` | `public static float DrawAndMeasureString(this Graphics graphics, string text, Font font, Brush brush, PointF position)` | `Graphics`, `Font`, `Brush`, `PointF` | Extension on `Graphics`. | +| 22 | `Graphics.GetTextCenter` | `public static float GetTextCenter(this Graphics graphics, string text, int imageWidth, Font font, out bool textFitsImage, int minIndentation)` | `Graphics`, `Font` | Extension on `Graphics`. Two overloads. | +| 23 | `Graphics.GetFontSizeWhereTextFitsImage` | `public static float GetFontSizeWhereTextFitsImage(this Graphics graphics, string text, int imageWidth, Font font, int minimalFontSize)` | `Graphics`, `Font` | Extension on `Graphics`. | +| 24 | `Graphics.AddTextPath` | `public static void AddTextPath(this Graphics graphics, TitleParameters titleParameters, int imageHeight, int imageWidth, string text, Color strokeColor, float strokeThickness, int pixelsAlignment)` | `Graphics`, `Color`, `TitleParameters` | Extension on `Graphics`. Two overloads. Uses `GraphicsPath`, `Pen`, `SolidBrush`. | + +### TitleParameters.cs (Wrappers) + +| # | API | Type | Notes | +| --- | --- | --- | --- | +| 25 | `TitleParameters.TitleColor` | `Color` property | Public property, deserialized from JSON. | +| 26 | `TitleParameters.FontFamily` | `FontFamily` property | Public property, deserialized from JSON. | +| 27 | `TitleParameters.FontStyle` | `FontStyle` property (enum) | Public property, deserialized from JSON. | +| 28 | `TitleParameters(FontFamily, FontStyle, double, Color, bool, TitleVerticalAlignment)` | Constructor | Direct SD types in parameters. | + +--- + +## Dependency Graph + +``` +Plugin Code + | + +-- SDConnection.SetImageAsync(Image) ........... [ADAPTED] + +-- Tools.ImageToBase64(Image) .................. [ADAPTED] + +-- Tools.Base64StringToImage(string) ........... [ADAPTED] + +-- Tools.GenerateKeyImage(DeviceType, out Graphics) ... [NEEDS ADAPTER - returns Bitmap+Graphics] + | | + | +-- Plugin draws on Graphics, then calls SetImageAsync(Image) + | + +-- GraphicsTools.ResizeImage(Image) ............ [NEEDS ADAPTER] + +-- GraphicsTools.CreateOpacityImage(Image) ..... [NEEDS ADAPTER] + +-- GraphicsTools.DrawMultiLinedText(...) ........ [NEEDS ADAPTER - Font/Color params] + +-- ExtensionMethods on Graphics ................ [HIGH-RISK - direct SD types] + +-- TitleParameters (Color/FontFamily/FontStyle) [HIGH-RISK - data class] +``` + +--- + +## Recommended Adapter Priority + +1. **`Tools.GenerateKeyImage` / `GenerateGenericKeyImage`** -- Most commonly used plugin entry point for drawing. Adapting this unblocks the majority of plugin workflows. +2. **`GraphicsTools.ResizeImage` / `ExtractRectangle` / `CreateOpacityImage`** -- Image manipulation utilities, frequently used. +3. **`Image.ToByteArray`** -- Simple encoding extension, easy to route through codec. +4. **`GraphicsTools.DrawMultiLinedText`** -- Text rendering, depends on Font/Color. +5. **`TitleParameters`** -- Core data class. Needs careful deprecation strategy since plugins read these properties directly. +6. **Extension methods on `Graphics`** -- Lowest priority; these are advanced drawing helpers that tightly couple to `System.Drawing.Graphics`. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..3d6f50a --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,64 @@ +# Migration Guide + +This guide covers migration from legacy `System.Drawing`-first plugin flows to the new compatibility model in `StreamDeck-Tools`. + +## Goals + +- Existing plugins should continue to work with minimal or no code changes in common scenarios. +- New development should move to non-`System.Drawing`-centric APIs over time. +- Migration should be incremental and predictable. + +## Upgrade Paths + +### Path A: No-Code-Change First + +- Upgrade package version. +- Keep existing calls like `SetImageAsync(Image image, ...)` while validating runtime behavior. +- If your plugin only uses library helpers for image/title handling, this is the recommended first step. + +### Path B: Proactive Modernization + +- Move to base64/byte-based APIs for image updates. +- Reduce direct usage of `System.Drawing` in plugin code. +- Adopt new non-`System.Drawing` APIs as they are added. + +## API Mapping Template + +Use this template while migrating plugin code: + +| Current API | New API | Migration effort | Notes | +| --- | --- | --- | --- | +| `SetImageAsync(Image, ...)` | `SetImageAsync(string base64Image, ...)` | rename + conversion | Convert image once and reuse encoded payload | +| `Tools.ImageToBase64(Image, ...)` | `Tools.ImageToBase64(Image, ...)` | no change | Legacy-compatible path retained | +| `Tools.Base64StringToImage(string)` | `Tools.Base64StringToImage(string)` | no change | Legacy-compatible path retained | +| `GraphicsTools.*` (`Image`/`Bitmap`) | upcoming backend-neutral equivalents | small-to-medium | Prefer new methods when available | + +## Direct System.Drawing Detection Checklist + +Scan plugin code for direct dependencies: + +- `using System.Drawing` +- `new Bitmap(...)` +- `Graphics.FromImage(...)` +- `Image.FromFile(...)` / `Image.FromStream(...)` +- `Font`, `FontFamily`, `FontStyle`, `Brush`, `SolidBrush` + +Suggested search commands: + +```bash +rg "using System\\.Drawing" --type cs +rg "Bitmap\\(|Graphics\\.FromImage|Image\\.From(File|Stream)" --type cs +rg "\\bFont\\b|FontFamily|FontStyle|SolidBrush|Brush\\b" --type cs +``` + +## Deprecation Timeline + +- **Release N**: legacy APIs remain supported without forced warnings. +- **Release N+1**: targeted `[Obsolete]` warnings start, each with clear replacement guidance. +- **Next major**: removal candidates are evaluated after adoption feedback and compatibility results. + +## Compatibility Notes + +- Exact pixel parity is not guaranteed for every text/layout edge case across rendering backends. +- Functional parity is the target for common plugin scenarios (resize, encoding, title/image updates). +- Plugins with heavy direct `System.Drawing` usage may require focused migration work. diff --git a/docs/PRE_EXISTING_ISSUES.md b/docs/PRE_EXISTING_ISSUES.md new file mode 100644 index 0000000..74c2650 --- /dev/null +++ b/docs/PRE_EXISTING_ISSUES.md @@ -0,0 +1,76 @@ +# Pre-Existing Issues + +Issues discovered during migration quality gates that were **not introduced by the migration**. These are tracked here for future resolution and are out of scope for the current migration work. + +## CRITICAL + +### `StreamDeckConnection.SendAsync(IMessage)` returns null on serialization failure +- **File**: `barraider-sdtools/Communication/StreamDeckConnection.cs` (lines ~170-180) +- **Description**: When `JsonConvert.SerializeObject` throws, the method returns `null`. Callers `await` the result, so `await null` throws `NullReferenceException`, hiding the original serialization error. +- **Impact**: Any serialization failure crashes the plugin with a misleading exception. + +## HIGH + +### `Tools.GenerateKeyImage` leaks `SolidBrush` +- **File**: `barraider-sdtools/Tools/Tools.cs` (lines ~198-207) +- **Description**: `SolidBrush` created for the background fill is never disposed. GDI+ brush handles are a limited resource. +- **Impact**: Repeated key image generation can exhaust GDI+ handles over time. + +### `Tools.AutoLoadPluginActions` does not null-check `Assembly.GetEntryAssembly()` +- **File**: `barraider-sdtools/Tools/Tools.cs` (line ~467) +- **Description**: `Assembly.GetEntryAssembly()` can return `null` in hosted or test scenarios. Calling `.GetTypes()` on null causes `NullReferenceException`. +- **Impact**: Plugin crashes in non-standard hosting environments. + +## MEDIUM + +### `SDConnection.previousImageHash` is not thread-safe +- **File**: `barraider-sdtools/Backend/SDConnection.cs` (lines ~24, 240-263) +- **Description**: `previousImageHash` field is read and written without synchronization. Concurrent `SetImageAsync` calls can race. +- **Impact**: Possible duplicate image sends or skipped updates under concurrent access. + +### `SDConnection.Dispose()` does not null-check `StreamDeckConnection` +- **File**: `barraider-sdtools/Backend/SDConnection.cs` (lines ~143-152) +- **Description**: If `StreamDeckConnection` were null after partial construction, unsubscribing events would throw. +- **Impact**: Low probability but possible crash during error-path disposal. + +### `StreamDeckConnection.OpenUrlAsync(string)` does not validate input +- **File**: `barraider-sdtools/Communication/StreamDeckConnection.cs` (lines ~241-243) +- **Description**: `new Uri(uri)` is called without checking for null, throwing `ArgumentNullException`. +- **Impact**: Unhelpful exception when plugin passes null URI. + +### `SDConnection` title-change retry has no limit +- **File**: `barraider-sdtools/Backend/SDConnection.cs` (lines ~456-464) +- **Description**: When `OnTitleParametersDidChange` is null, a 1-second delayed retry is scheduled with no retry cap or disposal check. +- **Impact**: Can schedule unbounded retries after connection disposal. + +### `SystemDrawingImageCodec.DecodeFromBytes` catch block does not log +- **File**: `barraider-sdtools/Internal/SystemDrawingImageCodec.cs` (lines ~45-48) +- **Description**: The catch block disposes and rethrows but does not log the failure. This is new code but the logging gap is cosmetic and not a correctness issue introduced by the migration (the original `Image.FromStream` had no logging either). +- **Note**: Tracked here as low priority; not blocking migration. + +## LOW + +### Unused `using NLog.Layouts` in `SDConnection.cs` +- **File**: `barraider-sdtools/Backend/SDConnection.cs` (line 13) +- **Description**: Import is unused. `layout` parameter in `SetFeedbackLayoutAsync` is a `string`, not an NLog type. +- **Impact**: No runtime impact; cosmetic. + +### `CancellationTokenSource` not disposed in `StreamDeckConnection` +- **File**: `barraider-sdtools/Communication/StreamDeckConnection.cs` (line ~24) +- **Description**: `cancelTokenSource` is never disposed. Minor resource leak. +- **Impact**: Negligible in practice. + +### `Tools.AutoPopulateSettings` lacks type-conversion error handling +- **File**: `barraider-sdtools/Tools/Tools.cs` (line ~389) +- **Description**: `Convert.ChangeType` can throw for incompatible types with no try/catch around individual property sets. +- **Impact**: One bad property value can abort population of remaining properties. + +### `Tools.FilenameFromPayload` does not validate JToken type +- **File**: `barraider-sdtools/Tools/Tools.cs` (line ~227) +- **Description**: Casting a non-string `JToken` to `string` may throw. +- **Impact**: Edge case with malformed PI payloads. + +### `Tools.BytesToSHA512` does not null-check input +- **File**: `barraider-sdtools/Tools/Tools.cs` (lines ~340-355) +- **Description**: `sha512.ComputeHash(null)` would throw `ArgumentNullException`. The exception is caught and logged, but a null guard would be cleaner. +- **Impact**: Low; callers generally pass non-null, and the catch block handles it. From d061b33a2328e7fc2e8aa2b53fe01ba580367f75 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:16:53 +0200 Subject: [PATCH 02/32] Phase 2: Adapt FileToBase64, add ToPngByteArray, mark ToByteArray obsolete - Add DecodeFromFile to IImageCodec/SystemDrawingImageCodec (copies pixel data to release file lock) - Route Tools.FileToBase64 through ImageCodecProvider.Instance.DecodeFromFile - Add Image.ToPngByteArray extension routed through codec abstraction - Mark Image.ToByteArray [Obsolete] (BMP format, use ToPngByteArray instead) Made-with: Cursor --- barraider-sdtools/Internal/IImageCodec.cs | 1 + .../Internal/SystemDrawingImageCodec.cs | 13 +++++++++++++ barraider-sdtools/Tools/ExtensionMethods.cs | 16 ++++++++++++++-- barraider-sdtools/Tools/Tools.cs | 2 +- barraider-sdtools/streamdeck-tools.xml | 9 ++++++++- 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/barraider-sdtools/Internal/IImageCodec.cs b/barraider-sdtools/Internal/IImageCodec.cs index 2b481d9..862c553 100644 --- a/barraider-sdtools/Internal/IImageCodec.cs +++ b/barraider-sdtools/Internal/IImageCodec.cs @@ -9,5 +9,6 @@ internal interface IImageCodec { byte[] EncodeToPngBytes(Image image); Image DecodeFromBytes(byte[] imageBytes); + Image DecodeFromFile(string filePath); } } diff --git a/barraider-sdtools/Internal/SystemDrawingImageCodec.cs b/barraider-sdtools/Internal/SystemDrawingImageCodec.cs index b517db5..cb21341 100644 --- a/barraider-sdtools/Internal/SystemDrawingImageCodec.cs +++ b/barraider-sdtools/Internal/SystemDrawingImageCodec.cs @@ -48,5 +48,18 @@ public Image DecodeFromBytes(byte[] imageBytes) throw; } } + + public Image DecodeFromFile(string filePath) + { + if (string.IsNullOrEmpty(filePath)) + { + return null; + } + + using (Image original = Image.FromFile(filePath)) + { + return new Bitmap(original); + } + } } } diff --git a/barraider-sdtools/Tools/ExtensionMethods.cs b/barraider-sdtools/Tools/ExtensionMethods.cs index d4855c0..61c4dcd 100644 --- a/barraider-sdtools/Tools/ExtensionMethods.cs +++ b/barraider-sdtools/Tools/ExtensionMethods.cs @@ -1,4 +1,5 @@ -using BarRaider.SdTools.Wrappers; +using BarRaider.SdTools.Internal; +using BarRaider.SdTools.Wrappers; using System; using System.Collections.Generic; using System.Drawing; @@ -65,10 +66,11 @@ public static string ToHex(this Brush brush) #region Image/Graphics /// - /// Converts an Image into a Byte Array + /// Converts an Image into a BMP Byte Array. /// /// /// + [Obsolete("ToByteArray encodes as BMP which will not be supported in future versions. Use ToPngByteArray instead.")] public static byte[] ToByteArray(this Image image) { using (var ms = new MemoryStream()) @@ -78,6 +80,16 @@ public static byte[] ToByteArray(this Image image) } } + /// + /// Converts an Image into a PNG Byte Array using the internal codec abstraction. + /// + /// + /// + public static byte[] ToPngByteArray(this Image image) + { + return ImageCodecProvider.Instance.EncodeToPngBytes(image); + } + /// /// Convert a in-memory image object to Base64 format. Set the addHeaderPrefix to true, if this is sent to the SendImageAsync function /// diff --git a/barraider-sdtools/Tools/Tools.cs b/barraider-sdtools/Tools/Tools.cs index 1c14395..b06746b 100644 --- a/barraider-sdtools/Tools/Tools.cs +++ b/barraider-sdtools/Tools/Tools.cs @@ -46,7 +46,7 @@ public static string FileToBase64(string fileName, bool addHeaderPrefix) return null; } - using (Image image = Image.FromFile(fileName)) + using (Image image = ImageCodecProvider.Instance.DecodeFromFile(fileName)) { return ImageToBase64(image, addHeaderPrefix); } diff --git a/barraider-sdtools/streamdeck-tools.xml b/barraider-sdtools/streamdeck-tools.xml index 2994bc6..ba74ba7 100644 --- a/barraider-sdtools/streamdeck-tools.xml +++ b/barraider-sdtools/streamdeck-tools.xml @@ -2283,7 +2283,14 @@ - Converts an Image into a Byte Array + Converts an Image into a BMP Byte Array. + + + + + + + Converts an Image into a PNG Byte Array using the internal codec abstraction. From 754d2bcfc58f34ab7caf0debab470ed8b2ab4c3c Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:18:57 +0200 Subject: [PATCH 03/32] Phase 2 docs: Update API inventory and migration guide for v7.0 - Reclassify APIs: 8 ADAPTED, 10 KEPT (future Obsolete), 10 HIGH-RISK - Expand migration guide with v7.0 changelog, upgrade paths, and full API mapping - Add deprecation timeline (v7.0 -> v7.x -> v8.0) - Link API inventory and pre-existing issues from migration guide Made-with: Cursor --- docs/API_INVENTORY.md | 66 +++++++++++++++++++++++------------------ docs/MIGRATION.md | 69 +++++++++++++++++++++++++++++-------------- 2 files changed, 85 insertions(+), 50 deletions(-) diff --git a/docs/API_INVENTORY.md b/docs/API_INVENTORY.md index c29446d..2846c5e 100644 --- a/docs/API_INVENTORY.md +++ b/docs/API_INVENTORY.md @@ -1,7 +1,7 @@ # System.Drawing Public API Inventory Complete inventory of every public API in `barraider-sdtools` that exposes `System.Drawing` types. -Each entry is classified as: **ADAPTED** (Phase 1), **NEEDS ADAPTER**, or **HIGH-RISK**. +Each entry is classified as: **ADAPTED**, **KEPT** (System.Drawing retained, future [Obsolete]), or **HIGH-RISK**. --- @@ -9,8 +9,8 @@ Each entry is classified as: **ADAPTED** (Phase 1), **NEEDS ADAPTER**, or **HIGH | Classification | Count | Description | | --- | --- | --- | -| ADAPTED | 5 | Already routed through internal codec abstraction in Phase 1 | -| NEEDS ADAPTER | 15 | Can be wrapped/adapted without breaking existing callers | +| ADAPTED | 8 | Routed through internal codec abstraction (Phase 1 + 2) | +| KEPT | 10 | System.Drawing types retained in signatures; will be marked [Obsolete] in a future release | | HIGH-RISK | 10 | Deep entanglement with System.Drawing types in public signatures | **Files with System.Drawing exposure:** @@ -23,7 +23,7 @@ Each entry is classified as: **ADAPTED** (Phase 1), **NEEDS ADAPTER**, or **HIGH --- -## ADAPTED (Phase 1 -- complete) +## ADAPTED (Phase 1 + Phase 2 -- complete) ### Tools.cs @@ -32,43 +32,49 @@ Each entry is classified as: **ADAPTED** (Phase 1), **NEEDS ADAPTER**, or **HIGH | 1 | `Tools.ImageToBase64` | `public static string ImageToBase64(Image image, bool addHeaderPrefix)` | `Image` (param) | Routes through `ImageCodecProvider.Instance.EncodeToPngBytes` | | 2 | `Tools.Base64StringToImage` | `public static Image Base64StringToImage(string base64String)` | `Image` (return) | Routes through `ImageCodecProvider.Instance.DecodeFromBytes` | | 3 | `Tools.ImageToSHA512` | `public static string ImageToSHA512(Image image)` | `Image` (param) | Routes through `ImageCodecProvider.Instance.EncodeToPngBytes` | +| 4 | `Tools.FileToBase64` | `public static string FileToBase64(string fileName, bool addHeaderPrefix)` | `Image` (internal only) | Routes through `ImageCodecProvider.Instance.DecodeFromFile`. No public SD exposure. | ### SDConnection.cs / ISDConnection.cs | # | API | Signature | System.Drawing types | Notes | | --- | --- | --- | --- | --- | -| 4 | `SDConnection.SetImageAsync` | `public async Task SetImageAsync(Image image, int? state, bool forceSendToStreamdeck)` | `Image` (param) | Converts to base64 via `Tools.ImageToBase64`, then calls string overload | -| 5 | `ISDConnection.SetImageAsync` | `Task SetImageAsync(Image image, int? state, bool forceSendToStreamdeck)` | `Image` (param) | Interface declaration matching #4 | +| 5 | `SDConnection.SetImageAsync` | `public async Task SetImageAsync(Image image, int? state, bool forceSendToStreamdeck)` | `Image` (param) | Converts to base64 via `Tools.ImageToBase64`, then calls string overload | +| 6 | `ISDConnection.SetImageAsync` | `Task SetImageAsync(Image image, int? state, bool forceSendToStreamdeck)` | `Image` (param) | Interface declaration matching #5 | + +### ExtensionMethods.cs + +| # | API | Signature | System.Drawing types | Notes | +| --- | --- | --- | --- | --- | +| 7 | `Image.ToBase64` | `public static string ToBase64(this Image image, bool addHeaderPrefix)` | `Image` (this) | Delegates to adapted `Tools.ImageToBase64` | +| 8 | `Image.ToPngByteArray` | `public static byte[] ToPngByteArray(this Image image)` | `Image` (this) | **NEW** -- routes through `ImageCodecProvider.Instance.EncodeToPngBytes` | --- -## NEEDS ADAPTER (moderate complexity) +## KEPT (System.Drawing signatures retained -- future [Obsolete]) ### Tools.cs | # | API | Signature | System.Drawing types | Notes | | --- | --- | --- | --- | --- | -| 6 | `Tools.FileToBase64` | `public static string FileToBase64(string fileName, bool addHeaderPrefix)` | `Image` (internal only) | Uses `Image.FromFile` internally. Return type is `string` -- no public SD exposure. Low effort to adapt. | -| 7 | `Tools.GenerateKeyImage` | `public static Bitmap GenerateKeyImage(DeviceType streamDeckType, out Graphics graphics)` | `Bitmap` (return), `Graphics` (out) | Core drawing entry point for plugins. | -| 8 | `Tools.GenerateGenericKeyImage` | `public static Bitmap GenerateGenericKeyImage(out Graphics graphics)` | `Bitmap` (return), `Graphics` (out) | Delegates to #7. | +| 9 | `Tools.GenerateKeyImage` | `public static Bitmap GenerateKeyImage(DeviceType streamDeckType, out Graphics graphics)` | `Bitmap` (return), `Graphics` (out) | Core drawing entry point for plugins. Kept as-is. | +| 10 | `Tools.GenerateGenericKeyImage` | `public static Bitmap GenerateGenericKeyImage(out Graphics graphics)` | `Bitmap` (return), `Graphics` (out) | Delegates to #9. | ### GraphicsTools.cs | # | API | Signature | System.Drawing types | Notes | | --- | --- | --- | --- | --- | -| 9 | `GraphicsTools.ResizeImage` | `public static Image ResizeImage(Image original, int newWidth, int newHeight)` | `Image` (param + return) | Uses `Bitmap`, `Graphics`, `InterpolationMode` internally. | -| 10 | `GraphicsTools.ExtractRectangle` | `public static Bitmap ExtractRectangle(Image image, int startX, int startY, int width, int height)` | `Image` (param), `Bitmap` (return) | Uses `Rectangle`, `Bitmap.Clone`. | -| 11 | `GraphicsTools.CreateOpacityImage` | `public static Image CreateOpacityImage(Image image, float opacity)` | `Image` (param + return) | Uses `Bitmap`, `Graphics`, `ColorMatrix`, `ImageAttributes`. | -| 12 | `GraphicsTools.DrawMultiLinedText` | `public static Image[] DrawMultiLinedText(string text, int currentTextPosition, int lettersPerLine, int numberOfLines, Font font, Color backgroundColor, Color textColor, bool expandToNextImage, PointF keyDrawStartingPosition)` | `Font`, `Color` (params), `Image[]` (return), `PointF` | Heavy usage of `Graphics`, `SolidBrush`. | -| 13 | `GraphicsTools.WrapStringToFitImage` | `public static string WrapStringToFitImage(string str, TitleParameters titleParameters, int leftPaddingPixels, int rightPaddingPixels, int imageWidthPixels)` | Uses `Font`, `Bitmap`, `Graphics` internally | Return type is `string` -- no direct SD exposure in signature, but depends on `TitleParameters` (which uses SD types). | +| 11 | `GraphicsTools.ResizeImage` | `public static Image ResizeImage(Image original, int newWidth, int newHeight)` | `Image` (param + return) | Kept as-is; System.Drawing types in signature. | +| 12 | `GraphicsTools.ExtractRectangle` | `public static Bitmap ExtractRectangle(Image image, int startX, int startY, int width, int height)` | `Image` (param), `Bitmap` (return) | Kept as-is. | +| 13 | `GraphicsTools.CreateOpacityImage` | `public static Image CreateOpacityImage(Image image, float opacity)` | `Image` (param + return) | Kept as-is. | +| 14 | `GraphicsTools.DrawMultiLinedText` | `public static Image[] DrawMultiLinedText(...)` | `Font`, `Color` (params), `Image[]` (return), `PointF` | Kept as-is. | +| 15 | `GraphicsTools.WrapStringToFitImage` | `public static string WrapStringToFitImage(...)` | Uses `Font`, `Bitmap`, `Graphics` internally | Kept as-is. | ### ExtensionMethods.cs | # | API | Signature | System.Drawing types | Notes | | --- | --- | --- | --- | --- | -| 14 | `Image.ToByteArray` | `public static byte[] ToByteArray(this Image image)` | `Image` (this) | Uses `ImageFormat.Bmp` directly. | -| 15 | `Image.ToBase64` | `public static string ToBase64(this Image image, bool addHeaderPrefix)` | `Image` (this) | Delegates to `Tools.ImageToBase64` -- effectively already adapted. | -| 16 | `string.SplitToFitKey` | `public static string SplitToFitKey(this string str, TitleParameters titleParameters, ...)` | Uses `Font`, `Bitmap`, `Graphics` internally | Return type is `string`, but internal SD usage. | +| 16 | `Image.ToByteArray` | `public static byte[] ToByteArray(this Image image)` | `Image` (this) | Marked `[Obsolete]` -- use `ToPngByteArray` instead. BMP encoding kept for backward compatibility. | +| 17 | `string.SplitToFitKey` | `public static string SplitToFitKey(this string str, TitleParameters titleParameters, ...)` | Uses `Font`, `Bitmap`, `Graphics` internally | Kept as-is. | --- @@ -111,24 +117,28 @@ Plugin Code +-- SDConnection.SetImageAsync(Image) ........... [ADAPTED] +-- Tools.ImageToBase64(Image) .................. [ADAPTED] +-- Tools.Base64StringToImage(string) ........... [ADAPTED] - +-- Tools.GenerateKeyImage(DeviceType, out Graphics) ... [NEEDS ADAPTER - returns Bitmap+Graphics] + +-- Tools.FileToBase64(string) .................. [ADAPTED] + +-- Image.ToPngByteArray() ...................... [ADAPTED - NEW] + +-- Image.ToByteArray() ......................... [OBSOLETE -> ToPngByteArray] + +-- Tools.GenerateKeyImage(DeviceType, out Graphics) ... [KEPT - returns Bitmap+Graphics] | | | +-- Plugin draws on Graphics, then calls SetImageAsync(Image) | - +-- GraphicsTools.ResizeImage(Image) ............ [NEEDS ADAPTER] - +-- GraphicsTools.CreateOpacityImage(Image) ..... [NEEDS ADAPTER] - +-- GraphicsTools.DrawMultiLinedText(...) ........ [NEEDS ADAPTER - Font/Color params] + +-- GraphicsTools.ResizeImage(Image) ............ [KEPT] + +-- GraphicsTools.CreateOpacityImage(Image) ..... [KEPT] + +-- GraphicsTools.DrawMultiLinedText(...) ........ [KEPT] +-- ExtensionMethods on Graphics ................ [HIGH-RISK - direct SD types] +-- TitleParameters (Color/FontFamily/FontStyle) [HIGH-RISK - data class] ``` --- -## Recommended Adapter Priority +## Future Deprecation Priority + +When the time comes to mark KEPT APIs as `[Obsolete]` and provide parallel replacements: -1. **`Tools.GenerateKeyImage` / `GenerateGenericKeyImage`** -- Most commonly used plugin entry point for drawing. Adapting this unblocks the majority of plugin workflows. +1. **`Tools.GenerateKeyImage` / `GenerateGenericKeyImage`** -- Most commonly used plugin entry point for drawing. 2. **`GraphicsTools.ResizeImage` / `ExtractRectangle` / `CreateOpacityImage`** -- Image manipulation utilities, frequently used. -3. **`Image.ToByteArray`** -- Simple encoding extension, easy to route through codec. -4. **`GraphicsTools.DrawMultiLinedText`** -- Text rendering, depends on Font/Color. -5. **`TitleParameters`** -- Core data class. Needs careful deprecation strategy since plugins read these properties directly. -6. **Extension methods on `Graphics`** -- Lowest priority; these are advanced drawing helpers that tightly couple to `System.Drawing.Graphics`. +3. **`GraphicsTools.DrawMultiLinedText`** -- Text rendering, depends on Font/Color. +4. **`TitleParameters`** -- Core data class. Needs careful deprecation strategy since plugins read properties directly. +5. **Extension methods on `Graphics`** -- Lowest priority; advanced drawing helpers tightly coupled to `System.Drawing.Graphics`. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 3d6f50a..cd7ae84 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -1,41 +1,61 @@ # Migration Guide -This guide covers migration from legacy `System.Drawing`-first plugin flows to the new compatibility model in `StreamDeck-Tools`. +This guide covers migration from legacy `System.Drawing`-first plugin flows to the new compatibility model in `StreamDeck-Tools` v7.0. ## Goals -- Existing plugins should continue to work with minimal or no code changes in common scenarios. +- Existing plugins should continue to work with minimal or no code changes. - New development should move to non-`System.Drawing`-centric APIs over time. - Migration should be incremental and predictable. +## What changed in v7.0 + +### Target Frameworks +- **Before**: `netstandard2.0` + `net8.0` +- **After**: `net48` + `net8.0` + `net9.0` + +### Internal graphics abstraction +All image encode/decode operations now route through an internal `IImageCodec` abstraction backed by `System.Drawing`. This is transparent to plugins -- existing code continues to work without changes. + +### New API: `Image.ToPngByteArray()` +Encodes an image to PNG bytes via the codec abstraction. Replaces the old `Image.ToByteArray()` which used BMP format. + +### Deprecated API: `Image.ToByteArray()` +Marked `[Obsolete]`. Encodes as BMP which will not be supported in future backend swaps. Use `Image.ToPngByteArray()` instead. + ## Upgrade Paths -### Path A: No-Code-Change First +### Path A: No-Code-Change (recommended first step) -- Upgrade package version. -- Keep existing calls like `SetImageAsync(Image image, ...)` while validating runtime behavior. -- If your plugin only uses library helpers for image/title handling, this is the recommended first step. +1. Upgrade to StreamDeck-Tools v7.0. +2. Retarget your plugin to `net48` (if using .NET Framework) or `net8.0`/`net9.0`. +3. Build and test -- existing calls like `SetImageAsync(Image, ...)`, `Tools.ImageToBase64`, `Tools.GenerateKeyImage` all work without changes. +4. Fix any `[Obsolete]` warnings (currently only `ToByteArray` -> `ToPngByteArray`). ### Path B: Proactive Modernization -- Move to base64/byte-based APIs for image updates. -- Reduce direct usage of `System.Drawing` in plugin code. -- Adopt new non-`System.Drawing` APIs as they are added. - -## API Mapping Template +- Move to base64/byte-based APIs for image updates (prefer `SetImageAsync(string base64Image, ...)`). +- Use `Image.ToPngByteArray()` instead of `Image.ToByteArray()`. +- Reduce direct usage of `System.Drawing` in plugin code where possible. -Use this template while migrating plugin code: +## API Mapping -| Current API | New API | Migration effort | Notes | +| Current API | Replacement | Effort | Notes | | --- | --- | --- | --- | -| `SetImageAsync(Image, ...)` | `SetImageAsync(string base64Image, ...)` | rename + conversion | Convert image once and reuse encoded payload | -| `Tools.ImageToBase64(Image, ...)` | `Tools.ImageToBase64(Image, ...)` | no change | Legacy-compatible path retained | -| `Tools.Base64StringToImage(string)` | `Tools.Base64StringToImage(string)` | no change | Legacy-compatible path retained | -| `GraphicsTools.*` (`Image`/`Bitmap`) | upcoming backend-neutral equivalents | small-to-medium | Prefer new methods when available | +| `SetImageAsync(Image, ...)` | `SetImageAsync(string base64Image, ...)` | rename + conversion | Convert image once via `Tools.ImageToBase64` and reuse | +| `Tools.ImageToBase64(Image, ...)` | same | no change | Internally adapted, signature unchanged | +| `Tools.Base64StringToImage(string)` | same | no change | Internally adapted, signature unchanged | +| `Tools.FileToBase64(string, ...)` | same | no change | Internally adapted, signature unchanged | +| `Tools.ImageToSHA512(Image)` | same | no change | Internally adapted, signature unchanged | +| `Image.ToByteArray()` | `Image.ToPngByteArray()` | rename | BMP -> PNG. `[Obsolete]` warning guides you | +| `Image.ToBase64(bool)` | same | no change | Delegates to adapted `Tools.ImageToBase64` | +| `Tools.GenerateKeyImage(...)` | same (for now) | no change | Returns `Bitmap` + `Graphics`; future replacement planned | +| `GraphicsTools.*` | same (for now) | no change | System.Drawing signatures retained; future replacement planned | +| `TitleParameters` properties | same (for now) | no change | `Color`, `FontFamily`, `FontStyle` retained; future replacement planned | ## Direct System.Drawing Detection Checklist -Scan plugin code for direct dependencies: +Scan your plugin code for direct `System.Drawing` dependencies that may need future migration: - `using System.Drawing` - `new Bitmap(...)` @@ -53,12 +73,17 @@ rg "\\bFont\\b|FontFamily|FontStyle|SolidBrush|Brush\\b" --type cs ## Deprecation Timeline -- **Release N**: legacy APIs remain supported without forced warnings. -- **Release N+1**: targeted `[Obsolete]` warnings start, each with clear replacement guidance. -- **Next major**: removal candidates are evaluated after adoption feedback and compatibility results. +- **v7.0** (current): Legacy APIs remain fully supported. Only `Image.ToByteArray()` is marked `[Obsolete]`. +- **v7.x**: Additional `[Obsolete]` warnings will be added to APIs with `System.Drawing` types in signatures, each with clear replacement guidance. +- **v8.0**: Removal candidates evaluated after adoption feedback and compatibility results. ## Compatibility Notes - Exact pixel parity is not guaranteed for every text/layout edge case across rendering backends. - Functional parity is the target for common plugin scenarios (resize, encoding, title/image updates). -- Plugins with heavy direct `System.Drawing` usage may require focused migration work. +- Plugins with heavy direct `System.Drawing` usage may require focused migration when the backend swap completes. + +## Reference + +- [API Inventory](API_INVENTORY.md) -- Complete classification of every public API's System.Drawing exposure. +- [Pre-Existing Issues](PRE_EXISTING_ISSUES.md) -- Known issues unrelated to the migration. From fdd5a9285170d33703b32814195321e0b55ddb1b Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:36:02 +0200 Subject: [PATCH 04/32] Phase 2 quality gate: Fix API inventory counts and log pre-existing issues - Corrected KEPT count (10->9) and HIGH-RISK count (10->12) in API_INVENTORY.md - Fixed duplicate numbering (#17) across KEPT and HIGH-RISK sections - Updated GraphicsTools.cs per-file count (6->7) - Added two pre-existing resource leaks to PRE_EXISTING_ISSUES.md: - ExtensionMethods.AddTextPath: Pen, GraphicsPath, SolidBrush not disposed - ExtensionMethods.SplitToFitKey: Font not disposed Made-with: Cursor --- docs/API_INVENTORY.md | 30 +++++++++++++++--------------- docs/PRE_EXISTING_ISSUES.md | 10 ++++++++++ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/docs/API_INVENTORY.md b/docs/API_INVENTORY.md index 2846c5e..d43010a 100644 --- a/docs/API_INVENTORY.md +++ b/docs/API_INVENTORY.md @@ -10,12 +10,12 @@ Each entry is classified as: **ADAPTED**, **KEPT** (System.Drawing retained, fut | Classification | Count | Description | | --- | --- | --- | | ADAPTED | 8 | Routed through internal codec abstraction (Phase 1 + 2) | -| KEPT | 10 | System.Drawing types retained in signatures; will be marked [Obsolete] in a future release | -| HIGH-RISK | 10 | Deep entanglement with System.Drawing types in public signatures | +| KEPT | 9 | System.Drawing types retained in signatures; will be marked [Obsolete] in a future release | +| HIGH-RISK | 12 | Deep entanglement with System.Drawing types in public signatures | **Files with System.Drawing exposure:** - `Tools/Tools.cs` (5 APIs) -- `Tools/GraphicsTools.cs` (6 APIs) +- `Tools/GraphicsTools.cs` (7 APIs) - `Tools/ExtensionMethods.cs` (10 APIs) - `Backend/SDConnection.cs` (1 API) - `Backend/ISDConnection.cs` (1 API) @@ -84,28 +84,28 @@ Each entry is classified as: **ADAPTED**, **KEPT** (System.Drawing retained, fut | # | API | Signature | System.Drawing types | Notes | | --- | --- | --- | --- | --- | -| 17 | `GraphicsTools.ColorFromHex` | `public static Color ColorFromHex(string hexColor)` | `Color` (return) | Uses `ColorTranslator.FromHtml`. | -| 18 | `GraphicsTools.GenerateColorShades` | `public static Color GenerateColorShades(string initialColor, int currentShade, int totalAmountOfShades)` | `Color` (return) | Uses `Color.FromArgb`. | +| 18 | `GraphicsTools.ColorFromHex` | `public static Color ColorFromHex(string hexColor)` | `Color` (return) | Uses `ColorTranslator.FromHtml`. | +| 19 | `GraphicsTools.GenerateColorShades` | `public static Color GenerateColorShades(string initialColor, int currentShade, int totalAmountOfShades)` | `Color` (return) | Uses `Color.FromArgb`. | ### ExtensionMethods.cs | # | API | Signature | System.Drawing types | Notes | | --- | --- | --- | --- | --- | -| 19 | `Color.ToHex` | `public static string ToHex(this Color color)` | `Color` (this) | Extension on `Color`. | -| 20 | `Brush.ToHex` | `public static string ToHex(this Brush brush)` | `Brush` (this), `SolidBrush` | Extension on `Brush`. | -| 21 | `Graphics.DrawAndMeasureString` | `public static float DrawAndMeasureString(this Graphics graphics, string text, Font font, Brush brush, PointF position)` | `Graphics`, `Font`, `Brush`, `PointF` | Extension on `Graphics`. | -| 22 | `Graphics.GetTextCenter` | `public static float GetTextCenter(this Graphics graphics, string text, int imageWidth, Font font, out bool textFitsImage, int minIndentation)` | `Graphics`, `Font` | Extension on `Graphics`. Two overloads. | -| 23 | `Graphics.GetFontSizeWhereTextFitsImage` | `public static float GetFontSizeWhereTextFitsImage(this Graphics graphics, string text, int imageWidth, Font font, int minimalFontSize)` | `Graphics`, `Font` | Extension on `Graphics`. | -| 24 | `Graphics.AddTextPath` | `public static void AddTextPath(this Graphics graphics, TitleParameters titleParameters, int imageHeight, int imageWidth, string text, Color strokeColor, float strokeThickness, int pixelsAlignment)` | `Graphics`, `Color`, `TitleParameters` | Extension on `Graphics`. Two overloads. Uses `GraphicsPath`, `Pen`, `SolidBrush`. | +| 20 | `Color.ToHex` | `public static string ToHex(this Color color)` | `Color` (this) | Extension on `Color`. | +| 21 | `Brush.ToHex` | `public static string ToHex(this Brush brush)` | `Brush` (this), `SolidBrush` | Extension on `Brush`. | +| 22 | `Graphics.DrawAndMeasureString` | `public static float DrawAndMeasureString(this Graphics graphics, string text, Font font, Brush brush, PointF position)` | `Graphics`, `Font`, `Brush`, `PointF` | Extension on `Graphics`. | +| 23 | `Graphics.GetTextCenter` | `public static float GetTextCenter(this Graphics graphics, string text, int imageWidth, Font font, out bool textFitsImage, int minIndentation)` | `Graphics`, `Font` | Extension on `Graphics`. Two overloads. | +| 24 | `Graphics.GetFontSizeWhereTextFitsImage` | `public static float GetFontSizeWhereTextFitsImage(this Graphics graphics, string text, int imageWidth, Font font, int minimalFontSize)` | `Graphics`, `Font` | Extension on `Graphics`. | +| 25 | `Graphics.AddTextPath` | `public static void AddTextPath(this Graphics graphics, TitleParameters titleParameters, int imageHeight, int imageWidth, string text, Color strokeColor, float strokeThickness, int pixelsAlignment)` | `Graphics`, `Color`, `TitleParameters` | Extension on `Graphics`. Two overloads. Uses `GraphicsPath`, `Pen`, `SolidBrush`. | ### TitleParameters.cs (Wrappers) | # | API | Type | Notes | | --- | --- | --- | --- | -| 25 | `TitleParameters.TitleColor` | `Color` property | Public property, deserialized from JSON. | -| 26 | `TitleParameters.FontFamily` | `FontFamily` property | Public property, deserialized from JSON. | -| 27 | `TitleParameters.FontStyle` | `FontStyle` property (enum) | Public property, deserialized from JSON. | -| 28 | `TitleParameters(FontFamily, FontStyle, double, Color, bool, TitleVerticalAlignment)` | Constructor | Direct SD types in parameters. | +| 26 | `TitleParameters.TitleColor` | `Color` property | Public property, deserialized from JSON. | +| 27 | `TitleParameters.FontFamily` | `FontFamily` property | Public property, deserialized from JSON. | +| 28 | `TitleParameters.FontStyle` | `FontStyle` property (enum) | Public property, deserialized from JSON. | +| 29 | `TitleParameters(FontFamily, FontStyle, double, Color, bool, TitleVerticalAlignment)` | Constructor | Direct SD types in parameters. | --- diff --git a/docs/PRE_EXISTING_ISSUES.md b/docs/PRE_EXISTING_ISSUES.md index 74c2650..05297db 100644 --- a/docs/PRE_EXISTING_ISSUES.md +++ b/docs/PRE_EXISTING_ISSUES.md @@ -74,3 +74,13 @@ Issues discovered during migration quality gates that were **not introduced by t - **File**: `barraider-sdtools/Tools/Tools.cs` (lines ~340-355) - **Description**: `sha512.ComputeHash(null)` would throw `ArgumentNullException`. The exception is caught and logged, but a null guard would be cleaner. - **Impact**: Low; callers generally pass non-null, and the catch block handles it. + +### `ExtensionMethods.AddTextPath` leaks `Pen`, `GraphicsPath`, and `SolidBrush` +- **File**: `barraider-sdtools/Tools/ExtensionMethods.cs` (lines ~248-256) +- **Description**: `Pen`, `GraphicsPath`, and `SolidBrush` are created but never disposed. All three implement `IDisposable`. +- **Impact**: GDI+ handle leak on repeated calls. + +### `ExtensionMethods.SplitToFitKey` leaks `Font` +- **File**: `barraider-sdtools/Tools/ExtensionMethods.cs` (line ~311) +- **Description**: `Font font = new Font(...)` is never disposed. `Font` implements `IDisposable`. +- **Impact**: Minor GDI+ handle leak. From 0bded3b84da99b6a31edfaab4f4454a643357a14 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:39:28 +0200 Subject: [PATCH 05/32] Add plugin usage analysis documenting System.Drawing patterns across 27 plugins Analyzed 27 real-world plugins to validate v7.0 migration strategy: - 14 plugins (52%%) require zero changes (Tier 1) - 6 plugins (22%%) need minor migration via library helpers (Tier 2) - 7 plugins (26%%) require direct graphics library adoption (Tier 3) - TitleParameters confirmed critical: 14 plugins use it, must not change - GenerateKeyImage workflow is the dominant rendering pattern Made-with: Cursor --- docs/PLUGIN_USAGE_ANALYSIS.md | 135 ++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/PLUGIN_USAGE_ANALYSIS.md diff --git a/docs/PLUGIN_USAGE_ANALYSIS.md b/docs/PLUGIN_USAGE_ANALYSIS.md new file mode 100644 index 0000000..e8ce084 --- /dev/null +++ b/docs/PLUGIN_USAGE_ANALYSIS.md @@ -0,0 +1,135 @@ +# Plugin Usage Analysis + +Analysis of 27 real-world Stream Deck plugins that depend on `streamdeck-tools`, conducted to validate the v7.0 migration strategy and inform Phase 3 planning. + +**Source**: `E:\Projects\DotNet\StreamDeck\All_Plugins.sln` (28 projects, 27 unique plugins) + +--- + +## Summary + +| Category | Count | % | Description | +| --- | --- | --- | --- | +| No direct System.Drawing usage | 14 | 52% | Use only library helpers; will "just work" with v7.0 | +| Direct System.Drawing -- LOW complexity | 6 | 22% | Common patterns (SolidBrush, Font, ColorTranslator) | +| Direct System.Drawing -- MEDIUM complexity | 3 | 11% | Multiple custom-drawn actions, transforms | +| Direct System.Drawing -- HIGH complexity | 4 | 15% | Deep GDI+ (GraphicsPath, LockBits, pixel access) | + +--- + +## Migration Tiers + +### Tier 1: No Changes Needed (14 plugins) + +These plugins solely rely on `streamdeck-tools` library helpers and do not directly interact with `System.Drawing` types in their own code. They will work with v7.0 (and any future backend changes) with only a NuGet package upgrade. + +| Plugin | Notes | +| --- | --- | +| streamdeck-voicemeeter | Library helpers only | +| streamdeck-stockticker | Library helpers only | +| streamdeck-shadowplay | Library helpers only | +| streamdeck-soundpad | Library helpers only | +| streamdeck-streamcounter | Library helpers only | +| streamdeck-supermacro | Library helpers only | +| streamdeck-windowsmover | Library helpers only | +| streamdeck-battery | Library helpers only | +| streamdeck-audiometer | Library helpers only | +| streamdeck-speedtest | Library helpers only | +| streamdeck-webcam | Library helpers only | +| streamdeck-TextToSpeech | Library helpers only | +| BarRaiderVirtualDesktop | Library helpers only | +| BarRaiderAudio | Library helpers only | + +### Tier 2: Low Effort Migration (6 plugins) + +These plugins use some direct `System.Drawing` types, but only in common, easily abstractable patterns. The library can provide helper methods to replace these in a future release. + +| Plugin | Direct SD Patterns | Migration Path | +| --- | --- | --- | +| streamdeck-spotify | `SolidBrush`, `ColorTranslator.FromHtml`, `Font` | Replace with library color/font helpers | +| streamdeck-streamtimer | `SolidBrush`, `Font` | Replace with library color/font helpers | +| streamdeck-obstools | `SolidBrush`, `ColorTranslator.FromHtml` | Replace with library color helper | +| streamdeck-textfiletools | `SolidBrush`, `Font`, `Image.FromFile` | Replace with library helpers | +| streamdeck-stopwatch | `SolidBrush`, `Font` | Replace with library color/font helpers | +| streamdeck-minecraft | `Image.FromFile`, `Bitmap` | Replace with library image loading helper | + +### Tier 3: High Effort Migration (7 plugins) + +These plugins use deep GDI+ APIs that cannot be transparently abstracted by the library. Full migration will require plugin developers to adopt a replacement graphics library (e.g., SkiaSharp) directly. + +| Plugin | Complexity | Direct SD Patterns | Notes | +| --- | --- | --- | --- | +| streamdeck-chatpager | HIGH | `GraphicsPath`, `StringFormat`, `AddString`, `SolidBrush`, `Font` | Complex text rendering with paths | +| streamdeck-games | HIGH | Multiple game managers with `Graphics.FromImage`, `Bitmap`, `SolidBrush`, `Pen`, custom drawing | Full custom game rendering | +| streamdeck-screensaver | HIGH | `LockBits`, `BitmapData`, pixel-level access | Direct pixel manipulation | +| streamdeck-advancedlauncher | HIGH | `LockBits`, `BitmapData`, `SetPixel` | Icon extraction and pixel manipulation | +| streamdeck-disco | MEDIUM | Matrix effects, `RotateTransform`, `SolidBrush`, `Bitmap` | Transform-heavy animation | +| streamdeck-wintools | MEDIUM | Multiple custom-drawn actions, `SolidBrush`, `Font`, `Bitmap` | Several action classes with drawing | +| barraider-spotify | MEDIUM | `Image.FromFile`, `Image.FromStream`, image buffering/caching | Image loading and caching patterns | + +--- + +## Common Direct System.Drawing Patterns + +Frequency of direct `System.Drawing` type usage across the 13 plugins that use them: + +| Pattern | Plugin Count | Library Abstraction Status | +| --- | --- | --- | +| `new SolidBrush(...)` | 9 | Not yet abstracted; candidate for Tier 2 helper | +| `new Bitmap(...)` | 7 | Partially abstracted via `IImageCodec.DecodeFromBytes/File` | +| `new Font(...)` | 7 | Not yet abstracted; candidate for Tier 2 helper | +| `Graphics.FromImage(...)` | 7 | Used via `GenerateKeyImage` pattern in library | +| `ColorTranslator.FromHtml(...)` | 5 | Not yet abstracted; candidate for Tier 2 helper (`GraphicsTools.ColorFromHex` exists but uses `ColorTranslator` internally) | +| `Image.FromFile` / `Image.FromStream` | 5 | Partially abstracted via `IImageCodec.DecodeFromFile` | +| `GraphicsPath` | 2 | Cannot be transparently abstracted | +| `LockBits` / `BitmapData` | 2 | Cannot be transparently abstracted | +| `StringFormat` | 2 | Cannot be transparently abstracted | +| `RotateTransform` | 1 | Cannot be transparently abstracted | + +--- + +## Critical API Findings + +### TitleParameters Must Not Change + +- **14 plugins** use `TitleParameters` (received from Stream Deck events) +- **3 plugins** construct `TitleParameters` directly (spotify, chatpager x2) +- **4 plugins** read the `TitleColor` property +- **4 plugins** construct `Font` from `TitleParameters.FontFamily` and `FontSizeInPixels` +- Usage is almost entirely **read-only** (store from event, pass to library API) +- The constructor signature `(FontFamily, FontStyle, double, Color, bool, TitleVerticalAlignment)` is part of the public contract and must remain stable + +**Conclusion**: `TitleParameters` property types (`Color`, `FontFamily`, `FontStyle`) and constructor must remain unchanged in v7.0. Any future changes require a parallel new type with an adapter. + +### GenerateKeyImage Is the Dominant Workflow + +The canonical plugin workflow for custom rendering: + +1. `Tools.GenerateGenericKeyImage(out Graphics graphics)` -- get `Bitmap` + `Graphics` +2. Draw with `Graphics` (`FillRectangle`, `AddTextPath`, `DrawString`, etc.) +3. `graphics.Dispose()` +4. `await Connection.SetImageAsync(image)` -- send to deck + +This is used by nearly all plugins that render custom images. The library controls both the image creation and the sending; the `Graphics` object is the "escape hatch" where plugins perform arbitrary drawing. Any future backend swap must continue to return a valid `System.Drawing.Graphics` on `net48`, since plugins draw directly on it. + +--- + +## Implications for Phase 3 + +### Validated Decisions + +1. **Phase 0-2 work is solid** -- no rollback needed +2. **The codec abstraction** (`IImageCodec`, `ImageCodecProvider`) is correct and complete for encode/decode +3. **The ADAPTED APIs** (#1-#8 in API_INVENTORY.md) are properly routed through the abstraction +4. **The KEPT APIs** (#9-#17 in API_INVENTORY.md) correctly retain System.Drawing signatures +5. **The `[Obsolete]` on `ToByteArray`** is appropriate + +### Recommendations + +1. **TitleParameters MUST remain unchanged in v7.0** -- 3 plugins construct it, 14 read it. Any change breaks half the ecosystem. +2. **Cross-platform (macOS) is a stretch goal, not a blocker** -- all 27 plugins are Windows-only (`net48`, Stream Deck SDK plugins are Windows executables). Cross-platform is future-facing. +3. **Library should add helpers for common Tier 2 patterns** to ease migration: + - Color parsing from HTML hex (replacing direct `ColorTranslator.FromHtml` calls) + - Image loading from file/stream (partially done via `DecodeFromFile`) + - Font creation helper (from family name, size, style) +4. **Phase 3 should prioritize expanding the abstraction layer** (Option B) over immediately adding a SkiaSharp backend (Option A), as this provides immediate value to the 6 Tier 2 plugins without adding a large dependency. From 70c606fe7669f056631949766fb3779e3e925a22 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:41:56 +0200 Subject: [PATCH 06/32] Remove internal planning docs from repo, add docs/ to .gitignore Planning/tracking docs (API_INVENTORY, MIGRATION, PRE_EXISTING_ISSUES, PLUGIN_USAGE_ANALYSIS) are kept locally but should not be pushed to the repository. Made-with: Cursor --- .gitignore | 3 + docs/API_INVENTORY.md | 144 ---------------------------------- docs/MIGRATION.md | 89 --------------------- docs/PLUGIN_USAGE_ANALYSIS.md | 135 ------------------------------- docs/PRE_EXISTING_ISSUES.md | 86 -------------------- 5 files changed, 3 insertions(+), 454 deletions(-) delete mode 100644 docs/API_INVENTORY.md delete mode 100644 docs/MIGRATION.md delete mode 100644 docs/PLUGIN_USAGE_ANALYSIS.md delete mode 100644 docs/PRE_EXISTING_ISSUES.md diff --git a/.gitignore b/.gitignore index 4d13c54..219ddf0 100644 --- a/.gitignore +++ b/.gitignore @@ -332,3 +332,6 @@ ASALocalRun/ # Local History for Visual Studio .localhistory/ + +# Internal migration planning docs (not shipped with the library) +docs/ diff --git a/docs/API_INVENTORY.md b/docs/API_INVENTORY.md deleted file mode 100644 index d43010a..0000000 --- a/docs/API_INVENTORY.md +++ /dev/null @@ -1,144 +0,0 @@ -# System.Drawing Public API Inventory - -Complete inventory of every public API in `barraider-sdtools` that exposes `System.Drawing` types. -Each entry is classified as: **ADAPTED**, **KEPT** (System.Drawing retained, future [Obsolete]), or **HIGH-RISK**. - ---- - -## Summary - -| Classification | Count | Description | -| --- | --- | --- | -| ADAPTED | 8 | Routed through internal codec abstraction (Phase 1 + 2) | -| KEPT | 9 | System.Drawing types retained in signatures; will be marked [Obsolete] in a future release | -| HIGH-RISK | 12 | Deep entanglement with System.Drawing types in public signatures | - -**Files with System.Drawing exposure:** -- `Tools/Tools.cs` (5 APIs) -- `Tools/GraphicsTools.cs` (7 APIs) -- `Tools/ExtensionMethods.cs` (10 APIs) -- `Backend/SDConnection.cs` (1 API) -- `Backend/ISDConnection.cs` (1 API) -- `Wrappers/TitleParameters.cs` (3 properties + 2 constructors) - ---- - -## ADAPTED (Phase 1 + Phase 2 -- complete) - -### Tools.cs - -| # | API | Signature | System.Drawing types | Notes | -| --- | --- | --- | --- | --- | -| 1 | `Tools.ImageToBase64` | `public static string ImageToBase64(Image image, bool addHeaderPrefix)` | `Image` (param) | Routes through `ImageCodecProvider.Instance.EncodeToPngBytes` | -| 2 | `Tools.Base64StringToImage` | `public static Image Base64StringToImage(string base64String)` | `Image` (return) | Routes through `ImageCodecProvider.Instance.DecodeFromBytes` | -| 3 | `Tools.ImageToSHA512` | `public static string ImageToSHA512(Image image)` | `Image` (param) | Routes through `ImageCodecProvider.Instance.EncodeToPngBytes` | -| 4 | `Tools.FileToBase64` | `public static string FileToBase64(string fileName, bool addHeaderPrefix)` | `Image` (internal only) | Routes through `ImageCodecProvider.Instance.DecodeFromFile`. No public SD exposure. | - -### SDConnection.cs / ISDConnection.cs - -| # | API | Signature | System.Drawing types | Notes | -| --- | --- | --- | --- | --- | -| 5 | `SDConnection.SetImageAsync` | `public async Task SetImageAsync(Image image, int? state, bool forceSendToStreamdeck)` | `Image` (param) | Converts to base64 via `Tools.ImageToBase64`, then calls string overload | -| 6 | `ISDConnection.SetImageAsync` | `Task SetImageAsync(Image image, int? state, bool forceSendToStreamdeck)` | `Image` (param) | Interface declaration matching #5 | - -### ExtensionMethods.cs - -| # | API | Signature | System.Drawing types | Notes | -| --- | --- | --- | --- | --- | -| 7 | `Image.ToBase64` | `public static string ToBase64(this Image image, bool addHeaderPrefix)` | `Image` (this) | Delegates to adapted `Tools.ImageToBase64` | -| 8 | `Image.ToPngByteArray` | `public static byte[] ToPngByteArray(this Image image)` | `Image` (this) | **NEW** -- routes through `ImageCodecProvider.Instance.EncodeToPngBytes` | - ---- - -## KEPT (System.Drawing signatures retained -- future [Obsolete]) - -### Tools.cs - -| # | API | Signature | System.Drawing types | Notes | -| --- | --- | --- | --- | --- | -| 9 | `Tools.GenerateKeyImage` | `public static Bitmap GenerateKeyImage(DeviceType streamDeckType, out Graphics graphics)` | `Bitmap` (return), `Graphics` (out) | Core drawing entry point for plugins. Kept as-is. | -| 10 | `Tools.GenerateGenericKeyImage` | `public static Bitmap GenerateGenericKeyImage(out Graphics graphics)` | `Bitmap` (return), `Graphics` (out) | Delegates to #9. | - -### GraphicsTools.cs - -| # | API | Signature | System.Drawing types | Notes | -| --- | --- | --- | --- | --- | -| 11 | `GraphicsTools.ResizeImage` | `public static Image ResizeImage(Image original, int newWidth, int newHeight)` | `Image` (param + return) | Kept as-is; System.Drawing types in signature. | -| 12 | `GraphicsTools.ExtractRectangle` | `public static Bitmap ExtractRectangle(Image image, int startX, int startY, int width, int height)` | `Image` (param), `Bitmap` (return) | Kept as-is. | -| 13 | `GraphicsTools.CreateOpacityImage` | `public static Image CreateOpacityImage(Image image, float opacity)` | `Image` (param + return) | Kept as-is. | -| 14 | `GraphicsTools.DrawMultiLinedText` | `public static Image[] DrawMultiLinedText(...)` | `Font`, `Color` (params), `Image[]` (return), `PointF` | Kept as-is. | -| 15 | `GraphicsTools.WrapStringToFitImage` | `public static string WrapStringToFitImage(...)` | Uses `Font`, `Bitmap`, `Graphics` internally | Kept as-is. | - -### ExtensionMethods.cs - -| # | API | Signature | System.Drawing types | Notes | -| --- | --- | --- | --- | --- | -| 16 | `Image.ToByteArray` | `public static byte[] ToByteArray(this Image image)` | `Image` (this) | Marked `[Obsolete]` -- use `ToPngByteArray` instead. BMP encoding kept for backward compatibility. | -| 17 | `string.SplitToFitKey` | `public static string SplitToFitKey(this string str, TitleParameters titleParameters, ...)` | Uses `Font`, `Bitmap`, `Graphics` internally | Kept as-is. | - ---- - -## HIGH-RISK (deep System.Drawing entanglement in public surface) - -### GraphicsTools.cs - -| # | API | Signature | System.Drawing types | Notes | -| --- | --- | --- | --- | --- | -| 18 | `GraphicsTools.ColorFromHex` | `public static Color ColorFromHex(string hexColor)` | `Color` (return) | Uses `ColorTranslator.FromHtml`. | -| 19 | `GraphicsTools.GenerateColorShades` | `public static Color GenerateColorShades(string initialColor, int currentShade, int totalAmountOfShades)` | `Color` (return) | Uses `Color.FromArgb`. | - -### ExtensionMethods.cs - -| # | API | Signature | System.Drawing types | Notes | -| --- | --- | --- | --- | --- | -| 20 | `Color.ToHex` | `public static string ToHex(this Color color)` | `Color` (this) | Extension on `Color`. | -| 21 | `Brush.ToHex` | `public static string ToHex(this Brush brush)` | `Brush` (this), `SolidBrush` | Extension on `Brush`. | -| 22 | `Graphics.DrawAndMeasureString` | `public static float DrawAndMeasureString(this Graphics graphics, string text, Font font, Brush brush, PointF position)` | `Graphics`, `Font`, `Brush`, `PointF` | Extension on `Graphics`. | -| 23 | `Graphics.GetTextCenter` | `public static float GetTextCenter(this Graphics graphics, string text, int imageWidth, Font font, out bool textFitsImage, int minIndentation)` | `Graphics`, `Font` | Extension on `Graphics`. Two overloads. | -| 24 | `Graphics.GetFontSizeWhereTextFitsImage` | `public static float GetFontSizeWhereTextFitsImage(this Graphics graphics, string text, int imageWidth, Font font, int minimalFontSize)` | `Graphics`, `Font` | Extension on `Graphics`. | -| 25 | `Graphics.AddTextPath` | `public static void AddTextPath(this Graphics graphics, TitleParameters titleParameters, int imageHeight, int imageWidth, string text, Color strokeColor, float strokeThickness, int pixelsAlignment)` | `Graphics`, `Color`, `TitleParameters` | Extension on `Graphics`. Two overloads. Uses `GraphicsPath`, `Pen`, `SolidBrush`. | - -### TitleParameters.cs (Wrappers) - -| # | API | Type | Notes | -| --- | --- | --- | --- | -| 26 | `TitleParameters.TitleColor` | `Color` property | Public property, deserialized from JSON. | -| 27 | `TitleParameters.FontFamily` | `FontFamily` property | Public property, deserialized from JSON. | -| 28 | `TitleParameters.FontStyle` | `FontStyle` property (enum) | Public property, deserialized from JSON. | -| 29 | `TitleParameters(FontFamily, FontStyle, double, Color, bool, TitleVerticalAlignment)` | Constructor | Direct SD types in parameters. | - ---- - -## Dependency Graph - -``` -Plugin Code - | - +-- SDConnection.SetImageAsync(Image) ........... [ADAPTED] - +-- Tools.ImageToBase64(Image) .................. [ADAPTED] - +-- Tools.Base64StringToImage(string) ........... [ADAPTED] - +-- Tools.FileToBase64(string) .................. [ADAPTED] - +-- Image.ToPngByteArray() ...................... [ADAPTED - NEW] - +-- Image.ToByteArray() ......................... [OBSOLETE -> ToPngByteArray] - +-- Tools.GenerateKeyImage(DeviceType, out Graphics) ... [KEPT - returns Bitmap+Graphics] - | | - | +-- Plugin draws on Graphics, then calls SetImageAsync(Image) - | - +-- GraphicsTools.ResizeImage(Image) ............ [KEPT] - +-- GraphicsTools.CreateOpacityImage(Image) ..... [KEPT] - +-- GraphicsTools.DrawMultiLinedText(...) ........ [KEPT] - +-- ExtensionMethods on Graphics ................ [HIGH-RISK - direct SD types] - +-- TitleParameters (Color/FontFamily/FontStyle) [HIGH-RISK - data class] -``` - ---- - -## Future Deprecation Priority - -When the time comes to mark KEPT APIs as `[Obsolete]` and provide parallel replacements: - -1. **`Tools.GenerateKeyImage` / `GenerateGenericKeyImage`** -- Most commonly used plugin entry point for drawing. -2. **`GraphicsTools.ResizeImage` / `ExtractRectangle` / `CreateOpacityImage`** -- Image manipulation utilities, frequently used. -3. **`GraphicsTools.DrawMultiLinedText`** -- Text rendering, depends on Font/Color. -4. **`TitleParameters`** -- Core data class. Needs careful deprecation strategy since plugins read properties directly. -5. **Extension methods on `Graphics`** -- Lowest priority; advanced drawing helpers tightly coupled to `System.Drawing.Graphics`. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md deleted file mode 100644 index cd7ae84..0000000 --- a/docs/MIGRATION.md +++ /dev/null @@ -1,89 +0,0 @@ -# Migration Guide - -This guide covers migration from legacy `System.Drawing`-first plugin flows to the new compatibility model in `StreamDeck-Tools` v7.0. - -## Goals - -- Existing plugins should continue to work with minimal or no code changes. -- New development should move to non-`System.Drawing`-centric APIs over time. -- Migration should be incremental and predictable. - -## What changed in v7.0 - -### Target Frameworks -- **Before**: `netstandard2.0` + `net8.0` -- **After**: `net48` + `net8.0` + `net9.0` - -### Internal graphics abstraction -All image encode/decode operations now route through an internal `IImageCodec` abstraction backed by `System.Drawing`. This is transparent to plugins -- existing code continues to work without changes. - -### New API: `Image.ToPngByteArray()` -Encodes an image to PNG bytes via the codec abstraction. Replaces the old `Image.ToByteArray()` which used BMP format. - -### Deprecated API: `Image.ToByteArray()` -Marked `[Obsolete]`. Encodes as BMP which will not be supported in future backend swaps. Use `Image.ToPngByteArray()` instead. - -## Upgrade Paths - -### Path A: No-Code-Change (recommended first step) - -1. Upgrade to StreamDeck-Tools v7.0. -2. Retarget your plugin to `net48` (if using .NET Framework) or `net8.0`/`net9.0`. -3. Build and test -- existing calls like `SetImageAsync(Image, ...)`, `Tools.ImageToBase64`, `Tools.GenerateKeyImage` all work without changes. -4. Fix any `[Obsolete]` warnings (currently only `ToByteArray` -> `ToPngByteArray`). - -### Path B: Proactive Modernization - -- Move to base64/byte-based APIs for image updates (prefer `SetImageAsync(string base64Image, ...)`). -- Use `Image.ToPngByteArray()` instead of `Image.ToByteArray()`. -- Reduce direct usage of `System.Drawing` in plugin code where possible. - -## API Mapping - -| Current API | Replacement | Effort | Notes | -| --- | --- | --- | --- | -| `SetImageAsync(Image, ...)` | `SetImageAsync(string base64Image, ...)` | rename + conversion | Convert image once via `Tools.ImageToBase64` and reuse | -| `Tools.ImageToBase64(Image, ...)` | same | no change | Internally adapted, signature unchanged | -| `Tools.Base64StringToImage(string)` | same | no change | Internally adapted, signature unchanged | -| `Tools.FileToBase64(string, ...)` | same | no change | Internally adapted, signature unchanged | -| `Tools.ImageToSHA512(Image)` | same | no change | Internally adapted, signature unchanged | -| `Image.ToByteArray()` | `Image.ToPngByteArray()` | rename | BMP -> PNG. `[Obsolete]` warning guides you | -| `Image.ToBase64(bool)` | same | no change | Delegates to adapted `Tools.ImageToBase64` | -| `Tools.GenerateKeyImage(...)` | same (for now) | no change | Returns `Bitmap` + `Graphics`; future replacement planned | -| `GraphicsTools.*` | same (for now) | no change | System.Drawing signatures retained; future replacement planned | -| `TitleParameters` properties | same (for now) | no change | `Color`, `FontFamily`, `FontStyle` retained; future replacement planned | - -## Direct System.Drawing Detection Checklist - -Scan your plugin code for direct `System.Drawing` dependencies that may need future migration: - -- `using System.Drawing` -- `new Bitmap(...)` -- `Graphics.FromImage(...)` -- `Image.FromFile(...)` / `Image.FromStream(...)` -- `Font`, `FontFamily`, `FontStyle`, `Brush`, `SolidBrush` - -Suggested search commands: - -```bash -rg "using System\\.Drawing" --type cs -rg "Bitmap\\(|Graphics\\.FromImage|Image\\.From(File|Stream)" --type cs -rg "\\bFont\\b|FontFamily|FontStyle|SolidBrush|Brush\\b" --type cs -``` - -## Deprecation Timeline - -- **v7.0** (current): Legacy APIs remain fully supported. Only `Image.ToByteArray()` is marked `[Obsolete]`. -- **v7.x**: Additional `[Obsolete]` warnings will be added to APIs with `System.Drawing` types in signatures, each with clear replacement guidance. -- **v8.0**: Removal candidates evaluated after adoption feedback and compatibility results. - -## Compatibility Notes - -- Exact pixel parity is not guaranteed for every text/layout edge case across rendering backends. -- Functional parity is the target for common plugin scenarios (resize, encoding, title/image updates). -- Plugins with heavy direct `System.Drawing` usage may require focused migration when the backend swap completes. - -## Reference - -- [API Inventory](API_INVENTORY.md) -- Complete classification of every public API's System.Drawing exposure. -- [Pre-Existing Issues](PRE_EXISTING_ISSUES.md) -- Known issues unrelated to the migration. diff --git a/docs/PLUGIN_USAGE_ANALYSIS.md b/docs/PLUGIN_USAGE_ANALYSIS.md deleted file mode 100644 index e8ce084..0000000 --- a/docs/PLUGIN_USAGE_ANALYSIS.md +++ /dev/null @@ -1,135 +0,0 @@ -# Plugin Usage Analysis - -Analysis of 27 real-world Stream Deck plugins that depend on `streamdeck-tools`, conducted to validate the v7.0 migration strategy and inform Phase 3 planning. - -**Source**: `E:\Projects\DotNet\StreamDeck\All_Plugins.sln` (28 projects, 27 unique plugins) - ---- - -## Summary - -| Category | Count | % | Description | -| --- | --- | --- | --- | -| No direct System.Drawing usage | 14 | 52% | Use only library helpers; will "just work" with v7.0 | -| Direct System.Drawing -- LOW complexity | 6 | 22% | Common patterns (SolidBrush, Font, ColorTranslator) | -| Direct System.Drawing -- MEDIUM complexity | 3 | 11% | Multiple custom-drawn actions, transforms | -| Direct System.Drawing -- HIGH complexity | 4 | 15% | Deep GDI+ (GraphicsPath, LockBits, pixel access) | - ---- - -## Migration Tiers - -### Tier 1: No Changes Needed (14 plugins) - -These plugins solely rely on `streamdeck-tools` library helpers and do not directly interact with `System.Drawing` types in their own code. They will work with v7.0 (and any future backend changes) with only a NuGet package upgrade. - -| Plugin | Notes | -| --- | --- | -| streamdeck-voicemeeter | Library helpers only | -| streamdeck-stockticker | Library helpers only | -| streamdeck-shadowplay | Library helpers only | -| streamdeck-soundpad | Library helpers only | -| streamdeck-streamcounter | Library helpers only | -| streamdeck-supermacro | Library helpers only | -| streamdeck-windowsmover | Library helpers only | -| streamdeck-battery | Library helpers only | -| streamdeck-audiometer | Library helpers only | -| streamdeck-speedtest | Library helpers only | -| streamdeck-webcam | Library helpers only | -| streamdeck-TextToSpeech | Library helpers only | -| BarRaiderVirtualDesktop | Library helpers only | -| BarRaiderAudio | Library helpers only | - -### Tier 2: Low Effort Migration (6 plugins) - -These plugins use some direct `System.Drawing` types, but only in common, easily abstractable patterns. The library can provide helper methods to replace these in a future release. - -| Plugin | Direct SD Patterns | Migration Path | -| --- | --- | --- | -| streamdeck-spotify | `SolidBrush`, `ColorTranslator.FromHtml`, `Font` | Replace with library color/font helpers | -| streamdeck-streamtimer | `SolidBrush`, `Font` | Replace with library color/font helpers | -| streamdeck-obstools | `SolidBrush`, `ColorTranslator.FromHtml` | Replace with library color helper | -| streamdeck-textfiletools | `SolidBrush`, `Font`, `Image.FromFile` | Replace with library helpers | -| streamdeck-stopwatch | `SolidBrush`, `Font` | Replace with library color/font helpers | -| streamdeck-minecraft | `Image.FromFile`, `Bitmap` | Replace with library image loading helper | - -### Tier 3: High Effort Migration (7 plugins) - -These plugins use deep GDI+ APIs that cannot be transparently abstracted by the library. Full migration will require plugin developers to adopt a replacement graphics library (e.g., SkiaSharp) directly. - -| Plugin | Complexity | Direct SD Patterns | Notes | -| --- | --- | --- | --- | -| streamdeck-chatpager | HIGH | `GraphicsPath`, `StringFormat`, `AddString`, `SolidBrush`, `Font` | Complex text rendering with paths | -| streamdeck-games | HIGH | Multiple game managers with `Graphics.FromImage`, `Bitmap`, `SolidBrush`, `Pen`, custom drawing | Full custom game rendering | -| streamdeck-screensaver | HIGH | `LockBits`, `BitmapData`, pixel-level access | Direct pixel manipulation | -| streamdeck-advancedlauncher | HIGH | `LockBits`, `BitmapData`, `SetPixel` | Icon extraction and pixel manipulation | -| streamdeck-disco | MEDIUM | Matrix effects, `RotateTransform`, `SolidBrush`, `Bitmap` | Transform-heavy animation | -| streamdeck-wintools | MEDIUM | Multiple custom-drawn actions, `SolidBrush`, `Font`, `Bitmap` | Several action classes with drawing | -| barraider-spotify | MEDIUM | `Image.FromFile`, `Image.FromStream`, image buffering/caching | Image loading and caching patterns | - ---- - -## Common Direct System.Drawing Patterns - -Frequency of direct `System.Drawing` type usage across the 13 plugins that use them: - -| Pattern | Plugin Count | Library Abstraction Status | -| --- | --- | --- | -| `new SolidBrush(...)` | 9 | Not yet abstracted; candidate for Tier 2 helper | -| `new Bitmap(...)` | 7 | Partially abstracted via `IImageCodec.DecodeFromBytes/File` | -| `new Font(...)` | 7 | Not yet abstracted; candidate for Tier 2 helper | -| `Graphics.FromImage(...)` | 7 | Used via `GenerateKeyImage` pattern in library | -| `ColorTranslator.FromHtml(...)` | 5 | Not yet abstracted; candidate for Tier 2 helper (`GraphicsTools.ColorFromHex` exists but uses `ColorTranslator` internally) | -| `Image.FromFile` / `Image.FromStream` | 5 | Partially abstracted via `IImageCodec.DecodeFromFile` | -| `GraphicsPath` | 2 | Cannot be transparently abstracted | -| `LockBits` / `BitmapData` | 2 | Cannot be transparently abstracted | -| `StringFormat` | 2 | Cannot be transparently abstracted | -| `RotateTransform` | 1 | Cannot be transparently abstracted | - ---- - -## Critical API Findings - -### TitleParameters Must Not Change - -- **14 plugins** use `TitleParameters` (received from Stream Deck events) -- **3 plugins** construct `TitleParameters` directly (spotify, chatpager x2) -- **4 plugins** read the `TitleColor` property -- **4 plugins** construct `Font` from `TitleParameters.FontFamily` and `FontSizeInPixels` -- Usage is almost entirely **read-only** (store from event, pass to library API) -- The constructor signature `(FontFamily, FontStyle, double, Color, bool, TitleVerticalAlignment)` is part of the public contract and must remain stable - -**Conclusion**: `TitleParameters` property types (`Color`, `FontFamily`, `FontStyle`) and constructor must remain unchanged in v7.0. Any future changes require a parallel new type with an adapter. - -### GenerateKeyImage Is the Dominant Workflow - -The canonical plugin workflow for custom rendering: - -1. `Tools.GenerateGenericKeyImage(out Graphics graphics)` -- get `Bitmap` + `Graphics` -2. Draw with `Graphics` (`FillRectangle`, `AddTextPath`, `DrawString`, etc.) -3. `graphics.Dispose()` -4. `await Connection.SetImageAsync(image)` -- send to deck - -This is used by nearly all plugins that render custom images. The library controls both the image creation and the sending; the `Graphics` object is the "escape hatch" where plugins perform arbitrary drawing. Any future backend swap must continue to return a valid `System.Drawing.Graphics` on `net48`, since plugins draw directly on it. - ---- - -## Implications for Phase 3 - -### Validated Decisions - -1. **Phase 0-2 work is solid** -- no rollback needed -2. **The codec abstraction** (`IImageCodec`, `ImageCodecProvider`) is correct and complete for encode/decode -3. **The ADAPTED APIs** (#1-#8 in API_INVENTORY.md) are properly routed through the abstraction -4. **The KEPT APIs** (#9-#17 in API_INVENTORY.md) correctly retain System.Drawing signatures -5. **The `[Obsolete]` on `ToByteArray`** is appropriate - -### Recommendations - -1. **TitleParameters MUST remain unchanged in v7.0** -- 3 plugins construct it, 14 read it. Any change breaks half the ecosystem. -2. **Cross-platform (macOS) is a stretch goal, not a blocker** -- all 27 plugins are Windows-only (`net48`, Stream Deck SDK plugins are Windows executables). Cross-platform is future-facing. -3. **Library should add helpers for common Tier 2 patterns** to ease migration: - - Color parsing from HTML hex (replacing direct `ColorTranslator.FromHtml` calls) - - Image loading from file/stream (partially done via `DecodeFromFile`) - - Font creation helper (from family name, size, style) -4. **Phase 3 should prioritize expanding the abstraction layer** (Option B) over immediately adding a SkiaSharp backend (Option A), as this provides immediate value to the 6 Tier 2 plugins without adding a large dependency. diff --git a/docs/PRE_EXISTING_ISSUES.md b/docs/PRE_EXISTING_ISSUES.md deleted file mode 100644 index 05297db..0000000 --- a/docs/PRE_EXISTING_ISSUES.md +++ /dev/null @@ -1,86 +0,0 @@ -# Pre-Existing Issues - -Issues discovered during migration quality gates that were **not introduced by the migration**. These are tracked here for future resolution and are out of scope for the current migration work. - -## CRITICAL - -### `StreamDeckConnection.SendAsync(IMessage)` returns null on serialization failure -- **File**: `barraider-sdtools/Communication/StreamDeckConnection.cs` (lines ~170-180) -- **Description**: When `JsonConvert.SerializeObject` throws, the method returns `null`. Callers `await` the result, so `await null` throws `NullReferenceException`, hiding the original serialization error. -- **Impact**: Any serialization failure crashes the plugin with a misleading exception. - -## HIGH - -### `Tools.GenerateKeyImage` leaks `SolidBrush` -- **File**: `barraider-sdtools/Tools/Tools.cs` (lines ~198-207) -- **Description**: `SolidBrush` created for the background fill is never disposed. GDI+ brush handles are a limited resource. -- **Impact**: Repeated key image generation can exhaust GDI+ handles over time. - -### `Tools.AutoLoadPluginActions` does not null-check `Assembly.GetEntryAssembly()` -- **File**: `barraider-sdtools/Tools/Tools.cs` (line ~467) -- **Description**: `Assembly.GetEntryAssembly()` can return `null` in hosted or test scenarios. Calling `.GetTypes()` on null causes `NullReferenceException`. -- **Impact**: Plugin crashes in non-standard hosting environments. - -## MEDIUM - -### `SDConnection.previousImageHash` is not thread-safe -- **File**: `barraider-sdtools/Backend/SDConnection.cs` (lines ~24, 240-263) -- **Description**: `previousImageHash` field is read and written without synchronization. Concurrent `SetImageAsync` calls can race. -- **Impact**: Possible duplicate image sends or skipped updates under concurrent access. - -### `SDConnection.Dispose()` does not null-check `StreamDeckConnection` -- **File**: `barraider-sdtools/Backend/SDConnection.cs` (lines ~143-152) -- **Description**: If `StreamDeckConnection` were null after partial construction, unsubscribing events would throw. -- **Impact**: Low probability but possible crash during error-path disposal. - -### `StreamDeckConnection.OpenUrlAsync(string)` does not validate input -- **File**: `barraider-sdtools/Communication/StreamDeckConnection.cs` (lines ~241-243) -- **Description**: `new Uri(uri)` is called without checking for null, throwing `ArgumentNullException`. -- **Impact**: Unhelpful exception when plugin passes null URI. - -### `SDConnection` title-change retry has no limit -- **File**: `barraider-sdtools/Backend/SDConnection.cs` (lines ~456-464) -- **Description**: When `OnTitleParametersDidChange` is null, a 1-second delayed retry is scheduled with no retry cap or disposal check. -- **Impact**: Can schedule unbounded retries after connection disposal. - -### `SystemDrawingImageCodec.DecodeFromBytes` catch block does not log -- **File**: `barraider-sdtools/Internal/SystemDrawingImageCodec.cs` (lines ~45-48) -- **Description**: The catch block disposes and rethrows but does not log the failure. This is new code but the logging gap is cosmetic and not a correctness issue introduced by the migration (the original `Image.FromStream` had no logging either). -- **Note**: Tracked here as low priority; not blocking migration. - -## LOW - -### Unused `using NLog.Layouts` in `SDConnection.cs` -- **File**: `barraider-sdtools/Backend/SDConnection.cs` (line 13) -- **Description**: Import is unused. `layout` parameter in `SetFeedbackLayoutAsync` is a `string`, not an NLog type. -- **Impact**: No runtime impact; cosmetic. - -### `CancellationTokenSource` not disposed in `StreamDeckConnection` -- **File**: `barraider-sdtools/Communication/StreamDeckConnection.cs` (line ~24) -- **Description**: `cancelTokenSource` is never disposed. Minor resource leak. -- **Impact**: Negligible in practice. - -### `Tools.AutoPopulateSettings` lacks type-conversion error handling -- **File**: `barraider-sdtools/Tools/Tools.cs` (line ~389) -- **Description**: `Convert.ChangeType` can throw for incompatible types with no try/catch around individual property sets. -- **Impact**: One bad property value can abort population of remaining properties. - -### `Tools.FilenameFromPayload` does not validate JToken type -- **File**: `barraider-sdtools/Tools/Tools.cs` (line ~227) -- **Description**: Casting a non-string `JToken` to `string` may throw. -- **Impact**: Edge case with malformed PI payloads. - -### `Tools.BytesToSHA512` does not null-check input -- **File**: `barraider-sdtools/Tools/Tools.cs` (lines ~340-355) -- **Description**: `sha512.ComputeHash(null)` would throw `ArgumentNullException`. The exception is caught and logged, but a null guard would be cleaner. -- **Impact**: Low; callers generally pass non-null, and the catch block handles it. - -### `ExtensionMethods.AddTextPath` leaks `Pen`, `GraphicsPath`, and `SolidBrush` -- **File**: `barraider-sdtools/Tools/ExtensionMethods.cs` (lines ~248-256) -- **Description**: `Pen`, `GraphicsPath`, and `SolidBrush` are created but never disposed. All three implement `IDisposable`. -- **Impact**: GDI+ handle leak on repeated calls. - -### `ExtensionMethods.SplitToFitKey` leaks `Font` -- **File**: `barraider-sdtools/Tools/ExtensionMethods.cs` (line ~311) -- **Description**: `Font font = new Font(...)` is never disposed. `Font` implements `IDisposable`. -- **Impact**: Minor GDI+ handle leak. From 701823760674158e730857f41c1d0a8f0965e2ca Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:42:10 +0200 Subject: [PATCH 07/32] Remove migration guide link from README (docs removed from repo) Made-with: Cursor --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 714f1e2..b7943db 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,6 @@ Instead of `PluginBase`, Derive from either `KeypadBase` (if you don't support d # Getting Started Introducing our new [wiki](https://github.com/BarRaider/streamdeck-tools/wiki) packed with usage instructions, examples and more. -## Migration -If you are upgrading from legacy `System.Drawing`-heavy usage, see the [migration guide](docs/MIGRATION.md). - # Dev Discussions / Support **Discord:** Discuss in #developers-chat in [Bar Raiders](http://discord.barraider.com) From 921978f5930c16a62a2aecf3fb4832c4e47b14d3 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:49:44 +0200 Subject: [PATCH 08/32] Phase 3: Add public helpers for Tier 2 plugin migration patterns Expand abstraction layer with public APIs to replace common direct System.Drawing usage: - Add IImageCodec.DecodeFromStream(Stream) + SystemDrawingImageCodec implementation - Add Tools.LoadImage(string filePath) -- replaces direct Image.FromFile calls - Add Tools.LoadImage(Stream stream) -- replaces direct Image.FromStream calls - Add Tools.CreateFont(familyName, sizeInPoints, style) -- replaces direct new Font() calls Quality gate: review agent (0 issues), bug-scan agent (0 migration bugs), build (0 errors, 0 warnings across net48/net8.0/net9.0) Made-with: Cursor --- barraider-sdtools/Internal/IImageCodec.cs | 2 + .../Internal/SystemDrawingImageCodec.cs | 21 ++++++++++ barraider-sdtools/Tools/Tools.cs | 42 +++++++++++++++++++ barraider-sdtools/streamdeck-tools.xml | 28 +++++++++++++ 4 files changed, 93 insertions(+) diff --git a/barraider-sdtools/Internal/IImageCodec.cs b/barraider-sdtools/Internal/IImageCodec.cs index 862c553..526982c 100644 --- a/barraider-sdtools/Internal/IImageCodec.cs +++ b/barraider-sdtools/Internal/IImageCodec.cs @@ -1,4 +1,5 @@ using System.Drawing; +using System.IO; namespace BarRaider.SdTools.Internal { @@ -10,5 +11,6 @@ internal interface IImageCodec byte[] EncodeToPngBytes(Image image); Image DecodeFromBytes(byte[] imageBytes); Image DecodeFromFile(string filePath); + Image DecodeFromStream(Stream stream); } } diff --git a/barraider-sdtools/Internal/SystemDrawingImageCodec.cs b/barraider-sdtools/Internal/SystemDrawingImageCodec.cs index cb21341..91fe728 100644 --- a/barraider-sdtools/Internal/SystemDrawingImageCodec.cs +++ b/barraider-sdtools/Internal/SystemDrawingImageCodec.cs @@ -61,5 +61,26 @@ public Image DecodeFromFile(string filePath) return new Bitmap(original); } } + + public Image DecodeFromStream(Stream stream) + { + if (stream == null) + { + return null; + } + + Image original = Image.FromStream(stream); + try + { + var copy = new Bitmap(original); + original.Dispose(); + return copy; + } + catch + { + original.Dispose(); + throw; + } + } } } diff --git a/barraider-sdtools/Tools/Tools.cs b/barraider-sdtools/Tools/Tools.cs index b06746b..641cf22 100644 --- a/barraider-sdtools/Tools/Tools.cs +++ b/barraider-sdtools/Tools/Tools.cs @@ -52,6 +52,33 @@ public static string FileToBase64(string fileName, bool addHeaderPrefix) } } + /// + /// Loads an image from a file path. Returns an independent copy that does not lock the file. + /// Replaces direct usage of Image.FromFile which holds a file lock. + /// + /// + /// An Image, or null if the path is null/empty or the file does not exist. + public static Image LoadImage(string filePath) + { + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) + { + return null; + } + + return ImageCodecProvider.Instance.DecodeFromFile(filePath); + } + + /// + /// Loads an image from a stream. Returns an independent copy; the caller may close the stream after this returns. + /// Replaces direct usage of Image.FromStream which requires the stream to remain open. + /// + /// + /// An Image, or null if the stream is null. + public static Image LoadImage(Stream stream) + { + return ImageCodecProvider.Instance.DecodeFromStream(stream); + } + /// /// Convert a in-memory image object to Base64 format. Set the addHeaderPrefix to true, if this is sent to the SendImageAsync function /// @@ -173,6 +200,21 @@ public static Bitmap GenerateKeyImage(DeviceType streamDeckType, out Graphics gr return GenerateKeyImage(height, width, out graphics); } + /// + /// Creates a Font from a family name, size in points, and optional style. + /// Prefer this over calling new Font(...) directly, as this helper will be + /// adapted to alternative backends in a future release. + /// The caller is responsible for disposing the returned Font. + /// + /// Font family name (e.g. "Arial", "Verdana"). + /// Font size in points. + /// Font style flags. Defaults to Regular. + /// A new Font instance. The caller must dispose it when done. + public static Font CreateFont(string familyName, float sizeInPoints, FontStyle style = FontStyle.Regular) + { + return new Font(familyName, sizeInPoints, style, GraphicsUnit.Point); + } + /// /// Creates a key image that fits all Stream Decks /// diff --git a/barraider-sdtools/streamdeck-tools.xml b/barraider-sdtools/streamdeck-tools.xml index ba74ba7..cf2a02d 100644 --- a/barraider-sdtools/streamdeck-tools.xml +++ b/barraider-sdtools/streamdeck-tools.xml @@ -2675,6 +2675,22 @@ + + + Loads an image from a file path. Returns an independent copy that does not lock the file. + Replaces direct usage of Image.FromFile which holds a file lock. + + + An Image, or null if the path is null/empty or the file does not exist. + + + + Loads an image from a stream. Returns an independent copy; the caller may close the stream after this returns. + Replaces direct usage of Image.FromStream which requires the stream to remain open. + + + An Image, or null if the stream is null. + Convert a in-memory image object to Base64 format. Set the addHeaderPrefix to true, if this is sent to the SendImageAsync function @@ -2715,6 +2731,18 @@ + + + Creates a Font from a family name, size in points, and optional style. + Prefer this over calling new Font(...) directly, as this helper will be + adapted to alternative backends in a future release. + The caller is responsible for disposing the returned Font. + + Font family name (e.g. "Arial", "Verdana"). + Font size in points. + Font style flags. Defaults to Regular. + A new Font instance. The caller must dispose it when done. + Creates a key image that fits all Stream Decks From c4b337a275e6f3785c2479ada5a69be2856cae0c Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:56:01 +0200 Subject: [PATCH 09/32] Phase 4: Add modern byte[]-first SetImageAsync overload, update SamplePlugin New APIs for plugins to avoid System.Drawing dependencies: - SDConnection.SetImageAsync(byte[] pngImageBytes, ...) for byte-first workflows - ISDConnection interface updated with matching declaration SamplePlugin updated to demonstrate both paths: - KeyPressed: legacy path (Image overload, TitleParameters, direct SD types) - KeyReleased: modern path (ColorFromHex, CreateFont, ToPngByteArray, byte[] overload) Quality gate: review (0 issues), bug-scan (1 SolidBrush leak fixed), build (0 errors) Made-with: Cursor --- SamplePlugin/PluginAction.cs | 14 +++++++----- barraider-sdtools/Backend/ISDConnection.cs | 12 ++++++++++- barraider-sdtools/Backend/SDConnection.cs | 25 ++++++++++++++++++++++ barraider-sdtools/streamdeck-tools.xml | 20 +++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/SamplePlugin/PluginAction.cs b/SamplePlugin/PluginAction.cs index 3c4a7bc..770fb7e 100644 --- a/SamplePlugin/PluginAction.cs +++ b/SamplePlugin/PluginAction.cs @@ -1,4 +1,4 @@ -using BarRaider.SdTools; +using BarRaider.SdTools; using BarRaider.SdTools.Wrappers; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -122,14 +122,18 @@ public async override void KeyPressed(KeyPayload payload) public async override void KeyReleased(KeyPayload payload) { - TitleParameters tp = new TitleParameters(new FontFamily("Arial"), FontStyle.Bold, 20, Color.White, true, TitleVerticalAlignment.Middle); + Color bgColor = GraphicsTools.ColorFromHex("#FFFFFF"); + using (Font font = Tools.CreateFont("Arial", 20, FontStyle.Bold)) + using (var bgBrush = new SolidBrush(bgColor)) + using (var textBrush = new SolidBrush(GraphicsTools.ColorFromHex("#000000"))) using (Image image = Tools.GenerateGenericKeyImage(out Graphics graphics)) { - graphics.FillRectangle(new SolidBrush(Color.White), 0, 0, image.Width, image.Height); - graphics.AddTextPath(tp, image.Height, image.Width, "Test", Color.Black, 7); + graphics.FillRectangle(bgBrush, 0, 0, image.Width, image.Height); + graphics.DrawString("Modern", font, textBrush, new PointF(10, 30)); graphics.Dispose(); - await Connection.SetImageAsync(image); + byte[] pngBytes = image.ToPngByteArray(); + await Connection.SetImageAsync(pngBytes); } } diff --git a/barraider-sdtools/Backend/ISDConnection.cs b/barraider-sdtools/Backend/ISDConnection.cs index c26df97..b491b17 100644 --- a/barraider-sdtools/Backend/ISDConnection.cs +++ b/barraider-sdtools/Backend/ISDConnection.cs @@ -1,4 +1,4 @@ -using BarRaider.SdTools.Events; +using BarRaider.SdTools.Events; using BarRaider.SdTools.Wrappers; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -104,6 +104,16 @@ public interface ISDConnection : IDisposable /// Task SetImageAsync(Image image, int? state = null, bool forceSendToStreamdeck = false); + /// + /// Sets an image on the StreamDeck key from raw PNG bytes. + /// Prefer this over the Image overload to avoid System.Drawing dependencies. + /// + /// PNG-encoded image bytes + /// A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states. + /// Should image be sent even if it is identical to the one sent previously. Default is false + /// + Task SetImageAsync(byte[] pngImageBytes, int? state = null, bool forceSendToStreamdeck = false); + /// /// Sets the default image for this state, as configured in the manifest /// diff --git a/barraider-sdtools/Backend/SDConnection.cs b/barraider-sdtools/Backend/SDConnection.cs index 231fdec..5603c95 100644 --- a/barraider-sdtools/Backend/SDConnection.cs +++ b/barraider-sdtools/Backend/SDConnection.cs @@ -263,6 +263,31 @@ public async Task SetImageAsync(Image image, int? state = null, bool forceSendTo } } + /// + /// Sets an image on the StreamDeck key from raw PNG bytes. + /// Prefer this over the Image overload to avoid System.Drawing dependencies. + /// + /// PNG-encoded image bytes + /// A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states. + /// Should image be sent even if it is identical to the one sent previously. Default is false + /// + public async Task SetImageAsync(byte[] pngImageBytes, int? state = null, bool forceSendToStreamdeck = false) + { + if (pngImageBytes == null) + { + await SetDefaultImageAsync(); + return; + } + + string base64Image = "data:image/png;base64," + Convert.ToBase64String(pngImageBytes); + string hash = Tools.StringToSHA512(base64Image); + if (forceSendToStreamdeck || hash != previousImageHash) + { + previousImageHash = hash; + await StreamDeckConnection.SetImageAsync(base64Image, ContextId, SDKTarget.HardwareAndSoftware, state); + } + } + /// /// Sets the default image for this state, as configured in the manifest /// diff --git a/barraider-sdtools/streamdeck-tools.xml b/barraider-sdtools/streamdeck-tools.xml index cf2a02d..3ad64ab 100644 --- a/barraider-sdtools/streamdeck-tools.xml +++ b/barraider-sdtools/streamdeck-tools.xml @@ -271,6 +271,16 @@ Should image be sent even if it is identical to the one sent previously. Default is false + + + Sets an image on the StreamDeck key from raw PNG bytes. + Prefer this over the Image overload to avoid System.Drawing dependencies. + + PNG-encoded image bytes + A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states. + Should image be sent even if it is identical to the one sent previously. Default is false + + Sets the default image for this state, as configured in the manifest @@ -764,6 +774,16 @@ Should image be sent even if it is identical to the one sent previously. Default is false + + + Sets an image on the StreamDeck key from raw PNG bytes. + Prefer this over the Image overload to avoid System.Drawing dependencies. + + PNG-encoded image bytes + A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states. + Should image be sent even if it is identical to the one sent previously. Default is false + + Sets the default image for this state, as configured in the manifest From 404350f3f1d896416288e3e54cdce1a1a4f6c024 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:14:10 +0200 Subject: [PATCH 10/32] Phase 5: Fix DecodeFromBytes resource leak on Bitmap copy failure If new Bitmap(original) throws, the original Image was not disposed because it was scoped inside the try block. Moved declaration outside try so catch can dispose it via original?.Dispose(). Final quality gate: review (APPROVED), bug-scan (1 issue fixed), build (0 errors, 0 warnings), NuGet package verified (lib/net48, lib/net8.0, lib/net9.0). Made-with: Cursor --- barraider-sdtools/Internal/SystemDrawingImageCodec.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/barraider-sdtools/Internal/SystemDrawingImageCodec.cs b/barraider-sdtools/Internal/SystemDrawingImageCodec.cs index 91fe728..63b1a15 100644 --- a/barraider-sdtools/Internal/SystemDrawingImageCodec.cs +++ b/barraider-sdtools/Internal/SystemDrawingImageCodec.cs @@ -34,9 +34,10 @@ public Image DecodeFromBytes(byte[] imageBytes) } var memoryStream = new MemoryStream(imageBytes); + Image original = null; try { - Image original = Image.FromStream(memoryStream); + original = Image.FromStream(memoryStream); var copy = new Bitmap(original); original.Dispose(); memoryStream.Dispose(); @@ -44,6 +45,7 @@ public Image DecodeFromBytes(byte[] imageBytes) } catch { + original?.Dispose(); memoryStream.Dispose(); throw; } From 6db31013afe76d3b1a1247d642479c2138367c47 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:44:44 +0200 Subject: [PATCH 11/32] Add SkiaSharp 3.119.2 dependency and internal SkiaSharpImageCodec Add SkiaSharp 3.119.2 (MIT license) as an unconditional NuGet dependency for all TFMs (net48, net8.0, net9.0). Create internal SkiaSharpImageCodec with PNG encode/decode methods using SKBitmap, enabling cross-platform image processing on Windows, macOS, and Linux. Made-with: Cursor --- .../Internal/SkiaSharpImageCodec.cs | 52 +++++++++++++++++++ barraider-sdtools/barraider-sdtools.csproj | 1 + 2 files changed, 53 insertions(+) create mode 100644 barraider-sdtools/Internal/SkiaSharpImageCodec.cs diff --git a/barraider-sdtools/Internal/SkiaSharpImageCodec.cs b/barraider-sdtools/Internal/SkiaSharpImageCodec.cs new file mode 100644 index 0000000..5d2f03d --- /dev/null +++ b/barraider-sdtools/Internal/SkiaSharpImageCodec.cs @@ -0,0 +1,52 @@ +using SkiaSharp; +using System.IO; + +namespace BarRaider.SdTools.Internal +{ + internal sealed class SkiaSharpImageCodec + { + public byte[] EncodeToPngBytes(SKBitmap bitmap) + { + if (bitmap == null) + { + return null; + } + + using (SKImage image = SKImage.FromBitmap(bitmap)) + using (SKData data = image.Encode(SKEncodedImageFormat.Png, 100)) + { + return data.ToArray(); + } + } + + public SKBitmap DecodeFromBytes(byte[] imageBytes) + { + if (imageBytes == null || imageBytes.Length == 0) + { + return null; + } + + return SKBitmap.Decode(imageBytes); + } + + public SKBitmap DecodeFromFile(string filePath) + { + if (string.IsNullOrEmpty(filePath)) + { + return null; + } + + return SKBitmap.Decode(filePath); + } + + public SKBitmap DecodeFromStream(Stream stream) + { + if (stream == null) + { + return null; + } + + return SKBitmap.Decode(stream); + } + } +} diff --git a/barraider-sdtools/barraider-sdtools.csproj b/barraider-sdtools/barraider-sdtools.csproj index 7d53682..bdf98b5 100644 --- a/barraider-sdtools/barraider-sdtools.csproj +++ b/barraider-sdtools/barraider-sdtools.csproj @@ -56,6 +56,7 @@ NOTE: See docs/MIGRATION.md for upgrade guidance. + From 8bc8f8e9ab0453a788cb742bac83bb226d908de2 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:45:01 +0200 Subject: [PATCH 12/32] Add SkiaSharp public APIs: SkiaTools, SkiaGraphicsTools, SkiaExtensionMethods Create cross-platform SkiaSharp-typed public API surface with feature parity to existing System.Drawing methods. Add SetImageAsync(SKBitmap) to ISDConnection/SDConnection. Add TitleSKColor, TitleTypeface, and FontStyleToSKFontStyle to TitleParameters for SkiaSharp consumers. Made-with: Cursor --- barraider-sdtools/Backend/ISDConnection.cs | 12 + barraider-sdtools/Backend/SDConnection.cs | 22 ++ .../Tools/SkiaExtensionMethods.cs | 264 ++++++++++++++++ barraider-sdtools/Tools/SkiaGraphicsTools.cs | 293 ++++++++++++++++++ barraider-sdtools/Tools/SkiaTools.cs | 230 ++++++++++++++ barraider-sdtools/Wrappers/TitleParameters.cs | 47 ++- 6 files changed, 867 insertions(+), 1 deletion(-) create mode 100644 barraider-sdtools/Tools/SkiaExtensionMethods.cs create mode 100644 barraider-sdtools/Tools/SkiaGraphicsTools.cs create mode 100644 barraider-sdtools/Tools/SkiaTools.cs diff --git a/barraider-sdtools/Backend/ISDConnection.cs b/barraider-sdtools/Backend/ISDConnection.cs index b491b17..9add43a 100644 --- a/barraider-sdtools/Backend/ISDConnection.cs +++ b/barraider-sdtools/Backend/ISDConnection.cs @@ -2,6 +2,7 @@ using BarRaider.SdTools.Wrappers; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using SkiaSharp; using System; using System.Collections.Generic; using System.Drawing; @@ -102,6 +103,7 @@ public interface ISDConnection : IDisposable /// A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states. /// Should image be sent even if it is identical to the one sent previously. Default is false /// + [Obsolete("Uses System.Drawing.Image which is not cross-platform. Use SetImageAsync(SKBitmap, ...) or SetImageAsync(byte[], ...) instead.")] Task SetImageAsync(Image image, int? state = null, bool forceSendToStreamdeck = false); /// @@ -114,6 +116,16 @@ public interface ISDConnection : IDisposable /// Task SetImageAsync(byte[] pngImageBytes, int? state = null, bool forceSendToStreamdeck = false); + /// + /// Sets an image on the StreamDeck key from an SKBitmap (cross-platform, SkiaSharp). + /// The bitmap is PNG-encoded and sent as base64. + /// + /// SkiaSharp bitmap to display on the key + /// A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states. + /// Should image be sent even if it is identical to the one sent previously. Default is false + /// + Task SetImageAsync(SKBitmap image, int? state = null, bool forceSendToStreamdeck = false); + /// /// Sets the default image for this state, as configured in the manifest /// diff --git a/barraider-sdtools/Backend/SDConnection.cs b/barraider-sdtools/Backend/SDConnection.cs index 5603c95..780b4a0 100644 --- a/barraider-sdtools/Backend/SDConnection.cs +++ b/barraider-sdtools/Backend/SDConnection.cs @@ -1,5 +1,6 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using SkiaSharp; using System; using System.Drawing; using System.Threading.Tasks; @@ -252,6 +253,7 @@ public async Task SetImageAsync(string base64Image, int? state = null, bool forc /// A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states. /// Should image be sent even if it is identical to the one sent previously. Default is false /// + [Obsolete("Uses System.Drawing.Image which is not cross-platform. Use SetImageAsync(SKBitmap, ...) or SetImageAsync(byte[], ...) instead.")] public async Task SetImageAsync(Image image, int? state = null, bool forceSendToStreamdeck = false) { string base64Image = Tools.ImageToBase64(image, true); @@ -288,6 +290,26 @@ public async Task SetImageAsync(byte[] pngImageBytes, int? state = null, bool fo } } + /// + /// Sets an image on the StreamDeck key from an SKBitmap (cross-platform, SkiaSharp). + /// The bitmap is PNG-encoded and sent as base64. + /// + /// SkiaSharp bitmap to display on the key + /// A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states. + /// Should image be sent even if it is identical to the one sent previously. Default is false + /// + public async Task SetImageAsync(SKBitmap image, int? state = null, bool forceSendToStreamdeck = false) + { + if (image == null) + { + await SetDefaultImageAsync(); + return; + } + + byte[] pngBytes = image.ToPngByteArray(); + await SetImageAsync(pngBytes, state, forceSendToStreamdeck); + } + /// /// Sets the default image for this state, as configured in the manifest /// diff --git a/barraider-sdtools/Tools/SkiaExtensionMethods.cs b/barraider-sdtools/Tools/SkiaExtensionMethods.cs new file mode 100644 index 0000000..8fd234c --- /dev/null +++ b/barraider-sdtools/Tools/SkiaExtensionMethods.cs @@ -0,0 +1,264 @@ +using BarRaider.SdTools.Internal; +using BarRaider.SdTools.Wrappers; +using SkiaSharp; +using System; + +namespace BarRaider.SdTools +{ + /// + /// SkiaSharp extension methods for SKBitmap and SKCanvas. + /// These are the cross-platform equivalents of the System.Drawing extension methods in . + /// + public static class SkiaExtensionMethods + { + private static readonly Lazy codec = + new Lazy(() => new SkiaSharpImageCodec()); + + #region SKBitmap Extensions + + /// + /// Converts an SKBitmap to a PNG byte array. + /// + /// The bitmap to encode. + /// PNG-encoded byte array. + public static byte[] ToPngByteArray(this SKBitmap bitmap) + { + return codec.Value.EncodeToPngBytes(bitmap); + } + + /// + /// Converts an SKBitmap to a Base64 PNG string. + /// + /// The bitmap to encode. + /// Whether to prepend the data URI header. + /// Base64-encoded PNG string. + public static string ToBase64(this SKBitmap bitmap, bool addHeaderPrefix) + { + return SkiaTools.ImageToBase64(bitmap, addHeaderPrefix); + } + + #endregion + + #region SKCanvas Extensions + + /// + /// Draws a string on an SKCanvas and returns the ending Y position. + /// + /// The canvas to draw on. + /// The text to draw. + /// The font for text rendering. + /// The paint for color/style. + /// The position to draw at (X, Y baseline). + /// The Y position below the drawn text. + public static float DrawAndMeasureString(this SKCanvas canvas, string text, SKFont font, SKPaint paint, SKPoint position) + { + SKRect bounds = new SKRect(); + font.MeasureText(text, out bounds, paint); + canvas.DrawText(text, position.X, position.Y, font, paint); + return position.Y + bounds.Height; + } + + /// + /// Returns the center X position of a string given the image width and font. + /// + /// The canvas (used for consistency with the System.Drawing extension signature). + /// The text to measure. + /// The total image width. + /// The font for measurement. + /// Set to true if the text fits within the image width. + /// Minimum indentation when text overflows. + /// The X coordinate to center the text. + public static float GetTextCenter(this SKCanvas canvas, string text, int imageWidth, SKFont font, out bool textFitsImage, int minIndentation = 0) + { + float textWidth = font.MeasureText(text); + float stringWidth = minIndentation; + textFitsImage = false; + if (textWidth < imageWidth) + { + textFitsImage = true; + stringWidth = Math.Abs(imageWidth - textWidth) / 2; + } + return stringWidth; + } + + /// + /// Returns the center X position of a string given the image width and font. + /// + /// The canvas. + /// The text to measure. + /// The total image width. + /// The font for measurement. + /// Minimum indentation when text overflows. + /// The X coordinate to center the text. + public static float GetTextCenter(this SKCanvas canvas, string text, int imageWidth, SKFont font, int minIndentation = 0) + { + return canvas.GetTextCenter(text, imageWidth, font, out _, minIndentation); + } + + /// + /// Returns the largest font size at which the text fits within the given image width. + /// + /// The canvas. + /// The text to measure. + /// Maximum width in pixels. + /// The base font (used for typeface and initial size). + /// The smallest acceptable font size. + /// The computed font size in the same units as font.Size. + public static float GetFontSizeWhereTextFitsImage(this SKCanvas canvas, string text, int imageWidth, SKFont font, int minimalFontSize = 6) + { + bool textFitsImage; + float size = font.Size; + using (var variableFont = new SKFont(font.Typeface, size)) + { + do + { + canvas.GetTextCenter(text, imageWidth, variableFont, out textFitsImage); + if (!textFitsImage) + { + size -= 0.5f; + variableFont.Size = size; + } + } + while (!textFitsImage && size > minimalFontSize); + } + return size; + } + + /// + /// Adds styled text to an SKCanvas using TitleParameters for layout. + /// Emulates the text rendering settings from the Stream Deck Property Inspector. + /// + /// The canvas to draw on. + /// Title parameters from the Stream Deck event. + /// Height of the key image. + /// Width of the key image. + /// The text to draw. + /// Alignment offset in pixels. + public static void AddTextPath(this SKCanvas canvas, TitleParameters titleParameters, int imageHeight, int imageWidth, string text, int pixelsAlignment = 15) + { + AddTextPath(canvas, titleParameters, imageHeight, imageWidth, text, SKColors.Black, 1, pixelsAlignment); + } + + /// + /// Adds styled text to an SKCanvas using TitleParameters for layout, with stroke settings. + /// + /// The canvas to draw on. + /// Title parameters from the Stream Deck event. + /// Height of the key image. + /// Width of the key image. + /// The text to draw. + /// Color for the text stroke. + /// Thickness of the text stroke. + /// Alignment offset in pixels. + public static void AddTextPath(this SKCanvas canvas, TitleParameters titleParameters, int imageHeight, int imageWidth, string text, SKColor strokeColor, float strokeThickness, int pixelsAlignment = 15) + { + try + { + if (titleParameters == null) + { + Logger.Instance.LogMessage(TracingLevel.ERROR, "SkiaExtensionMethods.AddTextPath: titleParameters is null"); + return; + } + + float fontSize = (float)titleParameters.FontSizeInPixelsScaledToDefaultImage; + var typeface = titleParameters.TitleTypeface; + var color = titleParameters.TitleSKColor; + + using (var font = new SKFont(typeface, fontSize)) + { + float textWidth = font.MeasureText(text); + + int stringWidth = 0; + if (textWidth < imageWidth) + { + stringWidth = (int)(Math.Abs(imageWidth - textWidth) / 2) - pixelsAlignment; + } + + int stringHeight = pixelsAlignment; + if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Middle) + { + stringHeight = (imageHeight / 2) - pixelsAlignment; + } + else if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Bottom) + { + stringHeight = (int)(Math.Abs(imageHeight - fontSize) - pixelsAlignment); + } + + using (var strokePaint = new SKPaint + { + Color = strokeColor, + Style = SKPaintStyle.Stroke, + StrokeWidth = strokeThickness, + IsAntialias = true + }) + { + canvas.DrawText(text, stringWidth, stringHeight, font, strokePaint); + } + + using (var fillPaint = new SKPaint + { + Color = color, + Style = SKPaintStyle.Fill, + IsAntialias = true + }) + { + canvas.DrawText(text, stringWidth, stringHeight, font, fillPaint); + } + } + } + catch (Exception ex) + { + Logger.Instance.LogMessage(TracingLevel.ERROR, $"SkiaExtensionMethods.AddTextPath Exception: {ex}"); + } + } + + /// + /// Adds line breaks to a string so it fits within the key image when using SetTitleAsync(). + /// SkiaSharp equivalent of . + /// + /// The string to split. + /// Title parameters for font information. + /// Left padding in pixels. + /// Right padding in pixels. + /// Width of the key image in pixels. + /// The string with line breaks inserted. + public static string SplitToFitKey(this string str, TitleParameters titleParameters, SKFont font, int leftPaddingPixels = 3, int rightPaddingPixels = 3, int imageWidthPixels = 72) + { + try + { + if (titleParameters == null || font == null) + { + return str; + } + + int padding = leftPaddingPixels + rightPaddingPixels; + var finalString = new System.Text.StringBuilder(); + var currentLine = new System.Text.StringBuilder(); + + for (int idx = 0; idx < str.Length; idx++) + { + currentLine.Append(str[idx]); + float width = font.MeasureText(currentLine.ToString()); + if (width <= imageWidthPixels - padding) + { + finalString.Append(str[idx]); + } + else + { + finalString.Append("\n" + str[idx]); + currentLine = new System.Text.StringBuilder(str[idx].ToString()); + } + } + + return finalString.ToString(); + } + catch (Exception ex) + { + Logger.Instance.LogMessage(TracingLevel.ERROR, $"SkiaExtensionMethods.SplitToFitKey Exception: {ex}"); + return str; + } + } + + #endregion + } +} diff --git a/barraider-sdtools/Tools/SkiaGraphicsTools.cs b/barraider-sdtools/Tools/SkiaGraphicsTools.cs new file mode 100644 index 0000000..34dfd50 --- /dev/null +++ b/barraider-sdtools/Tools/SkiaGraphicsTools.cs @@ -0,0 +1,293 @@ +using BarRaider.SdTools.Wrappers; +using SkiaSharp; +using System; +using System.Collections.Generic; + +namespace BarRaider.SdTools +{ + /// + /// Cross-platform graphics manipulation utilities using SkiaSharp. + /// These methods are the SkiaSharp equivalents of the System.Drawing methods in . + /// + public static class SkiaGraphicsTools + { + /// + /// Parse a hex color string into an SKColor. + /// + /// Hex string such as "#FF0000" or "#80FF0000". + /// The parsed SKColor. + public static SKColor ColorFromHex(string hexColor) + { + return SkiaTools.ColorFromHex(hexColor); + } + + /// + /// Generates multiple shades based on an initial color and the number of stages. + /// + /// Initial hex color string. + /// The current shade index (0-based). + /// Total number of shade levels. + /// A darker shade of the initial color. + public static SKColor GenerateColorShades(string initialColor, int currentShade, int totalAmountOfShades) + { + SKColor color = ColorFromHex(initialColor); + double r = color.Red; + double g = color.Green; + double b = color.Blue; + + if (currentShade == totalAmountOfShades - 1) + { + currentShade = 1; + } + + for (int idx = 0; idx < currentShade; idx++) + { + r /= 2; + g /= 2; + b /= 2; + } + + return new SKColor((byte)r, (byte)g, (byte)b, color.Alpha); + } + + /// + /// Resizes an image while maintaining aspect ratio and centering on a black background. + /// + /// The source bitmap. + /// Target width. + /// Target height. + /// A new resized SKBitmap. Caller must dispose. Returns null if original is null. + public static SKBitmap ResizeImage(SKBitmap original, int newWidth, int newHeight) + { + if (original == null) + { + return null; + } + + int originalWidth = original.Width; + int originalHeight = original.Height; + + if (originalWidth == 0 || originalHeight == 0) + { + return null; + } + + double ratioX = (double)newWidth / originalWidth; + double ratioY = (double)newHeight / originalHeight; + double ratio = Math.Min(ratioX, ratioY); + + int posX = (int)((newWidth - (originalWidth * ratio)) / 2); + int posY = (int)((newHeight - (originalHeight * ratio)) / 2); + + var result = new SKBitmap(newWidth, newHeight); + try + { + using (var canvas = new SKCanvas(result)) + using (var paint = new SKPaint { IsAntialias = true }) + { + canvas.Clear(SKColors.Black); + var destRect = SKRect.Create(posX, posY, newWidth, newHeight); + canvas.DrawBitmap(original, destRect, paint); + } + } + catch + { + result.Dispose(); + throw; + } + return result; + } + + /// + /// Extracts a rectangular region from an image. + /// + /// The source bitmap. + /// Left edge of the rectangle. + /// Top edge of the rectangle. + /// Width of the rectangle. + /// Height of the rectangle. + /// A new SKBitmap with the extracted region. Caller must dispose. + public static SKBitmap ExtractRectangle(SKBitmap bitmap, int startX, int startY, int width, int height) + { + if (bitmap == null) + { + return null; + } + + var subset = new SKRectI(startX, startY, startX + width, startY + height); + var result = new SKBitmap(); + if (bitmap.ExtractSubset(result, subset)) + { + return result; + } + + result.Dispose(); + return null; + } + + /// + /// Creates a copy of the image with modified opacity. + /// + /// The source bitmap. + /// Opacity value from 0.0 (transparent) to 1.0 (opaque). + /// A new SKBitmap with the applied opacity. Caller must dispose. + public static SKBitmap CreateOpacityImage(SKBitmap bitmap, float opacity) + { + if (bitmap == null) + { + return null; + } + + try + { + var result = new SKBitmap(bitmap.Width, bitmap.Height); + try + { + using (var canvas = new SKCanvas(result)) + using (var paint = new SKPaint()) + { + byte alpha = (byte)(opacity * 255); + var colorFilter = SKColorFilter.CreateBlendMode( + new SKColor(255, 255, 255, alpha), SKBlendMode.DstIn); + paint.ColorFilter = colorFilter; + canvas.Clear(SKColors.Transparent); + canvas.DrawBitmap(bitmap, 0, 0, paint); + } + } + catch + { + result.Dispose(); + throw; + } + return result; + } + catch (Exception ex) + { + Logger.Instance.LogMessage(TracingLevel.ERROR, $"SkiaGraphicsTools.CreateOpacityImage exception: {ex}"); + return null; + } + } + + /// + /// Draws multi-lined text onto key images. Generates one or more images where each has + /// text drawn based on the given parameters. + /// + /// The text to draw. + /// Starting character position. + /// Maximum characters per line. + /// Maximum lines per image. + /// The SKFont for text rendering. + /// Background fill color. + /// Text color. + /// If true, overflow text creates additional images. + /// Starting draw position on the key. + /// Array of SKBitmaps with drawn text. Caller must dispose each bitmap. + public static SKBitmap[] DrawMultiLinedText(string text, int currentTextPosition, int lettersPerLine, int numberOfLines, + SKFont font, SKColor backgroundColor, SKColor textColor, bool expandToNextImage, SKPoint keyDrawStartingPosition) + { + if (string.IsNullOrEmpty(text) || font == null) + { + return Array.Empty(); + } + + float currentWidth = keyDrawStartingPosition.X; + float currentHeight = keyDrawStartingPosition.Y; + int currentLine = 0; + var images = new List(); + + SKBitmap img = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas); + if (img == null) + { + return Array.Empty(); + } + images.Add(img); + + canvas.Clear(backgroundColor); + + float lineHeight = img.Height / numberOfLines; + if (numberOfLines == 1) + { + currentHeight = img.Height / 2f; + } + + float widthIncrement = img.Width / (float)lettersPerLine; + + using (var paint = new SKPaint { Color = textColor, IsAntialias = true }) + { + for (int letter = currentTextPosition; letter < text.Length; letter++) + { + if (letter > currentTextPosition && letter % lettersPerLine == 0) + { + currentLine++; + if (currentLine >= numberOfLines) + { + if (expandToNextImage) + { + var overflow = DrawMultiLinedText(text, letter, lettersPerLine, numberOfLines, + font, backgroundColor, textColor, expandToNextImage, keyDrawStartingPosition); + images.AddRange(overflow); + } + break; + } + + currentHeight += lineHeight; + currentWidth = keyDrawStartingPosition.X; + } + + canvas.DrawText(text[letter].ToString(), currentWidth, currentHeight, font, paint); + currentWidth += widthIncrement; + } + } + + canvas.Dispose(); + return images.ToArray(); + } + + /// + /// Adds line breaks to a string so that it fits within the given image width + /// when rendered with the specified font. + /// + /// The string to wrap. + /// The SKFont used for measurement. + /// The maximum width in pixels. + /// Left padding in pixels. + /// Right padding in pixels. + /// The string with line breaks inserted. + public static string WrapStringToFitImage(string str, SKFont font, int imageWidthPixels = 72, int leftPaddingPixels = 5, int rightPaddingPixels = 5) + { + try + { + if (font == null) + { + return str; + } + + int padding = leftPaddingPixels + rightPaddingPixels; + var finalString = new System.Text.StringBuilder(); + var currentLine = new System.Text.StringBuilder(); + + for (int idx = 0; idx < str.Length; idx++) + { + currentLine.Append(str[idx]); + float width = font.MeasureText(currentLine.ToString()); + if (width <= imageWidthPixels - padding) + { + finalString.Append(str[idx]); + } + else + { + finalString.Append("\n" + str[idx]); + currentLine = new System.Text.StringBuilder(str[idx].ToString()); + } + } + + return finalString.ToString(); + } + catch (Exception ex) + { + Logger.Instance.LogMessage(TracingLevel.ERROR, $"SkiaGraphicsTools.WrapStringToFitImage Exception: {ex}"); + return str; + } + } + } +} diff --git a/barraider-sdtools/Tools/SkiaTools.cs b/barraider-sdtools/Tools/SkiaTools.cs new file mode 100644 index 0000000..81836cf --- /dev/null +++ b/barraider-sdtools/Tools/SkiaTools.cs @@ -0,0 +1,230 @@ +using BarRaider.SdTools.Internal; +using SkiaSharp; +using System; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; + +namespace BarRaider.SdTools +{ + /// + /// Cross-platform image utilities using SkiaSharp. These methods are the SkiaSharp equivalents + /// of the System.Drawing methods in and work on Windows, macOS, and Linux. + /// + public static class SkiaTools + { + private const string HEADER_PREFIX = "data:image/png;base64,"; + private const int CLASSIC_KEY_DEFAULT_HEIGHT = 72; + private const int CLASSIC_KEY_DEFAULT_WIDTH = 72; + private const int PLUS_KEY_DEFAULT_HEIGHT = 144; + private const int PLUS_KEY_DEFAULT_WIDTH = 144; + private const int XL_KEY_DEFAULT_HEIGHT = 96; + private const int XL_KEY_DEFAULT_WIDTH = 96; + private const int GENERIC_KEY_IMAGE_SIZE = 144; + + private static readonly Lazy codec = + new Lazy(() => new SkiaSharpImageCodec()); + + #region Image Related + + /// + /// Generates an empty key bitmap with the default dimensions for the given Stream Deck device type. + /// The returned SKCanvas is ready for drawing and must be disposed by the caller. + /// + /// The Stream Deck device type. + /// The SKCanvas for drawing on the bitmap. Caller must dispose. + /// An SKBitmap sized for the device. Caller must dispose. + public static SKBitmap GenerateKeyImage(DeviceType streamDeckType, out SKCanvas canvas) + { + int height = Tools.GetKeyDefaultHeight(streamDeckType); + int width = Tools.GetKeyDefaultWidth(streamDeckType); + return GenerateKeyImage(height, width, out canvas); + } + + /// + /// Creates a key image that fits all Stream Deck models (144x144). + /// The returned SKCanvas is ready for drawing and must be disposed by the caller. + /// + /// The SKCanvas for drawing on the bitmap. Caller must dispose. + /// An SKBitmap. Caller must dispose. + public static SKBitmap GenerateGenericKeyImage(out SKCanvas canvas) + { + return GenerateKeyImage(GENERIC_KEY_IMAGE_SIZE, GENERIC_KEY_IMAGE_SIZE, out canvas); + } + + /// + /// Convert an SKBitmap to a Base64 PNG string. + /// Set addHeaderPrefix to true if sending to SetImageAsync. + /// + /// The bitmap to encode. + /// Whether to prepend the data URI header. + /// Base64-encoded PNG string, or null if bitmap is null. + public static string ImageToBase64(SKBitmap bitmap, bool addHeaderPrefix) + { + if (bitmap == null) + { + return null; + } + + byte[] imageBytes = codec.Value.EncodeToPngBytes(bitmap); + if (imageBytes == null) + { + return null; + } + + string base64String = Convert.ToBase64String(imageBytes); + return addHeaderPrefix ? HEADER_PREFIX + base64String : base64String; + } + + /// + /// Convert a base64 image string to an SKBitmap. + /// + /// Base64-encoded image, optionally with a data URI header. + /// An SKBitmap, or null on failure. Caller must dispose. + public static SKBitmap Base64StringToImage(string base64String) + { + try + { + if (string.IsNullOrEmpty(base64String)) + { + return null; + } + + if (base64String.StartsWith(HEADER_PREFIX, StringComparison.Ordinal)) + { + base64String = base64String.Substring(HEADER_PREFIX.Length); + } + + byte[] imageBytes = Convert.FromBase64String(base64String); + return codec.Value.DecodeFromBytes(imageBytes); + } + catch (Exception ex) + { + Logger.Instance.LogMessage(TracingLevel.ERROR, $"SkiaTools.Base64StringToImage Exception: {ex}"); + } + return null; + } + + /// + /// Convert an image file to a Base64 PNG string. + /// Set addHeaderPrefix to true if sending to SetImageAsync. + /// + /// Path to the image file. + /// Whether to prepend the data URI header. + /// Base64-encoded PNG string, or null if file doesn't exist. + public static string FileToBase64(string fileName, bool addHeaderPrefix) + { + if (!File.Exists(fileName)) + { + return null; + } + + using (SKBitmap bitmap = codec.Value.DecodeFromFile(fileName)) + { + return ImageToBase64(bitmap, addHeaderPrefix); + } + } + + /// + /// Loads an image from a file path. Returns an independent SKBitmap that does not lock the file. + /// + /// Path to the image file. + /// An SKBitmap, or null if the path is invalid or file doesn't exist. Caller must dispose. + public static SKBitmap LoadImage(string filePath) + { + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) + { + return null; + } + + return codec.Value.DecodeFromFile(filePath); + } + + /// + /// Loads an image from a stream. The caller may close the stream after this returns. + /// + /// The stream containing image data. + /// An SKBitmap, or null if the stream is null. Caller must dispose. + public static SKBitmap LoadImage(Stream stream) + { + return codec.Value.DecodeFromStream(stream); + } + + /// + /// Returns a SHA512 hash of the PNG-encoded bitmap. + /// + /// The bitmap to hash. + /// Hex-encoded SHA512 hash, or null on failure. + public static string ImageToSHA512(SKBitmap bitmap) + { + if (bitmap == null) + { + return null; + } + + try + { + byte[] imageBytes = codec.Value.EncodeToPngBytes(bitmap); + return imageBytes == null ? null : Tools.BytesToSHA512(imageBytes); + } + catch (Exception ex) + { + Logger.Instance.LogMessage(TracingLevel.ERROR, $"SkiaTools.ImageToSHA512 Exception: {ex}"); + } + return null; + } + + /// + /// Creates an SKFont from a font family name, size in points, and optional style. + /// The caller is responsible for disposing the returned SKFont. + /// + /// Font family name (e.g. "Arial", "Verdana"). + /// Font size in points. + /// SkiaSharp font style. Defaults to Normal. + /// A new SKFont. Caller must dispose. + public static SKFont CreateFont(string familyName, float sizeInPoints, SKFontStyle style = null) + { + var typeface = SKTypeface.FromFamilyName(familyName, style ?? SKFontStyle.Normal); + return new SKFont(typeface, sizeInPoints); + } + + /// + /// Parses a hex color string (e.g. "#FF0000" or "#AAFF0000") into an SKColor. + /// + /// Hex color string with leading '#'. + /// The parsed SKColor. + public static SKColor ColorFromHex(string hexColor) + { + if (!string.IsNullOrEmpty(hexColor) && SKColor.TryParse(hexColor, out SKColor color)) + { + return color; + } + + Logger.Instance.LogMessage(TracingLevel.WARN, $"SkiaTools.ColorFromHex: Failed to parse '{hexColor}', returning black"); + return SKColors.Black; + } + + #endregion + + #region Private Methods + + private static SKBitmap GenerateKeyImage(int height, int width, out SKCanvas canvas) + { + try + { + var bitmap = new SKBitmap(width, height); + canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Black); + return bitmap; + } + catch (Exception ex) + { + Logger.Instance.LogMessage(TracingLevel.ERROR, $"SkiaTools.GenerateKeyImage exception: {ex} Height: {height} Width: {width}"); + } + canvas = null; + return null; + } + + #endregion + } +} diff --git a/barraider-sdtools/Wrappers/TitleParameters.cs b/barraider-sdtools/Wrappers/TitleParameters.cs index 1c893b1..22040db 100644 --- a/barraider-sdtools/Wrappers/TitleParameters.cs +++ b/barraider-sdtools/Wrappers/TitleParameters.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; +using SkiaSharp; using System; using System.Collections.Generic; using System.Drawing; @@ -86,6 +87,50 @@ public class TitleParameters [JsonProperty("titleAlignment")] public TitleVerticalAlignment VerticalAlignment { get; private set; } + /// + /// Title color as an SKColor for cross-platform SkiaSharp rendering. + /// Computed from the existing TitleColor property. + /// + [JsonIgnore] + public SKColor TitleSKColor => new SKColor(TitleColor.R, TitleColor.G, TitleColor.B, TitleColor.A); + + [JsonIgnore] + private SKTypeface cachedTypeface; + + /// + /// Font as an SKTypeface for cross-platform SkiaSharp rendering. + /// Computed and cached from the existing FontFamily property. + /// The returned typeface is owned by this TitleParameters instance. + /// + [JsonIgnore] + public SKTypeface TitleTypeface + { + get + { + if (cachedTypeface == null) + { + string familyName = FontFamily?.Name ?? DEFAULT_FONT_FAMILY_NAME; + cachedTypeface = SKTypeface.FromFamilyName(familyName, FontStyleToSKFontStyle()); + } + return cachedTypeface; + } + } + + /// + /// Converts the System.Drawing FontStyle to an equivalent SKFontStyle. + /// + /// The corresponding SKFontStyle. + public SKFontStyle FontStyleToSKFontStyle() + { + bool bold = (FontStyle & System.Drawing.FontStyle.Bold) != 0; + bool italic = (FontStyle & System.Drawing.FontStyle.Italic) != 0; + + var weight = bold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal; + var slant = italic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright; + + return new SKFontStyle(weight, SKFontStyleWidth.Normal, slant); + } + /// /// Constructor /// From b2bf09f7976b73ad963df86582f53aa579b3836e Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:45:17 +0200 Subject: [PATCH 13/32] Mark System.Drawing APIs as [Obsolete], update SamplePlugin for SkiaSharp Mark all public System.Drawing-based methods in Tools.cs, GraphicsTools.cs, ExtensionMethods.cs, ISDConnection.cs, and SDConnection.cs as [Obsolete] with messages pointing to their SkiaSharp equivalents. Update SamplePlugin KeyReleased to demonstrate cross-platform SkiaSharp rendering path. Made-with: Cursor --- SamplePlugin/PluginAction.cs | 22 ++++++++++----------- barraider-sdtools/Tools/ExtensionMethods.cs | 11 +++++++++++ barraider-sdtools/Tools/GraphicsTools.cs | 9 ++++++++- barraider-sdtools/Tools/Tools.cs | 9 +++++++++ 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/SamplePlugin/PluginAction.cs b/SamplePlugin/PluginAction.cs index 770fb7e..6afb17d 100644 --- a/SamplePlugin/PluginAction.cs +++ b/SamplePlugin/PluginAction.cs @@ -2,6 +2,7 @@ using BarRaider.SdTools.Wrappers; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using SkiaSharp; using System; using System.Collections.Generic; using System.Drawing; @@ -122,18 +123,17 @@ public async override void KeyPressed(KeyPayload payload) public async override void KeyReleased(KeyPayload payload) { - Color bgColor = GraphicsTools.ColorFromHex("#FFFFFF"); - using (Font font = Tools.CreateFont("Arial", 20, FontStyle.Bold)) - using (var bgBrush = new SolidBrush(bgColor)) - using (var textBrush = new SolidBrush(GraphicsTools.ColorFromHex("#000000"))) - using (Image image = Tools.GenerateGenericKeyImage(out Graphics graphics)) + // Cross-platform approach using SkiaSharp (works on Windows, macOS, Linux) + using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) { - graphics.FillRectangle(bgBrush, 0, 0, image.Width, image.Height); - graphics.DrawString("Modern", font, textBrush, new PointF(10, 30)); - graphics.Dispose(); - - byte[] pngBytes = image.ToPngByteArray(); - await Connection.SetImageAsync(pngBytes); + canvas.Clear(SKColors.White); + using (var font = SkiaTools.CreateFont("Arial", 20, SKFontStyle.Bold)) + using (var paint = new SKPaint { Color = SKColors.Black, IsAntialias = true }) + { + canvas.DrawText("Cross-Platform", 10, 50, font, paint); + } + canvas.Dispose(); + await Connection.SetImageAsync(image); } } diff --git a/barraider-sdtools/Tools/ExtensionMethods.cs b/barraider-sdtools/Tools/ExtensionMethods.cs index 61c4dcd..d6ca208 100644 --- a/barraider-sdtools/Tools/ExtensionMethods.cs +++ b/barraider-sdtools/Tools/ExtensionMethods.cs @@ -42,6 +42,7 @@ public static bool IsCoordinatesSame(this KeyCoordinates coordinates, KeyCoordin /// /// /// + [Obsolete("Uses System.Drawing.Color which is not cross-platform. Use SKColor directly instead.")] public static string ToHex(this Color color) { return string.Format("#{0:X2}{1:X2}{2:X2}", color.R, color.G, color.B); @@ -52,6 +53,7 @@ public static string ToHex(this Color color) /// /// /// + [Obsolete("Uses System.Drawing.Brush which is not cross-platform. Use SKPaint.Color directly instead.")] public static string ToHex(this Brush brush) { if (brush is SolidBrush solidBrush) @@ -85,6 +87,7 @@ public static byte[] ToByteArray(this Image image) /// /// /// + [Obsolete("Uses System.Drawing.Image which is not cross-platform. Use SKBitmap.ToPngByteArray() from SkiaExtensionMethods instead.")] public static byte[] ToPngByteArray(this Image image) { return ImageCodecProvider.Instance.EncodeToPngBytes(image); @@ -96,6 +99,7 @@ public static byte[] ToPngByteArray(this Image image) /// /// /// + [Obsolete("Uses System.Drawing.Image which is not cross-platform. Use SKBitmap.ToBase64() from SkiaExtensionMethods instead.")] public static string ToBase64(this Image image, bool addHeaderPrefix) { return Tools.ImageToBase64(image, addHeaderPrefix); @@ -110,6 +114,7 @@ public static string ToBase64(this Image image, bool addHeaderPrefix) /// /// /// + [Obsolete("Uses System.Drawing types which are not cross-platform. Use SKCanvas.DrawAndMeasureString() from SkiaExtensionMethods instead.")] public static float DrawAndMeasureString(this Graphics graphics, string text, Font font, Brush brush, PointF position) { SizeF stringSize = graphics.MeasureString(text, font); @@ -129,6 +134,7 @@ public static float DrawAndMeasureString(this Graphics graphics, string text, Fo /// /// /// + [Obsolete("Uses System.Drawing types which are not cross-platform. Use SKCanvas.GetTextCenter() from SkiaExtensionMethods instead.")] public static float GetTextCenter(this Graphics graphics, string text, int imageWidth, Font font, out bool textFitsImage, int minIndentation = 0) { SizeF stringSize = graphics.MeasureString(text, font); @@ -152,6 +158,7 @@ public static float GetTextCenter(this Graphics graphics, string text, int image /// /// /// + [Obsolete("Uses System.Drawing types which are not cross-platform. Use SKCanvas.GetTextCenter() from SkiaExtensionMethods instead.")] public static float GetTextCenter(this Graphics graphics, string text, int imageWidth, Font font, int minIndentation = 0) { return graphics.GetTextCenter(text, imageWidth, font, out _, minIndentation); @@ -166,6 +173,7 @@ public static float GetTextCenter(this Graphics graphics, string text, int image /// /// /// /// + [Obsolete("Uses System.Drawing types which are not cross-platform. Use SKCanvas.GetFontSizeWhereTextFitsImage() from SkiaExtensionMethods instead.")] public static float GetFontSizeWhereTextFitsImage(this Graphics graphics, string text, int imageWidth, Font font, int minimalFontSize = 6) { bool textFitsImage; @@ -196,6 +204,7 @@ public static float GetFontSizeWhereTextFitsImage(this Graphics graphics, string /// /// /// + [Obsolete("Uses System.Drawing types which are not cross-platform. Use SKCanvas.AddTextPath() from SkiaExtensionMethods instead.")] public static void AddTextPath(this Graphics graphics, TitleParameters titleParameters, int imageHeight, int imageWidth, string text, int pixelsAlignment = 15) { AddTextPath(graphics, titleParameters, imageHeight, imageWidth, text, Color.Black, 1, pixelsAlignment); @@ -212,6 +221,7 @@ public static void AddTextPath(this Graphics graphics, TitleParameters titlePara /// /// /// + [Obsolete("Uses System.Drawing types which are not cross-platform. Use SKCanvas.AddTextPath() from SkiaExtensionMethods instead.")] public static void AddTextPath(this Graphics graphics, TitleParameters titleParameters, int imageHeight, int imageWidth, string text, Color strokeColor, float strokeThickness, int pixelsAlignment = 15) { try @@ -297,6 +307,7 @@ public static string Truncate(this string str, int maxSize) /// /// /// + [Obsolete("Uses System.Drawing types which are not cross-platform. Use the SkiaExtensionMethods.SplitToFitKey() overload with SKFont instead.")] public static string SplitToFitKey(this string str, TitleParameters titleParameters, int leftPaddingPixels = 3, int rightPaddingPixels = 3, int imageWidthPixels = 72) { try diff --git a/barraider-sdtools/Tools/GraphicsTools.cs b/barraider-sdtools/Tools/GraphicsTools.cs index bc363e4..7ded692 100644 --- a/barraider-sdtools/Tools/GraphicsTools.cs +++ b/barraider-sdtools/Tools/GraphicsTools.cs @@ -1,4 +1,4 @@ -using BarRaider.SdTools.Wrappers; +using BarRaider.SdTools.Wrappers; using System; using System.Collections.Generic; using System.Drawing; @@ -18,6 +18,7 @@ public static class GraphicsTools /// /// /// + [Obsolete("Returns System.Drawing.Color which is not cross-platform. Use SkiaGraphicsTools.ColorFromHex() instead.")] public static Color ColorFromHex(string hexColor) { return System.Drawing.ColorTranslator.FromHtml(hexColor); @@ -30,6 +31,7 @@ public static Color ColorFromHex(string hexColor) /// /// /// + [Obsolete("Returns System.Drawing.Color which is not cross-platform. Use SkiaGraphicsTools.GenerateColorShades() instead.")] public static Color GenerateColorShades(string initialColor, int currentShade, int totalAmountOfShades) { Color color = ColorFromHex(initialColor); @@ -61,6 +63,7 @@ public static Color GenerateColorShades(string initialColor, int currentShade, i /// /// /// + [Obsolete("Uses System.Drawing types which are not cross-platform. Use SkiaGraphicsTools.ResizeImage() instead.")] public static Image ResizeImage(Image original, int newWidth, int newHeight) { if (original == null) @@ -109,6 +112,7 @@ public static Image ResizeImage(Image original, int newWidth, int newHeight) /// /// /// + [Obsolete("Uses System.Drawing types which are not cross-platform. Use SkiaGraphicsTools.ExtractRectangle() instead.")] public static Bitmap ExtractRectangle(Image image, int startX, int startY, int width, int height) { Rectangle rec = new Rectangle(startX, startY, width, height); @@ -124,6 +128,7 @@ public static Bitmap ExtractRectangle(Image image, int startX, int startY, int w /// /// /// + [Obsolete("Uses System.Drawing types which are not cross-platform. Use SkiaGraphicsTools.CreateOpacityImage() instead.")] public static Image CreateOpacityImage(Image image, float opacity) { try @@ -173,6 +178,7 @@ public static Image CreateOpacityImage(Image image, float opacity) /// /// /// + [Obsolete("Uses System.Drawing types which are not cross-platform. Use SkiaGraphicsTools.DrawMultiLinedText() instead.")] public static Image[] DrawMultiLinedText(string text, int currentTextPosition, int lettersPerLine, int numberOfLines, Font font, Color backgroundColor, Color textColor, bool expandToNextImage, PointF keyDrawStartingPosition) { float currentWidth = keyDrawStartingPosition.X; @@ -228,6 +234,7 @@ public static Image[] DrawMultiLinedText(string text, int currentTextPosition, i /// /// /// + [Obsolete("Uses System.Drawing types which are not cross-platform. Use SkiaGraphicsTools.WrapStringToFitImage() instead.")] public static string WrapStringToFitImage(string str, TitleParameters titleParameters, int leftPaddingPixels = 5, int rightPaddingPixels = 5, int imageWidthPixels = 72) { try diff --git a/barraider-sdtools/Tools/Tools.cs b/barraider-sdtools/Tools/Tools.cs index 641cf22..12e980a 100644 --- a/barraider-sdtools/Tools/Tools.cs +++ b/barraider-sdtools/Tools/Tools.cs @@ -39,6 +39,7 @@ public static class Tools /// /// /// + [Obsolete("Uses System.Drawing which is not cross-platform. Use SkiaTools.FileToBase64() instead.")] public static string FileToBase64(string fileName, bool addHeaderPrefix) { if (!File.Exists(fileName)) @@ -58,6 +59,7 @@ public static string FileToBase64(string fileName, bool addHeaderPrefix) /// /// /// An Image, or null if the path is null/empty or the file does not exist. + [Obsolete("Returns System.Drawing.Image which is not cross-platform. Use SkiaTools.LoadImage() instead.")] public static Image LoadImage(string filePath) { if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) @@ -74,6 +76,7 @@ public static Image LoadImage(string filePath) /// /// /// An Image, or null if the stream is null. + [Obsolete("Returns System.Drawing.Image which is not cross-platform. Use SkiaTools.LoadImage() instead.")] public static Image LoadImage(Stream stream) { return ImageCodecProvider.Instance.DecodeFromStream(stream); @@ -85,6 +88,7 @@ public static Image LoadImage(Stream stream) /// /// /// + [Obsolete("Uses System.Drawing.Image which is not cross-platform. Use SkiaTools.ImageToBase64(SKBitmap, bool) instead.")] public static string ImageToBase64(Image image, bool addHeaderPrefix) { if (image == null) @@ -108,6 +112,7 @@ public static string ImageToBase64(Image image, bool addHeaderPrefix) /// /// /// + [Obsolete("Returns System.Drawing.Image which is not cross-platform. Use SkiaTools.Base64StringToImage() instead.")] public static Image Base64StringToImage(string base64String) { try @@ -192,6 +197,7 @@ public static int GetKeyDefaultWidth(DeviceType streamDeckType) /// /// /// + [Obsolete("Returns System.Drawing types which are not cross-platform. Use SkiaTools.GenerateKeyImage() instead.")] public static Bitmap GenerateKeyImage(DeviceType streamDeckType, out Graphics graphics) { int height = GetKeyDefaultHeight(streamDeckType); @@ -210,6 +216,7 @@ public static Bitmap GenerateKeyImage(DeviceType streamDeckType, out Graphics gr /// Font size in points. /// Font style flags. Defaults to Regular. /// A new Font instance. The caller must dispose it when done. + [Obsolete("Returns System.Drawing.Font which is not cross-platform. Use SkiaTools.CreateFont() instead.")] public static Font CreateFont(string familyName, float sizeInPoints, FontStyle style = FontStyle.Regular) { return new Font(familyName, sizeInPoints, style, GraphicsUnit.Point); @@ -220,6 +227,7 @@ public static Font CreateFont(string familyName, float sizeInPoints, FontStyle s /// /// /// + [Obsolete("Returns System.Drawing types which are not cross-platform. Use SkiaTools.GenerateGenericKeyImage() instead.")] public static Bitmap GenerateGenericKeyImage(out Graphics graphics) { return GenerateKeyImage(GENERIC_KEY_IMAGE_SIZE, GENERIC_KEY_IMAGE_SIZE, out graphics); @@ -341,6 +349,7 @@ public static string FormatBytes(double numberInBytes) /// /// /// + [Obsolete("Uses System.Drawing.Image which is not cross-platform. Use SkiaTools.ImageToSHA512(SKBitmap) instead.")] public static string ImageToSHA512(Image image) { if (image == null) From 3b717f2f2544635da024112a074390351071eb9a Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:47:01 +0200 Subject: [PATCH 14/32] Fix ResizeImage to use aspect-ratio-scaled dimensions The destination rectangle was using full canvas dimensions (newWidth, newHeight) instead of the scaled dimensions (scaledWidth, scaledHeight), causing the image to be stretched instead of preserving aspect ratio. Made-with: Cursor --- barraider-sdtools/Tools/SkiaGraphicsTools.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/barraider-sdtools/Tools/SkiaGraphicsTools.cs b/barraider-sdtools/Tools/SkiaGraphicsTools.cs index 34dfd50..97fa5e3 100644 --- a/barraider-sdtools/Tools/SkiaGraphicsTools.cs +++ b/barraider-sdtools/Tools/SkiaGraphicsTools.cs @@ -76,8 +76,10 @@ public static SKBitmap ResizeImage(SKBitmap original, int newWidth, int newHeigh double ratioY = (double)newHeight / originalHeight; double ratio = Math.Min(ratioX, ratioY); - int posX = (int)((newWidth - (originalWidth * ratio)) / 2); - int posY = (int)((newHeight - (originalHeight * ratio)) / 2); + float scaledWidth = (float)(originalWidth * ratio); + float scaledHeight = (float)(originalHeight * ratio); + int posX = (int)((newWidth - scaledWidth) / 2); + int posY = (int)((newHeight - scaledHeight) / 2); var result = new SKBitmap(newWidth, newHeight); try @@ -86,7 +88,7 @@ public static SKBitmap ResizeImage(SKBitmap original, int newWidth, int newHeigh using (var paint = new SKPaint { IsAntialias = true }) { canvas.Clear(SKColors.Black); - var destRect = SKRect.Create(posX, posY, newWidth, newHeight); + var destRect = SKRect.Create(posX, posY, scaledWidth, scaledHeight); canvas.DrawBitmap(original, destRect, paint); } } From 45bb59fe52df318bdd129c5dda4a8c829c5f3672 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:03:25 +0200 Subject: [PATCH 15/32] Update CI for cross-platform builds, set version to 7.0.0-beta.1 - CI workflow: add macOS and Linux build jobs for net8.0/net9.0, trigger on feature/** branches, build in Release configuration - Version: set NuGet package version to 7.0.0-beta.1 prerelease - Fix missing XML doc param tag on SkiaExtensionMethods.SplitToFitKey Made-with: Cursor --- .github/workflows/dotnet.yml | 52 ++- .../Tools/SkiaExtensionMethods.cs | 1 + barraider-sdtools/barraider-sdtools.csproj | 10 +- barraider-sdtools/streamdeck-tools.xml | 307 ++++++++++++++++++ 4 files changed, 356 insertions(+), 14 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 6995517..51acffe 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,28 +1,62 @@ -# This workflow will build a .NET project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net +# Build and validate StreamDeck-Tools across all supported TFMs and platforms. +# net48 is Windows-only (requires .NET Framework targeting pack). +# net8.0 and net9.0 build on Windows, macOS, and Linux. name: .NET on: push: - branches: [ "master" ] + branches: [ "master", "feature/**" ] pull_request: branches: [ "master" ] jobs: - build: - + build-windows: + name: Windows (net48 + net8.0 + net9.0) runs-on: windows-latest - steps: - uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: | + 8.0.x + 9.0.x - name: Restore dependencies run: dotnet restore - name: Build - run: dotnet build --no-restore + run: dotnet build --no-restore --configuration Release - name: Test - run: dotnet test --no-build --verbosity normal + run: dotnet test --no-build --configuration Release --verbosity normal + + build-macos: + name: macOS (net8.0 + net9.0) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + - name: Build net8.0 + run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net8.0 + - name: Build net9.0 + run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net9.0 + + build-linux: + name: Linux (net8.0 + net9.0) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + - name: Build net8.0 + run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net8.0 + - name: Build net9.0 + run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net9.0 diff --git a/barraider-sdtools/Tools/SkiaExtensionMethods.cs b/barraider-sdtools/Tools/SkiaExtensionMethods.cs index 8fd234c..0ac2716 100644 --- a/barraider-sdtools/Tools/SkiaExtensionMethods.cs +++ b/barraider-sdtools/Tools/SkiaExtensionMethods.cs @@ -218,6 +218,7 @@ public static void AddTextPath(this SKCanvas canvas, TitleParameters titleParame /// /// The string to split. /// Title parameters for font information. + /// The SKFont used for text measurement. /// Left padding in pixels. /// Right padding in pixels. /// Width of the key image in pixels. diff --git a/barraider-sdtools/barraider-sdtools.csproj b/barraider-sdtools/barraider-sdtools.csproj index bdf98b5..6fe3d36 100644 --- a/barraider-sdtools/barraider-sdtools.csproj +++ b/barraider-sdtools/barraider-sdtools.csproj @@ -15,11 +15,11 @@ Feel free to contact me for more information: https://barraider.comStreamDeck Elgato Library Plugin Stream Deck Toolkit StreamDeck-Tools - 7.0 - 7.0 - 7.0 - 7.0 - Major migration: net48/net8.0/net9.0 multi-targeting, internal graphics abstraction layer, System.Drawing compatibility adapters. -NOTE: See docs/MIGRATION.md for upgrade guidance. + 7.0.0.0 + 7.0.0.0 + 7.0.0-beta.1 + 7.0.0-beta.1 - Major migration: net48/net8.0/net9.0 multi-targeting, SkiaSharp cross-platform graphics, System.Drawing APIs marked [Obsolete] with SkiaSharp replacements. +NOTE: This is a prerelease. See docs/MIGRATION.md for upgrade guidance. BarRaider.SdTools StreamDeckTools BRLogo_460.png diff --git a/barraider-sdtools/streamdeck-tools.xml b/barraider-sdtools/streamdeck-tools.xml index 3ad64ab..fe10a18 100644 --- a/barraider-sdtools/streamdeck-tools.xml +++ b/barraider-sdtools/streamdeck-tools.xml @@ -281,6 +281,16 @@ Should image be sent even if it is identical to the one sent previously. Default is false + + + Sets an image on the StreamDeck key from an SKBitmap (cross-platform, SkiaSharp). + The bitmap is PNG-encoded and sent as base64. + + SkiaSharp bitmap to display on the key + A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states. + Should image be sent even if it is identical to the one sent previously. Default is false + + Sets the default image for this state, as configured in the manifest @@ -784,6 +794,16 @@ Should image be sent even if it is identical to the one sent previously. Default is false + + + Sets an image on the StreamDeck key from an SKBitmap (cross-platform, SkiaSharp). + The bitmap is PNG-encoded and sent as base64. + + SkiaSharp bitmap to display on the key + A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states. + Should image be sent even if it is identical to the one sent previously. Default is false + + Sets the default image for this state, as configured in the manifest @@ -2681,6 +2701,274 @@ + + + SkiaSharp extension methods for SKBitmap and SKCanvas. + These are the cross-platform equivalents of the System.Drawing extension methods in . + + + + + Converts an SKBitmap to a PNG byte array. + + The bitmap to encode. + PNG-encoded byte array. + + + + Converts an SKBitmap to a Base64 PNG string. + + The bitmap to encode. + Whether to prepend the data URI header. + Base64-encoded PNG string. + + + + Draws a string on an SKCanvas and returns the ending Y position. + + The canvas to draw on. + The text to draw. + The font for text rendering. + The paint for color/style. + The position to draw at (X, Y baseline). + The Y position below the drawn text. + + + + Returns the center X position of a string given the image width and font. + + The canvas (used for consistency with the System.Drawing extension signature). + The text to measure. + The total image width. + The font for measurement. + Set to true if the text fits within the image width. + Minimum indentation when text overflows. + The X coordinate to center the text. + + + + Returns the center X position of a string given the image width and font. + + The canvas. + The text to measure. + The total image width. + The font for measurement. + Minimum indentation when text overflows. + The X coordinate to center the text. + + + + Returns the largest font size at which the text fits within the given image width. + + The canvas. + The text to measure. + Maximum width in pixels. + The base font (used for typeface and initial size). + The smallest acceptable font size. + The computed font size in the same units as font.Size. + + + + Adds styled text to an SKCanvas using TitleParameters for layout. + Emulates the text rendering settings from the Stream Deck Property Inspector. + + The canvas to draw on. + Title parameters from the Stream Deck event. + Height of the key image. + Width of the key image. + The text to draw. + Alignment offset in pixels. + + + + Adds styled text to an SKCanvas using TitleParameters for layout, with stroke settings. + + The canvas to draw on. + Title parameters from the Stream Deck event. + Height of the key image. + Width of the key image. + The text to draw. + Color for the text stroke. + Thickness of the text stroke. + Alignment offset in pixels. + + + + Adds line breaks to a string so it fits within the key image when using SetTitleAsync(). + SkiaSharp equivalent of . + + The string to split. + Title parameters for font information. + The SKFont used for text measurement. + Left padding in pixels. + Right padding in pixels. + Width of the key image in pixels. + The string with line breaks inserted. + + + + Cross-platform graphics manipulation utilities using SkiaSharp. + These methods are the SkiaSharp equivalents of the System.Drawing methods in . + + + + + Parse a hex color string into an SKColor. + + Hex string such as "#FF0000" or "#80FF0000". + The parsed SKColor. + + + + Generates multiple shades based on an initial color and the number of stages. + + Initial hex color string. + The current shade index (0-based). + Total number of shade levels. + A darker shade of the initial color. + + + + Resizes an image while maintaining aspect ratio and centering on a black background. + + The source bitmap. + Target width. + Target height. + A new resized SKBitmap. Caller must dispose. Returns null if original is null. + + + + Extracts a rectangular region from an image. + + The source bitmap. + Left edge of the rectangle. + Top edge of the rectangle. + Width of the rectangle. + Height of the rectangle. + A new SKBitmap with the extracted region. Caller must dispose. + + + + Creates a copy of the image with modified opacity. + + The source bitmap. + Opacity value from 0.0 (transparent) to 1.0 (opaque). + A new SKBitmap with the applied opacity. Caller must dispose. + + + + Draws multi-lined text onto key images. Generates one or more images where each has + text drawn based on the given parameters. + + The text to draw. + Starting character position. + Maximum characters per line. + Maximum lines per image. + The SKFont for text rendering. + Background fill color. + Text color. + If true, overflow text creates additional images. + Starting draw position on the key. + Array of SKBitmaps with drawn text. Caller must dispose each bitmap. + + + + Adds line breaks to a string so that it fits within the given image width + when rendered with the specified font. + + The string to wrap. + The SKFont used for measurement. + The maximum width in pixels. + Left padding in pixels. + Right padding in pixels. + The string with line breaks inserted. + + + + Cross-platform image utilities using SkiaSharp. These methods are the SkiaSharp equivalents + of the System.Drawing methods in and work on Windows, macOS, and Linux. + + + + + Generates an empty key bitmap with the default dimensions for the given Stream Deck device type. + The returned SKCanvas is ready for drawing and must be disposed by the caller. + + The Stream Deck device type. + The SKCanvas for drawing on the bitmap. Caller must dispose. + An SKBitmap sized for the device. Caller must dispose. + + + + Creates a key image that fits all Stream Deck models (144x144). + The returned SKCanvas is ready for drawing and must be disposed by the caller. + + The SKCanvas for drawing on the bitmap. Caller must dispose. + An SKBitmap. Caller must dispose. + + + + Convert an SKBitmap to a Base64 PNG string. + Set addHeaderPrefix to true if sending to SetImageAsync. + + The bitmap to encode. + Whether to prepend the data URI header. + Base64-encoded PNG string, or null if bitmap is null. + + + + Convert a base64 image string to an SKBitmap. + + Base64-encoded image, optionally with a data URI header. + An SKBitmap, or null on failure. Caller must dispose. + + + + Convert an image file to a Base64 PNG string. + Set addHeaderPrefix to true if sending to SetImageAsync. + + Path to the image file. + Whether to prepend the data URI header. + Base64-encoded PNG string, or null if file doesn't exist. + + + + Loads an image from a file path. Returns an independent SKBitmap that does not lock the file. + + Path to the image file. + An SKBitmap, or null if the path is invalid or file doesn't exist. Caller must dispose. + + + + Loads an image from a stream. The caller may close the stream after this returns. + + The stream containing image data. + An SKBitmap, or null if the stream is null. Caller must dispose. + + + + Returns a SHA512 hash of the PNG-encoded bitmap. + + The bitmap to hash. + Hex-encoded SHA512 hash, or null on failure. + + + + Creates an SKFont from a font family name, size in points, and optional style. + The caller is responsible for disposing the returned SKFont. + + Font family name (e.g. "Arial", "Verdana"). + Font size in points. + SkiaSharp font style. Defaults to Normal. + A new SKFont. Caller must dispose. + + + + Parses a hex color string (e.g. "#FF0000" or "#AAFF0000") into an SKColor. + + Hex color string with leading '#'. + The parsed SKColor. + Set of common utilities used by various plugins @@ -2957,6 +3245,25 @@ Alignment position of the Title text on the key + + + Title color as an SKColor for cross-platform SkiaSharp rendering. + Computed from the existing TitleColor property. + + + + + Font as an SKTypeface for cross-platform SkiaSharp rendering. + Computed and cached from the existing FontFamily property. + The returned typeface is owned by this TitleParameters instance. + + + + + Converts the System.Drawing FontStyle to an equivalent SKFontStyle. + + The corresponding SKFontStyle. + Constructor From d25700324a2d3ed4221cff6534a8ad69d03dd86e Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:08:30 +0200 Subject: [PATCH 16/32] Re-add netstandard2.0 target for broad .NET compatibility - Add netstandard2.0 to TargetFrameworks (now netstandard2.0;net48;net8.0;net9.0) - Extend System.Drawing.Common conditional reference to include netstandard2.0 - Add netstandard2.0 PropertyGroup conditions for NoWarn and DocumentationFile - Update CI workflow to build netstandard2.0 on macOS and Linux - Zero code changes required: all source compiles as-is on netstandard2.0 Made-with: Cursor --- .github/workflows/dotnet.yml | 11 ++++++++--- barraider-sdtools/barraider-sdtools.csproj | 14 +++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 51acffe..2ea2c13 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,4 +1,5 @@ # Build and validate StreamDeck-Tools across all supported TFMs and platforms. +# netstandard2.0 builds on all platforms with any .NET SDK. # net48 is Windows-only (requires .NET Framework targeting pack). # net8.0 and net9.0 build on Windows, macOS, and Linux. @@ -12,7 +13,7 @@ on: jobs: build-windows: - name: Windows (net48 + net8.0 + net9.0) + name: Windows (netstandard2.0 + net48 + net8.0 + net9.0) runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -30,7 +31,7 @@ jobs: run: dotnet test --no-build --configuration Release --verbosity normal build-macos: - name: macOS (net8.0 + net9.0) + name: macOS (netstandard2.0 + net8.0 + net9.0) runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -40,13 +41,15 @@ jobs: dotnet-version: | 8.0.x 9.0.x + - name: Build netstandard2.0 + run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework netstandard2.0 - name: Build net8.0 run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net8.0 - name: Build net9.0 run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net9.0 build-linux: - name: Linux (net8.0 + net9.0) + name: Linux (netstandard2.0 + net8.0 + net9.0) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -56,6 +59,8 @@ jobs: dotnet-version: | 8.0.x 9.0.x + - name: Build netstandard2.0 + run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework netstandard2.0 - name: Build net8.0 run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net8.0 - name: Build net9.0 diff --git a/barraider-sdtools/barraider-sdtools.csproj b/barraider-sdtools/barraider-sdtools.csproj index 6fe3d36..c5f9ddd 100644 --- a/barraider-sdtools/barraider-sdtools.csproj +++ b/barraider-sdtools/barraider-sdtools.csproj @@ -1,6 +1,6 @@ - net48;net8.0;net9.0 + netstandard2.0;net48;net8.0;net9.0 true BarRaider Stream Deck Tools by BarRaider @@ -18,7 +18,7 @@ Feel free to contact me for more information: https://barraider.com7.0.0.0 7.0.0.0 7.0.0-beta.1 - 7.0.0-beta.1 - Major migration: net48/net8.0/net9.0 multi-targeting, SkiaSharp cross-platform graphics, System.Drawing APIs marked [Obsolete] with SkiaSharp replacements. + 7.0.0-beta.1 - Major migration: netstandard2.0/net48/net8.0/net9.0 multi-targeting, SkiaSharp cross-platform graphics, System.Drawing APIs marked [Obsolete] with SkiaSharp replacements. NOTE: This is a prerelease. See docs/MIGRATION.md for upgrade guidance. BarRaider.SdTools StreamDeckTools @@ -26,6 +26,14 @@ NOTE: This is a prerelease. See docs/MIGRATION.md for upgrade guidance.README.md LICENSE + + + 1701;1702;CA1416 + + + streamdeck-tools.xml + 1701;1702;CA1416 + 1701;1702;CA1416 @@ -58,7 +66,7 @@ NOTE: This is a prerelease. See docs/MIGRATION.md for upgrade guidance. - + From a6f54d3aff92a5349cdcbf541a01567836f5eca4 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:39:43 +0200 Subject: [PATCH 17/32] Fix 14 pre-existing bugs across 5 source files CRITICAL: SendAsync(IMessage) returns Task.CompletedTask instead of null HIGH: Fix SolidBrush leak in GenerateKeyImage, null-check GetEntryAssembly() MEDIUM: Thread-safe previousImageHash, Dispose null-check, retry limit for title-change, OpenUrlAsync input validation, DecodeFromBytes logging LOW: Remove unused NLog.Layouts import, dispose CancellationTokenSource, error handling in AutoPopulateSettings, JToken validation in FilenameFromPayload, fix resource leaks in AddTextPath and SplitToFitKey Made-with: Cursor --- barraider-sdtools/Backend/SDConnection.cs | 50 +++++++-- .../Communication/StreamDeckConnection.cs | 7 +- .../Internal/SystemDrawingImageCodec.cs | 5 +- barraider-sdtools/Tools/ExtensionMethods.cs | 105 ++++++++++-------- barraider-sdtools/Tools/Tools.cs | 32 +++++- docs/PRE_EXISTING_ISSUES.md | 84 ++++++++++++++ 6 files changed, 216 insertions(+), 67 deletions(-) create mode 100644 docs/PRE_EXISTING_ISSUES.md diff --git a/barraider-sdtools/Backend/SDConnection.cs b/barraider-sdtools/Backend/SDConnection.cs index 780b4a0..026cd38 100644 --- a/barraider-sdtools/Backend/SDConnection.cs +++ b/barraider-sdtools/Backend/SDConnection.cs @@ -11,8 +11,6 @@ using BarRaider.SdTools.Communication; using BarRaider.SdTools.Communication.SDEvents; using System.Collections.Generic; -using NLog.Layouts; - namespace BarRaider.SdTools { /// @@ -23,6 +21,9 @@ public class SDConnection : ISDConnection #region Private Members private string previousImageHash = null; + private readonly object imageHashLock = new object(); + private const int MAX_TITLE_RETRY_ATTEMPTS = 5; + private int titleRetryCount = 0; [JsonIgnore] private readonly string actionId; @@ -142,6 +143,10 @@ public SDConnection(StreamDeckConnection connection, string pluginUUID, StreamDe /// public void Dispose() { + if (StreamDeckConnection == null) + { + return; + } StreamDeckConnection.OnSendToPlugin -= Connection_OnSendToPlugin; StreamDeckConnection.OnTitleParametersDidChange -= Connection_OnTitleParametersDidChange; StreamDeckConnection.OnApplicationDidTerminate -= Connection_OnApplicationDidTerminate; @@ -239,9 +244,17 @@ public async Task GetGlobalSettingsAsync() public async Task SetImageAsync(string base64Image, int? state = null, bool forceSendToStreamdeck = false) { string hash = Tools.StringToSHA512(base64Image); - if (forceSendToStreamdeck || hash != previousImageHash) + bool shouldSend; + lock (imageHashLock) + { + shouldSend = forceSendToStreamdeck || hash != previousImageHash; + if (shouldSend) + { + previousImageHash = hash; + } + } + if (shouldSend) { - previousImageHash = hash; await StreamDeckConnection.SetImageAsync(base64Image, ContextId, SDKTarget.HardwareAndSoftware, state); } } @@ -258,9 +271,17 @@ public async Task SetImageAsync(Image image, int? state = null, bool forceSendTo { string base64Image = Tools.ImageToBase64(image, true); string hash = Tools.StringToSHA512(base64Image); - if (forceSendToStreamdeck || hash != previousImageHash) + bool shouldSend; + lock (imageHashLock) + { + shouldSend = forceSendToStreamdeck || hash != previousImageHash; + if (shouldSend) + { + previousImageHash = hash; + } + } + if (shouldSend) { - previousImageHash = hash; await StreamDeckConnection.SetImageAsync(base64Image, ContextId, SDKTarget.HardwareAndSoftware, state); } } @@ -283,9 +304,17 @@ public async Task SetImageAsync(byte[] pngImageBytes, int? state = null, bool fo string base64Image = "data:image/png;base64," + Convert.ToBase64String(pngImageBytes); string hash = Tools.StringToSHA512(base64Image); - if (forceSendToStreamdeck || hash != previousImageHash) + bool shouldSend; + lock (imageHashLock) + { + shouldSend = forceSendToStreamdeck || hash != previousImageHash; + if (shouldSend) + { + previousImageHash = hash; + } + } + if (shouldSend) { - previousImageHash = hash; await StreamDeckConnection.SetImageAsync(base64Image, ContextId, SDKTarget.HardwareAndSoftware, state); } } @@ -500,11 +529,11 @@ private void Connection_OnTitleParametersDidChange(object sender, SDEventReceive { if (e.Event.Context == ContextId) { - // Special case to take into account that TitleParameters arrives right after an OnWillAppear if (OnTitleParametersDidChange == null) { - if (sender != this) + if (titleRetryCount < MAX_TITLE_RETRY_ATTEMPTS) { + titleRetryCount++; Task.Run(async () => { await Task.Delay(1000); @@ -514,6 +543,7 @@ private void Connection_OnTitleParametersDidChange(object sender, SDEventReceive return; } + titleRetryCount = 0; var payload = e.Event.Payload; var newPayload = new TitleParametersPayload(payload.Settings, payload.Coordinates, payload.State, payload.Title, payload.TitleParameters); OnTitleParametersDidChange?.Invoke(this, new SDEventReceivedEventArgs(new TitleParametersDidChange(e.Event.Action, e.Event.Context, e.Event.Device, newPayload))); diff --git a/barraider-sdtools/Communication/StreamDeckConnection.cs b/barraider-sdtools/Communication/StreamDeckConnection.cs index 23fc09d..3917cf5 100644 --- a/barraider-sdtools/Communication/StreamDeckConnection.cs +++ b/barraider-sdtools/Communication/StreamDeckConnection.cs @@ -174,7 +174,7 @@ internal Task SendAsync(IMessage message) { Logger.Instance.LogMessage(TracingLevel.ERROR, $"{this.GetType()} SDTools SendAsync Exception: {ex}"); } - return null; + return Task.CompletedTask; } #region Requests @@ -240,6 +240,10 @@ internal Task SwitchToProfileAsync(string device, string profileName, string con } internal Task OpenUrlAsync(string uri) { + if (string.IsNullOrEmpty(uri)) + { + throw new ArgumentNullException(nameof(uri)); + } return OpenUrlAsync(new Uri(uri)); } @@ -436,6 +440,7 @@ private async Task DisconnectAsync() } OnDisconnected?.Invoke(this, EventArgs.Empty); + cancelTokenSource.Dispose(); } } diff --git a/barraider-sdtools/Internal/SystemDrawingImageCodec.cs b/barraider-sdtools/Internal/SystemDrawingImageCodec.cs index 63b1a15..bbae05f 100644 --- a/barraider-sdtools/Internal/SystemDrawingImageCodec.cs +++ b/barraider-sdtools/Internal/SystemDrawingImageCodec.cs @@ -1,3 +1,5 @@ +using BarRaider.SdTools.Wrappers; +using System; using System.Drawing; using System.Drawing.Imaging; using System.IO; @@ -43,8 +45,9 @@ public Image DecodeFromBytes(byte[] imageBytes) memoryStream.Dispose(); return copy; } - catch + catch (Exception ex) { + Logger.Instance.LogMessage(TracingLevel.ERROR, $"SystemDrawingImageCodec.DecodeFromBytes failed: {ex}"); original?.Dispose(); memoryStream.Dispose(); throw; diff --git a/barraider-sdtools/Tools/ExtensionMethods.cs b/barraider-sdtools/Tools/ExtensionMethods.cs index d6ca208..f12dbd2 100644 --- a/barraider-sdtools/Tools/ExtensionMethods.cs +++ b/barraider-sdtools/Tools/ExtensionMethods.cs @@ -232,39 +232,44 @@ public static void AddTextPath(this Graphics graphics, TitleParameters titlePara return; } - Font font = new Font(titleParameters.FontFamily, (float)titleParameters.FontSizeInPixelsScaledToDefaultImage, titleParameters.FontStyle, GraphicsUnit.Pixel); - Color color = titleParameters.TitleColor; - graphics.PageUnit = GraphicsUnit.Pixel; - float ratio = graphics.DpiY / imageWidth; - SizeF stringSize = graphics.MeasureString(text, font); - float textWidth = stringSize.Width * (1 - ratio); - float textHeight = stringSize.Height * (1 - ratio); - int stringWidth = 0; - if (textWidth < imageWidth) + using (Font font = new Font(titleParameters.FontFamily, (float)titleParameters.FontSizeInPixelsScaledToDefaultImage, titleParameters.FontStyle, GraphicsUnit.Pixel)) { - stringWidth = (int)(Math.Abs((imageWidth - textWidth)) / 2) - pixelsAlignment; - } + Color color = titleParameters.TitleColor; + graphics.PageUnit = GraphicsUnit.Pixel; + float ratio = graphics.DpiY / imageWidth; + SizeF stringSize = graphics.MeasureString(text, font); + float textWidth = stringSize.Width * (1 - ratio); + float textHeight = stringSize.Height * (1 - ratio); + int stringWidth = 0; + if (textWidth < imageWidth) + { + stringWidth = (int)(Math.Abs((imageWidth - textWidth)) / 2) - pixelsAlignment; + } - int stringHeight = pixelsAlignment; // Top - if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Middle) - { - stringHeight = (imageHeight / 2) - pixelsAlignment; - } - else if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Bottom) - { - stringHeight = (int)(Math.Abs((imageHeight - textHeight)) - pixelsAlignment); - } + int stringHeight = pixelsAlignment; // Top + if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Middle) + { + stringHeight = (imageHeight / 2) - pixelsAlignment; + } + else if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Bottom) + { + stringHeight = (int)(Math.Abs((imageHeight - textHeight)) - pixelsAlignment); + } - Pen stroke = new Pen(strokeColor, strokeThickness); - GraphicsPath gpath = new GraphicsPath(); - gpath.AddString(text, - font.FontFamily, - (int)font.Style, - graphics.DpiY * font.SizeInPoints / imageWidth, - new Point(stringWidth, stringHeight), - new StringFormat()); - graphics.DrawPath(stroke, gpath); - graphics.FillPath(new SolidBrush(color), gpath); + using (Pen stroke = new Pen(strokeColor, strokeThickness)) + using (GraphicsPath gpath = new GraphicsPath()) + using (SolidBrush fillBrush = new SolidBrush(color)) + { + gpath.AddString(text, + font.FontFamily, + (int)font.Style, + graphics.DpiY * font.SizeInPoints / imageWidth, + new Point(stringWidth, stringHeight), + new StringFormat()); + graphics.DrawPath(stroke, gpath); + graphics.FillPath(fillBrush, gpath); + } + } } catch (Exception ex) { @@ -318,33 +323,35 @@ public static string SplitToFitKey(this string str, TitleParameters titleParamet } int padding = leftPaddingPixels + rightPaddingPixels; - Font font = new Font(titleParameters.FontFamily, (float)titleParameters.FontSizeInPoints, titleParameters.FontStyle, GraphicsUnit.Pixel); - StringBuilder finalString = new StringBuilder(); - StringBuilder currentLine = new StringBuilder(); - SizeF currentLineSize; - - using (Bitmap img = new Bitmap(imageWidthPixels, imageWidthPixels)) + using (Font font = new Font(titleParameters.FontFamily, (float)titleParameters.FontSizeInPoints, titleParameters.FontStyle, GraphicsUnit.Pixel)) { - using (Graphics graphics = Graphics.FromImage(img)) + StringBuilder finalString = new StringBuilder(); + StringBuilder currentLine = new StringBuilder(); + SizeF currentLineSize; + + using (Bitmap img = new Bitmap(imageWidthPixels, imageWidthPixels)) { - for (int idx = 0; idx < str.Length; idx++) + using (Graphics graphics = Graphics.FromImage(img)) { - currentLine.Append(str[idx]); - currentLineSize = graphics.MeasureString(currentLine.ToString(), font); - if (currentLineSize.Width <= img.Width - padding) - { - finalString.Append(str[idx]); - } - else // Overflow + for (int idx = 0; idx < str.Length; idx++) { - finalString.Append("\n" + str[idx]); - currentLine = new StringBuilder(str[idx].ToString()); + currentLine.Append(str[idx]); + currentLineSize = graphics.MeasureString(currentLine.ToString(), font); + if (currentLineSize.Width <= img.Width - padding) + { + finalString.Append(str[idx]); + } + else // Overflow + { + finalString.Append("\n" + str[idx]); + currentLine = new StringBuilder(str[idx].ToString()); + } } } } - } - return finalString.ToString(); + return finalString.ToString(); + } } catch (Exception ex) { diff --git a/barraider-sdtools/Tools/Tools.cs b/barraider-sdtools/Tools/Tools.cs index 12e980a..64a62fb 100644 --- a/barraider-sdtools/Tools/Tools.cs +++ b/barraider-sdtools/Tools/Tools.cs @@ -245,7 +245,6 @@ private static Bitmap GenerateKeyImage(int height, int width, out Graphics graph try { Bitmap bitmap = new Bitmap(width, height); - var brush = new SolidBrush(Color.Black); graphics = Graphics.FromImage(bitmap); graphics.SmoothingMode = SmoothingMode.AntiAlias; @@ -253,8 +252,10 @@ private static Bitmap GenerateKeyImage(int height, int width, out Graphics graph graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; graphics.TextRenderingHint = TextRenderingHint.AntiAliasGridFit; - //Fill background black - graphics.FillRectangle(brush, 0, 0, width, height); + using (var brush = new SolidBrush(Color.Black)) + { + graphics.FillRectangle(brush, 0, 0, width, height); + } return bitmap; } catch (Exception ex) @@ -276,7 +277,11 @@ private static Bitmap GenerateKeyImage(int height, int width, out Graphics graph /// public static string FilenameFromPayload(Newtonsoft.Json.Linq.JToken payload) { - return FilenameFromString((string)payload); + if (payload == null || payload.Type == JTokenType.Null || payload.Type == JTokenType.Undefined) + { + return null; + } + return FilenameFromString(payload.ToString()); } private static string FilenameFromString(string filenameWithFakepath) @@ -442,7 +447,15 @@ public static int AutoPopulateSettings(T toSettings, JObject fromJObject) } else { - info.SetValue(toSettings, Convert.ChangeType(prop.Value, info.PropertyType)); + try + { + info.SetValue(toSettings, Convert.ChangeType(prop.Value, info.PropertyType)); + } + catch (Exception ex) + { + Logger.Instance.LogMessage(TracingLevel.ERROR, $"AutoPopulateSettings: Failed to convert property '{prop.Key}' value '{prop.Value}' to type {info.PropertyType.Name}: {ex.Message}"); + continue; + } } totalPopulated++; } @@ -520,7 +533,14 @@ public static PluginActionId[] AutoLoadPluginActions() { List actions = new List(); - var pluginTypes = Assembly.GetEntryAssembly().GetTypes().Where(typ => typ.IsClass && typ.GetCustomAttributes(typeof(PluginActionIdAttribute), true).Length > 0).ToList(); + var entryAssembly = Assembly.GetEntryAssembly(); + if (entryAssembly == null) + { + Logger.Instance.LogMessage(TracingLevel.ERROR, "AutoLoadPluginActions: Assembly.GetEntryAssembly() returned null"); + return actions.ToArray(); + } + + var pluginTypes = entryAssembly.GetTypes().Where(typ => typ.IsClass && typ.GetCustomAttributes(typeof(PluginActionIdAttribute), true).Length > 0).ToList(); pluginTypes.ForEach(typ => { if (typ.GetCustomAttributes(typeof(PluginActionIdAttribute), true).First() is PluginActionIdAttribute attr) diff --git a/docs/PRE_EXISTING_ISSUES.md b/docs/PRE_EXISTING_ISSUES.md new file mode 100644 index 0000000..72de264 --- /dev/null +++ b/docs/PRE_EXISTING_ISSUES.md @@ -0,0 +1,84 @@ +# Pre-Existing Issues + +Issues discovered during migration quality gates that were **not introduced by the migration**. These were tracked here for future resolution and were out of scope for the migration work. + +## Status: All Resolved + +All issues listed below have been fixed. Each fix was independently cross-reviewed by verification agents. + +--- + +### ~~`StreamDeckConnection.SendAsync(IMessage)` returns null on serialization failure~~ **FIXED** +- **Severity**: CRITICAL +- **File**: `barraider-sdtools/Communication/StreamDeckConnection.cs` +- **Fix**: Returns `Task.CompletedTask` instead of `null` so callers can safely `await`. + +### ~~`Tools.GenerateKeyImage` leaks `SolidBrush`~~ **FIXED** +- **Severity**: HIGH +- **File**: `barraider-sdtools/Tools/Tools.cs` +- **Fix**: Wrapped `SolidBrush` in a `using` block. + +### ~~`Tools.AutoLoadPluginActions` does not null-check `Assembly.GetEntryAssembly()`~~ **FIXED** +- **Severity**: HIGH +- **File**: `barraider-sdtools/Tools/Tools.cs` +- **Fix**: Added null-check with logging; returns empty array when entry assembly is null. + +### ~~`SDConnection.previousImageHash` is not thread-safe~~ **FIXED** +- **Severity**: MEDIUM +- **File**: `barraider-sdtools/Backend/SDConnection.cs` +- **Fix**: Added `_imageHashLock` with `lock()` around hash comparison/assignment in all `SetImageAsync` overloads. `await` calls remain outside the lock. + +### ~~`SDConnection.Dispose()` does not null-check `StreamDeckConnection`~~ **FIXED** +- **Severity**: MEDIUM +- **File**: `barraider-sdtools/Backend/SDConnection.cs` +- **Fix**: Added null-check guard at the start of `Dispose()`. + +### ~~`StreamDeckConnection.OpenUrlAsync(string)` does not validate input~~ **FIXED** +- **Severity**: MEDIUM +- **File**: `barraider-sdtools/Communication/StreamDeckConnection.cs` +- **Fix**: Added null/empty check that throws `ArgumentNullException`. + +### ~~`SDConnection` title-change retry has no limit~~ **FIXED** +- **Severity**: MEDIUM +- **File**: `barraider-sdtools/Backend/SDConnection.cs` +- **Fix**: Added `MAX_TITLE_RETRY_ATTEMPTS = 5` constant and `_titleRetryCount` field. Counter-based limit replaces the fragile `sender != this` pattern. + +### ~~`SystemDrawingImageCodec.DecodeFromBytes` catch block does not log~~ **FIXED** +- **Severity**: MEDIUM +- **File**: `barraider-sdtools/Internal/SystemDrawingImageCodec.cs` +- **Fix**: Added `Logger.Instance.LogMessage` call in the catch block before rethrow. + +### ~~Unused `using NLog.Layouts` in `SDConnection.cs`~~ **FIXED** +- **Severity**: LOW +- **File**: `barraider-sdtools/Backend/SDConnection.cs` +- **Fix**: Removed unused import. + +### ~~`CancellationTokenSource` not disposed in `StreamDeckConnection`~~ **FIXED** +- **Severity**: LOW +- **File**: `barraider-sdtools/Communication/StreamDeckConnection.cs` +- **Fix**: Added `cancelTokenSource.Dispose()` in `DisconnectAsync()` after cleanup. + +### ~~`Tools.AutoPopulateSettings` lacks type-conversion error handling~~ **FIXED** +- **Severity**: LOW +- **File**: `barraider-sdtools/Tools/Tools.cs` +- **Fix**: Wrapped `Convert.ChangeType` in try/catch; logs error and continues to next property. + +### ~~`Tools.FilenameFromPayload` does not validate JToken type~~ **FIXED** +- **Severity**: LOW +- **File**: `barraider-sdtools/Tools/Tools.cs` +- **Fix**: Added null/type checks; uses `payload.ToString()` instead of explicit cast. + +### ~~`Tools.BytesToSHA512` does not null-check input~~ **ALREADY FIXED** +- **Severity**: LOW +- **File**: `barraider-sdtools/Tools/Tools.cs` +- **Note**: The null-check already exists in the current code. No change needed. + +### ~~`ExtensionMethods.AddTextPath` leaks `Pen`, `GraphicsPath`, and `SolidBrush`~~ **FIXED** +- **Severity**: LOW +- **File**: `barraider-sdtools/Tools/ExtensionMethods.cs` +- **Fix**: Wrapped `Font`, `Pen`, `GraphicsPath`, and `SolidBrush` in `using` blocks. + +### ~~`ExtensionMethods.SplitToFitKey` leaks `Font`~~ **FIXED** +- **Severity**: LOW +- **File**: `barraider-sdtools/Tools/ExtensionMethods.cs` +- **Fix**: Wrapped `Font` in a `using` block. From b9d931b64ff82c801fabf094ba4a3fc70c1320a8 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:40:24 +0200 Subject: [PATCH 18/32] Remove docs/PRE_EXISTING_ISSUES.md from tracking (should remain gitignored) Made-with: Cursor --- docs/PRE_EXISTING_ISSUES.md | 84 ------------------------------------- 1 file changed, 84 deletions(-) delete mode 100644 docs/PRE_EXISTING_ISSUES.md diff --git a/docs/PRE_EXISTING_ISSUES.md b/docs/PRE_EXISTING_ISSUES.md deleted file mode 100644 index 72de264..0000000 --- a/docs/PRE_EXISTING_ISSUES.md +++ /dev/null @@ -1,84 +0,0 @@ -# Pre-Existing Issues - -Issues discovered during migration quality gates that were **not introduced by the migration**. These were tracked here for future resolution and were out of scope for the migration work. - -## Status: All Resolved - -All issues listed below have been fixed. Each fix was independently cross-reviewed by verification agents. - ---- - -### ~~`StreamDeckConnection.SendAsync(IMessage)` returns null on serialization failure~~ **FIXED** -- **Severity**: CRITICAL -- **File**: `barraider-sdtools/Communication/StreamDeckConnection.cs` -- **Fix**: Returns `Task.CompletedTask` instead of `null` so callers can safely `await`. - -### ~~`Tools.GenerateKeyImage` leaks `SolidBrush`~~ **FIXED** -- **Severity**: HIGH -- **File**: `barraider-sdtools/Tools/Tools.cs` -- **Fix**: Wrapped `SolidBrush` in a `using` block. - -### ~~`Tools.AutoLoadPluginActions` does not null-check `Assembly.GetEntryAssembly()`~~ **FIXED** -- **Severity**: HIGH -- **File**: `barraider-sdtools/Tools/Tools.cs` -- **Fix**: Added null-check with logging; returns empty array when entry assembly is null. - -### ~~`SDConnection.previousImageHash` is not thread-safe~~ **FIXED** -- **Severity**: MEDIUM -- **File**: `barraider-sdtools/Backend/SDConnection.cs` -- **Fix**: Added `_imageHashLock` with `lock()` around hash comparison/assignment in all `SetImageAsync` overloads. `await` calls remain outside the lock. - -### ~~`SDConnection.Dispose()` does not null-check `StreamDeckConnection`~~ **FIXED** -- **Severity**: MEDIUM -- **File**: `barraider-sdtools/Backend/SDConnection.cs` -- **Fix**: Added null-check guard at the start of `Dispose()`. - -### ~~`StreamDeckConnection.OpenUrlAsync(string)` does not validate input~~ **FIXED** -- **Severity**: MEDIUM -- **File**: `barraider-sdtools/Communication/StreamDeckConnection.cs` -- **Fix**: Added null/empty check that throws `ArgumentNullException`. - -### ~~`SDConnection` title-change retry has no limit~~ **FIXED** -- **Severity**: MEDIUM -- **File**: `barraider-sdtools/Backend/SDConnection.cs` -- **Fix**: Added `MAX_TITLE_RETRY_ATTEMPTS = 5` constant and `_titleRetryCount` field. Counter-based limit replaces the fragile `sender != this` pattern. - -### ~~`SystemDrawingImageCodec.DecodeFromBytes` catch block does not log~~ **FIXED** -- **Severity**: MEDIUM -- **File**: `barraider-sdtools/Internal/SystemDrawingImageCodec.cs` -- **Fix**: Added `Logger.Instance.LogMessage` call in the catch block before rethrow. - -### ~~Unused `using NLog.Layouts` in `SDConnection.cs`~~ **FIXED** -- **Severity**: LOW -- **File**: `barraider-sdtools/Backend/SDConnection.cs` -- **Fix**: Removed unused import. - -### ~~`CancellationTokenSource` not disposed in `StreamDeckConnection`~~ **FIXED** -- **Severity**: LOW -- **File**: `barraider-sdtools/Communication/StreamDeckConnection.cs` -- **Fix**: Added `cancelTokenSource.Dispose()` in `DisconnectAsync()` after cleanup. - -### ~~`Tools.AutoPopulateSettings` lacks type-conversion error handling~~ **FIXED** -- **Severity**: LOW -- **File**: `barraider-sdtools/Tools/Tools.cs` -- **Fix**: Wrapped `Convert.ChangeType` in try/catch; logs error and continues to next property. - -### ~~`Tools.FilenameFromPayload` does not validate JToken type~~ **FIXED** -- **Severity**: LOW -- **File**: `barraider-sdtools/Tools/Tools.cs` -- **Fix**: Added null/type checks; uses `payload.ToString()` instead of explicit cast. - -### ~~`Tools.BytesToSHA512` does not null-check input~~ **ALREADY FIXED** -- **Severity**: LOW -- **File**: `barraider-sdtools/Tools/Tools.cs` -- **Note**: The null-check already exists in the current code. No change needed. - -### ~~`ExtensionMethods.AddTextPath` leaks `Pen`, `GraphicsPath`, and `SolidBrush`~~ **FIXED** -- **Severity**: LOW -- **File**: `barraider-sdtools/Tools/ExtensionMethods.cs` -- **Fix**: Wrapped `Font`, `Pen`, `GraphicsPath`, and `SolidBrush` in `using` blocks. - -### ~~`ExtensionMethods.SplitToFitKey` leaks `Font`~~ **FIXED** -- **Severity**: LOW -- **File**: `barraider-sdtools/Tools/ExtensionMethods.cs` -- **Fix**: Wrapped `Font` in a `using` block. From 5dffc22c2fcf7d064a7c12d33a3698bbd9d6a0c3 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Sat, 7 Mar 2026 14:42:15 +0200 Subject: [PATCH 19/32] Support .Net 10 --- .github/workflows/dotnet.yml | 22 +++++------ README.md | 13 +++---- barraider-sdtools/barraider-sdtools.csproj | 43 ++++------------------ 3 files changed, 24 insertions(+), 54 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 2ea2c13..994080f 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,7 +1,7 @@ # Build and validate StreamDeck-Tools across all supported TFMs and platforms. # netstandard2.0 builds on all platforms with any .NET SDK. # net48 is Windows-only (requires .NET Framework targeting pack). -# net8.0 and net9.0 build on Windows, macOS, and Linux. +# net8.0 and net10.0 build on Windows, macOS, and Linux. name: .NET @@ -13,7 +13,7 @@ on: jobs: build-windows: - name: Windows (netstandard2.0 + net48 + net8.0 + net9.0) + name: Windows (netstandard2.0 + net48 + net8.0 + net10.0) runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -22,7 +22,7 @@ jobs: with: dotnet-version: | 8.0.x - 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore - name: Build @@ -31,7 +31,7 @@ jobs: run: dotnet test --no-build --configuration Release --verbosity normal build-macos: - name: macOS (netstandard2.0 + net8.0 + net9.0) + name: macOS (netstandard2.0 + net8.0 + net10.0) runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -40,16 +40,16 @@ jobs: with: dotnet-version: | 8.0.x - 9.0.x + 10.0.x - name: Build netstandard2.0 run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework netstandard2.0 - name: Build net8.0 run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net8.0 - - name: Build net9.0 - run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net9.0 + - name: Build net10.0 + run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net10.0 build-linux: - name: Linux (netstandard2.0 + net8.0 + net9.0) + name: Linux (netstandard2.0 + net8.0 + net10.0) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -58,10 +58,10 @@ jobs: with: dotnet-version: | 8.0.x - 9.0.x + 10.0.x - name: Build netstandard2.0 run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework netstandard2.0 - name: Build net8.0 run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net8.0 - - name: Build net9.0 - run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net9.0 + - name: Build net10.0 + run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net10.0 diff --git a/README.md b/README.md index b7943db..aa1b434 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ #### C# library that wraps all the communication with the Stream Deck App, allowing you to focus on actually writing the Plugin's logic. -[![Build Status](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnetcore.yml/badge.svg)](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnetcore.yml)  [![NuGet](https://img.shields.io/nuget/v/streamdeck-tools.svg?style=flat)](https://www.nuget.org/packages/streamdeck-tools) +[![Build Status](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnet.yml/badge.svg)](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnet.yml)  [![NuGet](https://img.shields.io/nuget/v/streamdeck-tools.svg?style=flat)](https://www.nuget.org/packages/streamdeck-tools) **Author's website and contact information:** [https://barraider.com](https://barraider.com) @@ -43,13 +43,12 @@ Introducing our new [wiki](https://github.com/BarRaider/streamdeck-tools/wiki) p # Change Log -### Version 7.0 (Beta) -- See [Version 7.0 now in Beta!](#version-70-now-in-beta) above +### Version 7.0 (prerelease) +- Multi-target support: .NET Standard 2.0, .NET Framework 4.8, .NET 8.0, .NET 10.0 +- SkiaSharp cross-platform graphics (replaces System.Drawing on non-Windows) +- System.Drawing APIs marked `[Obsolete]` with SkiaSharp replacements -### Version 6.4 -- Support for Stream Deck Plus XL, Galleon 100 SD - -### Version 6.3 +### Version 6.3.1 - Support for new Stream Deck types ### Version 6.2 diff --git a/barraider-sdtools/barraider-sdtools.csproj b/barraider-sdtools/barraider-sdtools.csproj index c5f9ddd..f5fd164 100644 --- a/barraider-sdtools/barraider-sdtools.csproj +++ b/barraider-sdtools/barraider-sdtools.csproj @@ -1,6 +1,6 @@ - netstandard2.0;net48;net8.0;net9.0 + netstandard2.0;net48;net8.0;net10.0 true BarRaider Stream Deck Tools by BarRaider @@ -17,8 +17,9 @@ Feel free to contact me for more information: https://barraider.com 7.0.0.0 7.0.0.0 - 7.0.0-beta.1 - 7.0.0-beta.1 - Major migration: netstandard2.0/net48/net8.0/net9.0 multi-targeting, SkiaSharp cross-platform graphics, System.Drawing APIs marked [Obsolete] with SkiaSharp replacements. + 7.0.0-beta.2 + 7.0.0-beta.2 - Replace net9.0 (STS, EOL May 2026) with net10.0 (LTS). Simplify csproj. Targets: netstandard2.0/net48/net8.0/net10.0. +7.0.0-beta.1 - Major migration: netstandard2.0/net48/net8.0 multi-targeting, SkiaSharp cross-platform graphics, System.Drawing APIs marked [Obsolete] with SkiaSharp replacements. NOTE: This is a prerelease. See docs/MIGRATION.md for upgrade guidance. BarRaider.SdTools StreamDeckTools @@ -26,39 +27,9 @@ NOTE: This is a prerelease. See docs/MIGRATION.md for upgrade guidance.README.md LICENSE - - - 1701;1702;CA1416 - - - streamdeck-tools.xml - 1701;1702;CA1416 - - - - 1701;1702;CA1416 - - - - - - streamdeck-tools.xml - 1701;1702;CA1416 - - - streamdeck-tools.xml - - - 1701;1702;CA1416 - - - 1701;1702;CA1416 - - - 1701;1702;CA1416 - - + 1701;1702;CA1416 + streamdeck-tools.xml @@ -66,7 +37,7 @@ NOTE: This is a prerelease. See docs/MIGRATION.md for upgrade guidance. - + From be0f83aabf6d87e8ce16b74f8b01da92fe9bc793 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:53:43 +0200 Subject: [PATCH 20/32] Fix GetExeName() and bump version to 7.0.0-beta.3 Fix GetExeName() to correctly strip .exe extension on all platforms instead of using GetFileNameWithoutExtension which strips any extension. Bump NuGet package version to 7.0.0-beta.3. Made-with: Cursor --- barraider-sdtools/Tools/Tools.cs | 7 ++++++- barraider-sdtools/barraider-sdtools.csproj | 6 ++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/barraider-sdtools/Tools/Tools.cs b/barraider-sdtools/Tools/Tools.cs index 64a62fb..7b6b8eb 100644 --- a/barraider-sdtools/Tools/Tools.cs +++ b/barraider-sdtools/Tools/Tools.cs @@ -516,7 +516,12 @@ internal static string GetExeName() { try { - return System.IO.Path.GetFileNameWithoutExtension(System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName); + string fileName = System.IO.Path.GetFileName(System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName); + if (fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + { + fileName = fileName.Substring(0, fileName.Length - 4); + } + return fileName; } catch (Exception ex) { diff --git a/barraider-sdtools/barraider-sdtools.csproj b/barraider-sdtools/barraider-sdtools.csproj index f5fd164..e02a5b0 100644 --- a/barraider-sdtools/barraider-sdtools.csproj +++ b/barraider-sdtools/barraider-sdtools.csproj @@ -17,10 +17,8 @@ Feel free to contact me for more information: https://barraider.com 7.0.0.0 7.0.0.0 - 7.0.0-beta.2 - 7.0.0-beta.2 - Replace net9.0 (STS, EOL May 2026) with net10.0 (LTS). Simplify csproj. Targets: netstandard2.0/net48/net8.0/net10.0. -7.0.0-beta.1 - Major migration: netstandard2.0/net48/net8.0 multi-targeting, SkiaSharp cross-platform graphics, System.Drawing APIs marked [Obsolete] with SkiaSharp replacements. -NOTE: This is a prerelease. See docs/MIGRATION.md for upgrade guidance. + 7.0.0-beta.3 + 7.0.0-beta.3 - Major migration: netstandard2.0/net48/net8.0/net10.0 (LTS) multi-targeting, SkiaSharp cross-platform graphics, System.Drawing APIs marked [Obsolete] with SkiaSharp replacements. See migration instructions in the Github Readme. BarRaider.SdTools StreamDeckTools BRLogo_460.png From 8b517ee169c6392a176b5c351f47b76fddeaeb81 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:59:27 +0200 Subject: [PATCH 21/32] Update readme --- README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index aa1b434..58a7a3c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ #### C# library that wraps all the communication with the Stream Deck App, allowing you to focus on actually writing the Plugin's logic. -[![Build Status](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnet.yml/badge.svg)](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnet.yml)  [![NuGet](https://img.shields.io/nuget/v/streamdeck-tools.svg?style=flat)](https://www.nuget.org/packages/streamdeck-tools) +[![Build Status](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnetcore.yml/badge.svg)](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnetcore.yml)  [![NuGet](https://img.shields.io/nuget/v/streamdeck-tools.svg?style=flat)](https://www.nuget.org/packages/streamdeck-tools) **Author's website and contact information:** [https://barraider.com](https://barraider.com) @@ -43,12 +43,10 @@ Introducing our new [wiki](https://github.com/BarRaider/streamdeck-tools/wiki) p # Change Log -### Version 7.0 (prerelease) -- Multi-target support: .NET Standard 2.0, .NET Framework 4.8, .NET 8.0, .NET 10.0 -- SkiaSharp cross-platform graphics (replaces System.Drawing on non-Windows) -- System.Drawing APIs marked `[Obsolete]` with SkiaSharp replacements +### Version 6.4 +- Support for Stream Deck Plus XL, Galleon 100 SD -### Version 6.3.1 +### Version 6.3 - Support for new Stream Deck types ### Version 6.2 From 58e924e40022a1d950ba86abc6b22b3f39c9d78e Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Sun, 8 Mar 2026 01:21:40 +0200 Subject: [PATCH 22/32] fix: remove GDI+ dependency from TitleParameters constructor for macOS compatibility Made-with: Cursor --- barraider-sdtools/Wrappers/TitleParameters.cs | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/barraider-sdtools/Wrappers/TitleParameters.cs b/barraider-sdtools/Wrappers/TitleParameters.cs index 22040db..77a5e5a 100644 --- a/barraider-sdtools/Wrappers/TitleParameters.cs +++ b/barraider-sdtools/Wrappers/TitleParameters.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Drawing; +using System.Runtime.InteropServices; using System.Text; namespace BarRaider.SdTools.Wrappers @@ -64,10 +65,38 @@ public class TitleParameters public double FontSizeInPixelsScaledToDefaultImage => Math.Round(FontSizeInPixels * DEFAULT_IMAGE_SIZE_FONT_SCALE); /// - /// Font Family + /// Font family name as a plain string. Cross-platform replacement for FontFamily. /// - [JsonProperty("fontFamily")] - public FontFamily FontFamily { get; private set; } = new FontFamily(DEFAULT_FONT_FAMILY_NAME); + [JsonIgnore] + public string FontFamilyName { get; private set; } = DEFAULT_FONT_FAMILY_NAME; + + private FontFamily cachedFontFamily; + + /// + /// Font Family. Windows-only; throws PlatformNotSupportedException on macOS/Linux. + /// Use or for cross-platform code. + /// + [Obsolete("Use FontFamilyName (string) or TitleTypeface (SKTypeface) for cross-platform code.")] + [JsonIgnore] + public FontFamily FontFamily + { + get + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + throw new PlatformNotSupportedException( + "FontFamily requires GDI+ which is not available on this platform. " + + "Use FontFamilyName (string) or TitleTypeface (SKTypeface) instead."); + + if (cachedFontFamily == null) + cachedFontFamily = new FontFamily(FontFamilyName); + return cachedFontFamily; + } + private set + { + cachedFontFamily = value; + FontFamilyName = value?.Name ?? DEFAULT_FONT_FAMILY_NAME; + } + } /// /// Font Style @@ -99,7 +128,7 @@ public class TitleParameters /// /// Font as an SKTypeface for cross-platform SkiaSharp rendering. - /// Computed and cached from the existing FontFamily property. + /// Computed and cached from . /// The returned typeface is owned by this TitleParameters instance. /// [JsonIgnore] @@ -109,7 +138,7 @@ public SKTypeface TitleTypeface { if (cachedTypeface == null) { - string familyName = FontFamily?.Name ?? DEFAULT_FONT_FAMILY_NAME; + string familyName = FontFamilyName ?? DEFAULT_FONT_FAMILY_NAME; cachedTypeface = SKTypeface.FromFamilyName(familyName, FontStyleToSKFontStyle()); } return cachedTypeface; @@ -172,15 +201,19 @@ private void ParsePayload(string fontFamily, uint fontSize, string fontStyle, bo { ShowTitle = showTitle; - // Color if (!String.IsNullOrEmpty(titleColor)) { - TitleColor = ColorTranslator.FromHtml(titleColor); + if (SKColor.TryParse(titleColor, out SKColor skColor)) + { + TitleColor = Color.FromArgb(skColor.Alpha, skColor.Red, skColor.Green, skColor.Blue); + } } if (!String.IsNullOrEmpty(fontFamily)) { - FontFamily = new FontFamily(fontFamily); + FontFamilyName = fontFamily; + cachedFontFamily = null; + cachedTypeface = null; } FontSizeInPoints = fontSize; From 70d71a48354c436897a23bc8fd18307b23e19a2e Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Sun, 8 Mar 2026 01:35:27 +0200 Subject: [PATCH 23/32] docs: add v7.0 changelog with FontFamilyName breaking change note Made-with: Cursor --- README.md | 10 +++++ barraider-sdtools/README.md | 87 +++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 barraider-sdtools/README.md diff --git a/README.md b/README.md index 58a7a3c..6848413 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,16 @@ Introducing our new [wiki](https://github.com/BarRaider/streamdeck-tools/wiki) p # Change Log +### Version 7.0 (Beta) +- **Cross-platform support**: New SkiaSharp-based API surface (`SkiaTools`, `SkiaGraphicsTools`, `SkiaExtensionMethods`) for Windows + macOS. +- **Target frameworks**: netstandard2.0, net48, net8.0, net10.0. +- All `System.Drawing`-based APIs marked `[Obsolete]` with migration guidance. +- New `SetImageAsync(SKBitmap)` and `SetImageAsync(byte[])` overloads on `ISDConnection`. +- New `DrawTextLine` extension method on `SKCanvas` with Y-as-top semantics (matching `System.Drawing.Graphics.DrawString`). +- **Breaking change (macOS):** `TitleParameters.FontFamily` now throws `PlatformNotSupportedException` on non-Windows. Use `TitleParameters.FontFamilyName` (string) or `TitleParameters.TitleTypeface` (SKTypeface) instead. On Windows, `FontFamily` still works but produces an `[Obsolete]` compiler warning. +- New `TitleParameters.FontFamilyName` property: cross-platform string replacement for `FontFamily.Name`. +- `TitleParameters.TitleSKColor`, `TitleParameters.TitleTypeface`, `TitleParameters.FontStyleToSKFontStyle()` added for cross-platform rendering. + ### Version 6.4 - Support for Stream Deck Plus XL, Galleon 100 SD diff --git a/barraider-sdtools/README.md b/barraider-sdtools/README.md new file mode 100644 index 0000000..57db0bf --- /dev/null +++ b/barraider-sdtools/README.md @@ -0,0 +1,87 @@ +# BarRaider's Stream Deck Tools + +#### C# library that wraps all the communication with the Stream Deck App, allowing you to focus on actually writing the Plugin's logic. + +[![Build Status](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnetcore.yml/badge.svg)](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnetcore.yml)  [![NuGet](https://img.shields.io/nuget/v/streamdeck-tools.svg?style=flat)](https://www.nuget.org/packages/streamdeck-tools) + +**Author's website and contact information:** [https://barraider.com](https://barraider.com) + +# Stream Deck+ Support +Instead of `PluginBase`, Derive from either `KeypadBase` (if you don't support dials), `EncoderBase` (for only dials), `KeyAndEncoderBase` (for both keys and dials) + +# Getting Started +Introducing our new [wiki](https://github.com/BarRaider/streamdeck-tools/wiki) packed with usage instructions, examples and more. + +# Dev Discussions / Support +**Discord:** Discuss in #developers-chat in [Bar Raiders](http://discord.barraider.com) + +## Downloadable Resources +* [StreamDeck-Tools Template](https://github.com/BarRaider/streamdeck-tools/raw/master/utils/StreamDeck-Tools%20Template.vsix) for Visual Studio (2019/2022) - Automatically creates a project with all the files needed to compile a plugin. This is the best way to start a new plugin! +* [Install.bat](https://github.com/BarRaider/streamdeck-tools/blob/master/utils/install.bat) - Script that quickly uninstalls and reinstalls your plugin on the streamdeck (edit the batch file for more details). Put the install.bat file in your BIN folder (same folder that has Debug/Release sub-folders) +* [EasyPI](https://github.com/BarRaider/streamdeck-easypi) - Additional library used to easily pass information from the PI (Property Inspector) to your plugin. +* [Profiles](https://barraider.com/profiles) Downloadable empty profiles for the XL (32-key), Classic (15-key), Mini (6-key) and Mobile devices at https://barraider.com/profiles + +## Library Features +- Encapsulates all the communicating with the Stream Deck, getting a plugin working on the Stream Deck only requires implementing the PluginBase class. +- Sample plugin now included in this project on Github +- Built-in integration with NLog. Use `Logger.LogMessage()` for logging. +- Auto-populate user settings which were modified by the Property Inspector +- Access the Global Settings from anywhere in your code +- Simplified working with filenames from the Stream Deck SDK. +- `PluginActionId` attribute let's you easily associate your code to a specific action defined in the manifest.json +- Large set of helper functions to simplify creating images and sending them to the Stream Deck. + +# Change Log + +### Version 6.4 +- Support for Stream Deck Plus XL, Galleon 100 SD + +### Version 6.3 +- Support for new Stream Deck types + +### Version 6.2 +- Support for .NET 8.0 + +### Version 6.1 +- Support for new `DialDown` and `DialUp` events. +- Removed support for deprecated `DialPress` event + +### Version 6.0 +1. Merged streamdeck-client-csharp package into library to allow better logging of errors +2. Added support for SD+ SDK +3. Increased timeout of connection to Stream Deck due to the Stream Deck taking longer than before to reply on load +4. Added error catching to prevent 3rd party plugin exception to impact communication + + +### Version 3.2 is out! +- Created new `ISDConnection` interface which is now implemented by SDConnection and used by PluginAction. +- GlobalSettingsManager now has a short delay before calling GetGlobalSettings(), to reduce spamming the Stream Deck SDK. +- Updated dependencies to latest version + +### Version 3.1 is out! +- Updated Logger class to include process name and thread id + +### Version 3.0 is out! +- Updated file handling in `Tools.AutoPopulateSettings` and `Tools.FilenameFromPayload` methods +- Removed obsolete MD5 functions, use SHA512 functions instead +- `Tools.CenterText` function now has optional out `textFitsImage` value to verify the text does not exceed the image width +- New `Tools.FormatBytes` function converts bytes to human-readable value +- New `Graphics.GetFontSizeWhereTextFitsImage` function helps locate the best size for a text to fit an image on 1 line +- Updated dependency packages to latest versions +- Bug fix where FileNameProperty attribute + +### Version 2.7 is out! +- Fully wrapped all Stream Deck events (All part of the SDConneciton class). See ***"Subscribing to events"*** section below +- Added extension methods for multiple classes related to brushes/colors +- Added additional methods under the Tools class, including AddTextPathToGraphics which can be used to correctly position text on a key image based on the Text Settings in the Property Inspector see ***"Showing Title based on settings from Property Inspector"*** section below. +- Additional error checking +- Updated dependency packages to latest versions +- Sample plugin now included in this project on Github + +### 2019-11-17 +- Updated Install.bat (above) to newer version + +### Version 2.6 is out! +- Added new MD5 functions in the `Tools` helper class +- Optimized SetImage to not resubmit an image that was just posted to the device. Can be overridden with new property in Connection.SetImage() function. + From 5d4878c3d09f795a21eaa4f509c325f795d02663 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Sun, 8 Mar 2026 01:39:30 +0200 Subject: [PATCH 24/32] feat: add DrawTextLine with Y-as-top semantics, bump to 7.0.0-beta.4 Made-with: Cursor --- .../Tools/SkiaExtensionMethods.cs | 30 ++++++++++++++----- barraider-sdtools/barraider-sdtools.csproj | 4 +-- barraider-sdtools/streamdeck-tools.xml | 30 +++++++++++++++---- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/barraider-sdtools/Tools/SkiaExtensionMethods.cs b/barraider-sdtools/Tools/SkiaExtensionMethods.cs index 0ac2716..8f1b39a 100644 --- a/barraider-sdtools/Tools/SkiaExtensionMethods.cs +++ b/barraider-sdtools/Tools/SkiaExtensionMethods.cs @@ -42,20 +42,36 @@ public static string ToBase64(this SKBitmap bitmap, bool addHeaderPrefix) #region SKCanvas Extensions /// - /// Draws a string on an SKCanvas and returns the ending Y position. + /// Draws a line of text treating the Y coordinate as the top of the text box + /// (matching System.Drawing.Graphics.DrawString behavior) and returns the Y + /// position for the next line, based on the font's recommended line spacing. /// /// The canvas to draw on. /// The text to draw. /// The font for text rendering. /// The paint for color/style. - /// The position to draw at (X, Y baseline). - /// The Y position below the drawn text. + /// The position to draw at (X = left edge, Y = top of text). + /// The Y coordinate for the top of the next line. + public static float DrawTextLine(this SKCanvas canvas, string text, SKFont font, SKPaint paint, SKPoint position) + { + float baseline = position.Y + Math.Abs(font.Metrics.Ascent); + canvas.DrawText(text, position.X, baseline, font, paint); + return position.Y + font.Spacing; + } + + /// + /// Draws a string on an SKCanvas and returns the Y position for the next line. + /// Equivalent to . Position Y is the top of the text, not the baseline. + /// + /// The canvas to draw on. + /// The text to draw. + /// The font for text rendering. + /// The paint for color/style. + /// The position to draw at (X = left edge, Y = top of text). + /// The Y coordinate for the top of the next line. public static float DrawAndMeasureString(this SKCanvas canvas, string text, SKFont font, SKPaint paint, SKPoint position) { - SKRect bounds = new SKRect(); - font.MeasureText(text, out bounds, paint); - canvas.DrawText(text, position.X, position.Y, font, paint); - return position.Y + bounds.Height; + return canvas.DrawTextLine(text, font, paint, position); } /// diff --git a/barraider-sdtools/barraider-sdtools.csproj b/barraider-sdtools/barraider-sdtools.csproj index e02a5b0..54b24dc 100644 --- a/barraider-sdtools/barraider-sdtools.csproj +++ b/barraider-sdtools/barraider-sdtools.csproj @@ -17,8 +17,8 @@ Feel free to contact me for more information: https://barraider.com 7.0.0.0 7.0.0.0 - 7.0.0-beta.3 - 7.0.0-beta.3 - Major migration: netstandard2.0/net48/net8.0/net10.0 (LTS) multi-targeting, SkiaSharp cross-platform graphics, System.Drawing APIs marked [Obsolete] with SkiaSharp replacements. See migration instructions in the Github Readme. + 7.0.0-beta.4 + 7.0.0-beta.4 - Fix DrawAndMeasureString Y-positioning to match System.Drawing semantics (Y = top of text, not baseline). Add DrawTextLine as canonical replacement with correct line spacing via font.Spacing. BarRaider.SdTools StreamDeckTools BRLogo_460.png diff --git a/barraider-sdtools/streamdeck-tools.xml b/barraider-sdtools/streamdeck-tools.xml index fe10a18..bc554b9 100644 --- a/barraider-sdtools/streamdeck-tools.xml +++ b/barraider-sdtools/streamdeck-tools.xml @@ -2722,16 +2722,30 @@ Whether to prepend the data URI header. Base64-encoded PNG string. + + + Draws a line of text treating the Y coordinate as the top of the text box + (matching System.Drawing.Graphics.DrawString behavior) and returns the Y + position for the next line, based on the font's recommended line spacing. + + The canvas to draw on. + The text to draw. + The font for text rendering. + The paint for color/style. + The position to draw at (X = left edge, Y = top of text). + The Y coordinate for the top of the next line. + - Draws a string on an SKCanvas and returns the ending Y position. + Draws a string on an SKCanvas and returns the Y position for the next line. + Equivalent to . Position Y is the top of the text, not the baseline. The canvas to draw on. The text to draw. The font for text rendering. The paint for color/style. - The position to draw at (X, Y baseline). - The Y position below the drawn text. + The position to draw at (X = left edge, Y = top of text). + The Y coordinate for the top of the next line. @@ -3225,9 +3239,15 @@ Font Size Scaled to Image + + + Font family name as a plain string. Cross-platform replacement for FontFamily. + + - Font Family + Font Family. Windows-only; throws PlatformNotSupportedException on macOS/Linux. + Use or for cross-platform code. @@ -3254,7 +3274,7 @@ Font as an SKTypeface for cross-platform SkiaSharp rendering. - Computed and cached from the existing FontFamily property. + Computed and cached from . The returned typeface is owned by this TitleParameters instance. From 31b752195d50fb94e08967e51cbe3056e403b6f8 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:45:47 +0200 Subject: [PATCH 25/32] fix: correct AddTextPath text positioning and multi-line rendering - Scale font size by 72/imageWidth to match System.Drawing's GraphicsPath.AddString - Add baseline-to-top-left offset using font ascent (DrawText uses baseline, not top-left) - Handle newline characters from SplitToFitKey by drawing each line separately - Fix horizontal centering by removing pixelsAlignment from X calculation - Add null/empty guard for text parameter - Correct bottom and middle vertical alignment using actual font metrics Made-with: Cursor --- .../Tools/SkiaExtensionMethods.cs | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/barraider-sdtools/Tools/SkiaExtensionMethods.cs b/barraider-sdtools/Tools/SkiaExtensionMethods.cs index 8f1b39a..a79e0fb 100644 --- a/barraider-sdtools/Tools/SkiaExtensionMethods.cs +++ b/barraider-sdtools/Tools/SkiaExtensionMethods.cs @@ -176,28 +176,41 @@ public static void AddTextPath(this SKCanvas canvas, TitleParameters titleParame return; } + if (string.IsNullOrEmpty(text)) + { + return; + } + float fontSize = (float)titleParameters.FontSizeInPixelsScaledToDefaultImage; + // Match System.Drawing's GraphicsPath.AddString scaling: emSize = DpiY * SizeInPoints / imageWidth + // which simplifies to fontSize * 72 / imageWidth (since SizeInPoints = fontSize * 72 / DpiY). + float scaledFontSize = fontSize * 72.0f / imageWidth; var typeface = titleParameters.TitleTypeface; var color = titleParameters.TitleSKColor; - using (var font = new SKFont(typeface, fontSize)) + using (var font = new SKFont(typeface, scaledFontSize)) { - float textWidth = font.MeasureText(text); + // SKCanvas.DrawText uses the text baseline for Y, while the old + // GraphicsPath.AddString used the top-left corner. Shift Y down + // by the ascent so the visual position matches the old behavior. + float ascent = -font.Metrics.Ascent; + + string[] lines = text.Split('\n'); + float lineSpacing = font.Spacing; + float totalTextHeight = ascent + font.Metrics.Descent + (lines.Length - 1) * lineSpacing; - int stringWidth = 0; - if (textWidth < imageWidth) + float startY; + if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Bottom) { - stringWidth = (int)(Math.Abs(imageWidth - textWidth) / 2) - pixelsAlignment; + startY = imageHeight - pixelsAlignment - totalTextHeight + ascent; } - - int stringHeight = pixelsAlignment; - if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Middle) + else if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Middle) { - stringHeight = (imageHeight / 2) - pixelsAlignment; + startY = (imageHeight - totalTextHeight) / 2f + ascent; } - else if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Bottom) + else { - stringHeight = (int)(Math.Abs(imageHeight - fontSize) - pixelsAlignment); + startY = pixelsAlignment + ascent; } using (var strokePaint = new SKPaint @@ -207,10 +220,6 @@ public static void AddTextPath(this SKCanvas canvas, TitleParameters titleParame StrokeWidth = strokeThickness, IsAntialias = true }) - { - canvas.DrawText(text, stringWidth, stringHeight, font, strokePaint); - } - using (var fillPaint = new SKPaint { Color = color, @@ -218,7 +227,20 @@ public static void AddTextPath(this SKCanvas canvas, TitleParameters titleParame IsAntialias = true }) { - canvas.DrawText(text, stringWidth, stringHeight, font, fillPaint); + float y = startY; + foreach (string line in lines) + { + float textWidth = font.MeasureText(line); + float stringWidth = 0; + if (textWidth < imageWidth) + { + stringWidth = (imageWidth - textWidth) / 2f; + } + + canvas.DrawText(line, stringWidth, y, font, strokePaint); + canvas.DrawText(line, stringWidth, y, font, fillPaint); + y += lineSpacing; + } } } } From 1cd3bc91daa9ede902df3050f6b44dce794cb0a7 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:57:59 +0200 Subject: [PATCH 26/32] fix: add correct key sizes for Neo and Plus XL in GetKeyDefaultHeight/GetKeyDefaultWidth Made-with: Cursor --- barraider-sdtools/Tools/Tools.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/barraider-sdtools/Tools/Tools.cs b/barraider-sdtools/Tools/Tools.cs index 7b6b8eb..b1a3420 100644 --- a/barraider-sdtools/Tools/Tools.cs +++ b/barraider-sdtools/Tools/Tools.cs @@ -26,6 +26,10 @@ public static class Tools private const int CLASSIC_KEY_DEFAULT_WIDTH = 72; private const int PLUS_KEY_DEFAULT_HEIGHT = 144; private const int PLUS_KEY_DEFAULT_WIDTH = 144; + private const int PLUS_XL_KEY_DEFAULT_HEIGHT = 144; + private const int PLUS_XL_KEY_DEFAULT_WIDTH = 144; + private const int NEO_KEY_DEFAULT_HEIGHT = 96; + private const int NEO_KEY_DEFAULT_WIDTH = 96; private const int XL_KEY_DEFAULT_HEIGHT = 96; private const int XL_KEY_DEFAULT_WIDTH = 96; private const int GENERIC_KEY_IMAGE_SIZE = 144; @@ -151,12 +155,15 @@ public static int GetKeyDefaultHeight(DeviceType streamDeckType) case DeviceType.StreamDeckClassic: case DeviceType.StreamDeckMini: case DeviceType.StreamDeckMobile: - case DeviceType.StreamDeckNeo: return CLASSIC_KEY_DEFAULT_HEIGHT; + case DeviceType.StreamDeckNeo: + return NEO_KEY_DEFAULT_HEIGHT; case DeviceType.StreamDeckXL: return XL_KEY_DEFAULT_HEIGHT; case DeviceType.StreamDeckPlus: return PLUS_KEY_DEFAULT_HEIGHT; + case DeviceType.StreamDeckPlusXL: + return PLUS_XL_KEY_DEFAULT_HEIGHT; default: Logger.Instance.LogMessage(TracingLevel.ERROR, $"SDTools GetKeyDefaultHeight Error: Invalid StreamDeckDeviceType: {streamDeckType}"); break; @@ -177,12 +184,15 @@ public static int GetKeyDefaultWidth(DeviceType streamDeckType) case DeviceType.StreamDeckClassic: case DeviceType.StreamDeckMini: case DeviceType.StreamDeckMobile: - case DeviceType.StreamDeckNeo: return CLASSIC_KEY_DEFAULT_WIDTH; + case DeviceType.StreamDeckNeo: + return NEO_KEY_DEFAULT_WIDTH; case DeviceType.StreamDeckXL: return XL_KEY_DEFAULT_WIDTH; case DeviceType.StreamDeckPlus: return PLUS_KEY_DEFAULT_WIDTH; + case DeviceType.StreamDeckPlusXL: + return PLUS_XL_KEY_DEFAULT_WIDTH; default: Logger.Instance.LogMessage(TracingLevel.ERROR, $"SDTools GetKeyDefaultHeight Error: Invalid StreamDeckDeviceType: {streamDeckType}"); break; From 03c85a87c75cc24d5c3d16ec3792d047cc2d23a6 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:28:16 +0300 Subject: [PATCH 27/32] Migration instructions --- MIGRATION.md | 469 +++++++++++++++++++++ NUGET.md | 117 ++--- README.md | 28 +- barraider-sdtools/README.md | 87 ---- barraider-sdtools/barraider-sdtools.csproj | 6 +- 5 files changed, 534 insertions(+), 173 deletions(-) create mode 100644 MIGRATION.md delete mode 100644 barraider-sdtools/README.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..e839376 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,469 @@ +# StreamDeck-Tools v7.0 Migration Guide + +Migration guide for upgrading from `StreamDeck-Tools` v6.x to v7.0, including the new cross-platform SkiaSharp API surface. + +--- + +## What Changed in v7.0 + +### Target Frameworks + +| Before (v6.x) | After (v7.0) | +|---|---| +| `netstandard2.0` + `net48` | `netstandard2.0` + `net48` + `net8.0` + `net10.0` | + +The library retains `netstandard2.0` for broad compatibility and `net48` for .NET Framework consumers. `net8.0` (LTS) and `net10.0` (current) are added for modern .NET. `net9.0` is not included (EOL May 2026). + +### New Dependency: SkiaSharp 3.119.2 + +The library now ships with [SkiaSharp](https://github.com/mono/SkiaSharp) (MIT license) as a cross-platform graphics backend. SkiaSharp works on Windows, macOS, and Linux without GDI+. + +### Dual API Surface + +Every public method that accepted or returned `System.Drawing` types now has a SkiaSharp equivalent in a parallel class: + +| Legacy Class | SkiaSharp Replacement | +|---|---| +| `Tools` | `SkiaTools` | +| `GraphicsTools` | `SkiaGraphicsTools` | +| `ExtensionMethods` (on `Image`/`Graphics`) | `SkiaExtensionMethods` (on `SKBitmap`/`SKCanvas`) | + +### System.Drawing APIs Marked `[Obsolete]` + +All public methods using `System.Drawing` types are now marked `[Obsolete]` with messages pointing to their SkiaSharp replacements. They still compile and work on Windows but will **not** work on macOS/Linux with .NET 8+. + +### New `SetImageAsync` Overloads + +`ISDConnection` and `SDConnection` now support: + +| Method | Status | Platform | +|---|---|---| +| `SetImageAsync(SKBitmap, ...)` | **New** | Cross-platform | +| `SetImageAsync(byte[], ...)` | **New** | Cross-platform (raw PNG bytes) | +| `SetImageAsync(string, ...)` | Unchanged | Cross-platform (base64) | +| `SetImageAsync(Image, ...)` | `[Obsolete]` | Windows-only | + +### TitleParameters Changes + +`TitleParameters` retains its existing constructors and properties unchanged. New read-only SkiaSharp properties are added: + +| Property / Method | Type | Description | +|---|---|---| +| `FontFamilyName` | `string` | Cross-platform font family name. Replaces `FontFamily.Name`. | +| `TitleSKColor` | `SKColor` | Derived from `TitleColor`. | +| `TitleTypeface` | `SKTypeface` | Derived from `FontFamilyName` + `FontStyle`. Cached. | +| `FontStyleToSKFontStyle()` | `SKFontStyle` | Converts `System.Drawing.FontStyle` to `SKFontStyle`. | + +> **Breaking change (macOS):** `TitleParameters.FontFamily` (the `System.Drawing.FontFamily` property) is `[Obsolete]` and **throws `PlatformNotSupportedException`** on non-Windows. This is a hard crash, not just a warning. Any code that accesses `.FontFamily` (including in `OnTitleParametersDidChange` handlers) must switch to `.FontFamilyName` or `.TitleTypeface`. + +### DrawTextLine + +New extension method `SKCanvas.DrawTextLine(string, SKFont, SKPaint, SKPoint)` draws text treating Y as the **top of the text** (matching `System.Drawing.Graphics.DrawString` behavior) and returns the Y position for the next line. + +Use `DrawTextLine` instead of raw `SKCanvas.DrawText` when stacking multiple lines -- `DrawText` uses baseline Y which leads to positioning bugs. + +`DrawAndMeasureString` is also available on `SKCanvas` and delegates to `DrawTextLine`. + +### PluginBase Deprecation + +`PluginBase` is `[Obsolete]`. Use `KeypadBase` (keys only), `EncoderBase` (dials only), or `KeyAndEncoderBase` (both). + +--- + +## Type Mapping Reference + +| System.Drawing Type | SkiaSharp Type | Notes | +|---|---|---| +| `Image` / `Bitmap` | `SKBitmap` | `IDisposable`. Use `using` blocks. | +| `Graphics` | `SKCanvas` | Created from `SKBitmap`. `IDisposable`. | +| `Color` | `SKColor` | Struct, no disposal needed. | +| `Font` | `SKFont` | `IDisposable`. Holds typeface + size. | +| `FontFamily` | `SKTypeface` | Use `SKTypeface.FromFamilyName(...)`. | +| `FontStyle` | `SKFontStyle` | `SKFontStyle.Bold`, `SKFontStyle.Normal`, etc. | +| `SolidBrush` | `SKPaint` | Set `Style = SKPaintStyle.Fill` and `Color`. | +| `Pen` | `SKPaint` | Set `Style = SKPaintStyle.Stroke` and `StrokeWidth`. | +| `PointF` | `SKPoint` | | +| `Rectangle` / `RectangleF` | `SKRect` / `SKRectI` | | +| `ColorTranslator.FromHtml(...)` | `SkiaTools.ColorFromHex(...)` | Or `SKColor.TryParse(...)` directly. | +| `Image.FromFile(...)` | `SkiaTools.LoadImage(path)` | | +| `Image.FromStream(...)` | `SkiaTools.LoadImage(stream)` | | + +--- + +## Method-by-Method Migration + +### Tools -> SkiaTools + +| Legacy (`Tools.*`) | Replacement (`SkiaTools.*`) | Notes | +|---|---|---| +| `GenerateKeyImage(DeviceType, out Graphics)` | `GenerateKeyImage(DeviceType, out SKCanvas)` | Returns `SKBitmap` + `SKCanvas` | +| `GenerateGenericKeyImage(out Graphics)` | `GenerateGenericKeyImage(out SKCanvas)` | Returns `SKBitmap` + `SKCanvas` | +| `ImageToBase64(Image, bool)` | `ImageToBase64(SKBitmap, bool)` | | +| `Base64StringToImage(string)` returns `Image` | `Base64StringToImage(string)` returns `SKBitmap` | | +| `FileToBase64(string, bool)` | `FileToBase64(string, bool)` | Identical signature | +| `LoadImage(string)` returns `Image` | `LoadImage(string)` returns `SKBitmap` | | +| `LoadImage(Stream)` returns `Image` | `LoadImage(Stream)` returns `SKBitmap` | | +| `ImageToSHA512(Image)` | `ImageToSHA512(SKBitmap)` | | +| `CreateFont(...)` returns `Font` | `CreateFont(string, float, SKFontStyle?)` returns `SKFont` | Style defaults to Normal | +| *(no equivalent)* | `ColorFromHex(string)` returns `SKColor` | New helper | + +### GraphicsTools -> SkiaGraphicsTools + +| Legacy (`GraphicsTools.*`) | Replacement (`SkiaGraphicsTools.*`) | Notes | +|---|---|---| +| `ColorFromHex(string)` returns `Color` | `ColorFromHex(string)` returns `SKColor` | | +| `GenerateColorShades(string, int, int)` returns `Color` | `GenerateColorShades(string, int, int)` returns `SKColor` | | +| `ResizeImage(Image, int, int)` | `ResizeImage(SKBitmap, int, int)` | Returns `SKBitmap` | +| `ExtractRectangle(Image, ...)` | `ExtractRectangle(SKBitmap, ...)` | Returns `SKBitmap` | +| `CreateOpacityImage(Image, float)` | `CreateOpacityImage(SKBitmap, float)` | Returns `SKBitmap` | +| `DrawMultiLinedText(...)` with `Font`, `Color`, `PointF` | `DrawMultiLinedText(...)` with `SKFont`, `SKColor`, `SKPoint` | Returns `SKBitmap[]` | +| `WrapStringToFitImage(string, TitleParameters, leftPad, rightPad, imageWidth)` | `WrapStringToFitImage(string, SKFont, imageWidth, leftPad, rightPad)` | Takes `SKFont` instead of `TitleParameters`. **Parameter order changed.** | + +### ExtensionMethods -> SkiaExtensionMethods + +| Legacy | Replacement | Notes | +|---|---|---| +| `Image.ToPngByteArray()` | `SKBitmap.ToPngByteArray()` | | +| `Image.ToBase64(bool)` | `SKBitmap.ToBase64(bool)` | | +| `Image.ToByteArray()` (BMP) | *(removed)* | Use `ToPngByteArray()` instead | +| `Graphics.DrawAndMeasureString(string, Font, Brush, PointF)` | `SKCanvas.DrawTextLine(string, SKFont, SKPaint, SKPoint)` | **Preferred.** Y = top of text. Returns next-line Y. | +| `Graphics.GetTextCenter(...)` | `SKCanvas.GetTextCenter(...)` | Same semantics | +| `Graphics.GetFontSizeWhereTextFitsImage(...)` | `SKCanvas.GetFontSizeWhereTextFitsImage(...)` | Same semantics | +| `Graphics.AddTextPath(TitleParameters, ...)` | `SKCanvas.AddTextPath(TitleParameters, ...)` | Uses `TitleSKColor` and `TitleTypeface` internally | +| `string.SplitToFitKey(TitleParameters, ...)` | `string.SplitToFitKey(TitleParameters, SKFont, ...)` | **Requires explicit `SKFont` parameter** | +| `Color.ToHex()` | Use `SKColor` directly | | +| `Brush.ToHex()` | Use `SKPaint.Color` directly | | + +### Connection + +| Legacy | Replacement | Notes | +|---|---|---| +| `SetImageAsync(Image, ...)` | `SetImageAsync(SKBitmap, ...)` | Direct replacement | +| | `SetImageAsync(byte[], ...)` | Alternative: pass raw PNG bytes | + +--- + +## Getting an `SKFont` from `TitleParameters` + +Several methods (`WrapStringToFitImage`, `SplitToFitKey`, `GetTextCenter`, etc.) now require an `SKFont`. Create one from `TitleParameters`: + +```csharp +using var font = new SKFont(titleParameters.TitleTypeface, (float)titleParameters.FontSizeInPixelsScaledToDefaultImage); +``` + +The `SKFont` is `IDisposable` -- use a `using` statement or dispose it manually. + +--- + +## Disposal Patterns + +`SKBitmap`, `SKCanvas`, `SKFont`, and `SKPaint` are all `IDisposable`. Wrap them in `using` statements. + +`SetImageAsync(SKBitmap)` encodes the bitmap to PNG bytes synchronously before the async send, so it is safe to dispose the bitmap immediately after the `await`: + +```csharp +using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) +{ + // ... draw on canvas ... + canvas.Dispose(); + await Connection.SetImageAsync(image); +} +// image is disposed here -- safe, encoding already completed +``` + +For bitmaps stored as fields (e.g., cached key images), dispose them in your action's `Dispose()` method. + +--- + +## Migration Recipes + +### Recipe 1: Basic Key Image Rendering + +**Before (System.Drawing):** +```csharp +using (Image image = Tools.GenerateGenericKeyImage(out Graphics graphics)) +{ + graphics.FillRectangle(new SolidBrush(Color.White), 0, 0, image.Width, image.Height); + graphics.DrawString("Hello", new Font("Arial", 20), new SolidBrush(Color.Black), new PointF(10, 50)); + graphics.Dispose(); + await Connection.SetImageAsync(image); +} +``` + +**After (SkiaSharp):** +```csharp +using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) +{ + canvas.Clear(SKColors.White); + using var font = SkiaTools.CreateFont("Arial", 20); + using var paint = new SKPaint { Color = SKColors.Black, IsAntialias = true }; + canvas.DrawTextLine("Hello", font, paint, new SKPoint(10, 50)); + canvas.Dispose(); + await Connection.SetImageAsync(image); +} +``` + +### Recipe 2: Title Text with TitleParameters + +**Before:** +```csharp +TitleParameters tp = new TitleParameters(new FontFamily("Arial"), FontStyle.Bold, 20, Color.White, true, TitleVerticalAlignment.Middle); +using (Image image = Tools.GenerateGenericKeyImage(out Graphics graphics)) +{ + graphics.AddTextPath(tp, image.Height, image.Width, "My Title"); + graphics.Dispose(); + await Connection.SetImageAsync(image); +} +``` + +**After:** +```csharp +TitleParameters tp = new TitleParameters(new FontFamily("Arial"), FontStyle.Bold, 20, Color.White, true, TitleVerticalAlignment.Middle); +using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) +{ + canvas.AddTextPath(tp, image.Height, image.Width, "My Title"); + canvas.Dispose(); + await Connection.SetImageAsync(image); +} +``` + +> `AddTextPath` on `SKCanvas` uses `TitleSKColor` and `TitleTypeface` internally -- no changes needed beyond switching from `Graphics` to `SKCanvas`. + +### Recipe 3: Color Parsing + +**Before:** +```csharp +Color c = ColorTranslator.FromHtml("#FF0000"); +// or +Color c = GraphicsTools.ColorFromHex("#FF0000"); +``` + +**After:** +```csharp +SKColor c = SkiaTools.ColorFromHex("#FF0000"); +// or +SKColor c = SkiaGraphicsTools.ColorFromHex("#FF0000"); +// or +SKColor.TryParse("#FF0000", out SKColor c); +``` + +### Recipe 4: Image Loading and Base64 + +**Before:** +```csharp +Image img = Tools.LoadImage("icon.png"); +string base64 = Tools.ImageToBase64(img, true); +await Connection.SetImageAsync(base64); +``` + +**After:** +```csharp +using SKBitmap img = SkiaTools.LoadImage("icon.png"); +string base64 = SkiaTools.ImageToBase64(img, true); +await Connection.SetImageAsync(base64); +``` + +Or skip the base64 step entirely: +```csharp +using SKBitmap img = SkiaTools.LoadImage("icon.png"); +await Connection.SetImageAsync(img); +``` + +### Recipe 5: Image Resizing + +**Before:** +```csharp +Image resized = GraphicsTools.ResizeImage(original, 72, 72); +``` + +**After:** +```csharp +SKBitmap resized = SkiaGraphicsTools.ResizeImage(original, 72, 72); +``` + +### Recipe 6: Opacity + +**Before:** +```csharp +Image faded = GraphicsTools.CreateOpacityImage(original, 0.5f); +``` + +**After:** +```csharp +SKBitmap faded = SkiaGraphicsTools.CreateOpacityImage(original, 0.5f); +``` + +### Recipe 7: Font Creation + +**Before:** +```csharp +Font font = new Font("Arial", 14, FontStyle.Bold, GraphicsUnit.Pixel); +``` + +**After:** +```csharp +using SKFont font = SkiaTools.CreateFont("Arial", 14, SKFontStyle.Bold); +``` + +### Recipe 8: Text Centering + +**Before:** +```csharp +float x = graphics.GetTextCenter("Hello", imageWidth, font, out bool fits); +``` + +**After:** +```csharp +float x = canvas.GetTextCenter("Hello", imageWidth, skFont, out bool fits); +``` + +### Recipe 9: Multi-Line Text Layout with DrawTextLine + +Use `DrawTextLine` with layout constants tuned for the default 144x144 key image: + +```csharp +const int MARGIN_LEFT = 8; +const int MARGIN_TOP = 3; +const int CONTENT_TOP_OFFSET = 10; + +using SKBitmap img = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas); +float y = CONTENT_TOP_OFFSET; + +using var font = SkiaTools.CreateFont("Arial", 22, SKFontStyle.Bold); +using var paint = new SKPaint { Color = SKColors.White, IsAntialias = true }; + +y = canvas.DrawTextLine("Title", font, paint, new SKPoint(MARGIN_LEFT, y)) + MARGIN_TOP; +y = canvas.DrawTextLine("Line 2", font, paint, new SKPoint(MARGIN_LEFT, y)) + MARGIN_TOP; +y = canvas.DrawTextLine("Line 3", font, paint, new SKPoint(MARGIN_LEFT, y)) + MARGIN_TOP; + +canvas.Dispose(); +await Connection.SetImageAsync(img); +``` + +Each `DrawTextLine` call returns the Y position for the next line based on `font.Spacing`. Adding `MARGIN_TOP` provides a small gap between lines. + +--- + +## Migration Tiers + +Based on analysis of real-world plugins: + +### Tier 1: No Direct System.Drawing Usage (~52% of plugins) + +Plugins that only use library helpers (`GenerateKeyImage`, `SetImageAsync`, `SplitToFitKey`, etc.) without importing `System.Drawing` in their own code. + +**What to do:** +1. Upgrade `StreamDeck-Tools` NuGet to v7.0 +2. Build -- you will see `[Obsolete]` warnings. Everything still works. +3. When ready, follow the recipes above to switch to SkiaSharp APIs and eliminate warnings. + +### Tier 2: Common System.Drawing Patterns (~22% of plugins) + +Plugins that use `SolidBrush`, `ColorTranslator.FromHtml`, `Font`, `Image.FromFile`, or `Graphics.DrawString` in their own code. Migration is mechanical renaming using the mapping tables above. + +**What to do:** +1. Upgrade the NuGet package +2. Add `using SkiaSharp;` to files with warnings +3. Replace System.Drawing types with SkiaSharp equivalents using the type mapping and recipes + +### Tier 3: Deep GDI+ Usage (~26% of plugins) + +Plugins that use GDI+ APIs without library wrappers: + +| GDI+ API | SkiaSharp Equivalent | +|---|---| +| `GraphicsPath`, `AddString`, `StringFormat` | `SKPath`, `SKCanvas.DrawTextOnPath` | +| `LockBits`, `BitmapData`, `SetPixel` | `SKBitmap.GetPixels()`, `SKBitmap.SetPixel()` | +| `RotateTransform`, matrix operations | `SKCanvas.RotateDegrees()`, `SKCanvas.SetMatrix()` | +| `Graphics.FromImage` rendering pipelines | `new SKCanvas(bitmap)` | + +**What to do:** +1. Upgrade the NuGet package +2. Add a direct `SkiaSharp` NuGet reference to your plugin project +3. Rewrite GDI+ drawing logic using SkiaSharp native APIs + +### Quick Triage Flowchart + +``` +Does your plugin import System.Drawing in its own code? + | + +-- NO --> Tier 1: Upgrade the NuGet package. Done. + | + +-- YES --> Does it use GraphicsPath, LockBits, RotateTransform, or SetPixel? + | + +-- NO --> Tier 2: Mechanical rename using the mapping tables. + | + +-- YES --> Tier 3: Rewrite GDI+ logic with SkiaSharp. +``` + +--- + +## Deprecation Timeline + +| Release | What Happens | +|---|---| +| **v7.0** (current) | All System.Drawing APIs marked `[Obsolete]` with replacement guidance. Everything still compiles and works on Windows. SkiaSharp APIs available in parallel. | +| **v7.x** | Stability and feedback period. No removals. Additional helpers may be added based on community feedback. | +| **v8.0** (future) | System.Drawing APIs evaluated for removal. Removal only after sufficient migration window. | + +--- + +## Compatibility Notes + +- All System.Drawing APIs continue to work on **Windows** across all four TFMs. +- For **macOS/Linux** with `net8.0`/`net10.0`, you **must** use the SkiaSharp APIs. System.Drawing throws `PlatformNotSupportedException`. +- `TitleParameters` constructor and properties are **unchanged**. The SkiaSharp properties are purely additive. +- Exact pixel parity between System.Drawing and SkiaSharp text rendering is not guaranteed. Functional parity is the target. +- SkiaSharp 3.119.2 is MIT-licensed. No licensing impact on plugin developers. + +--- + +## Required `using` Directives + +When migrating to SkiaSharp APIs, add these to your files: + +```csharp +using SkiaSharp; +using BarRaider.SdTools; // SkiaTools, SkiaGraphicsTools, SkiaExtensionMethods +``` + +--- + +## Complete Sample: Cross-Platform Plugin Action + +```csharp +using BarRaider.SdTools; +using BarRaider.SdTools.Wrappers; +using SkiaSharp; +using System.Threading.Tasks; + +[PluginActionId("com.example.myplugin")] +public class MyAction : KeypadBase +{ + public MyAction(ISDConnection connection, InitialPayload payload) : base(connection, payload) { } + + public async override void KeyPressed(KeyPayload payload) + { + using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) + { + canvas.Clear(SKColors.DarkBlue); + using var font = SkiaTools.CreateFont("Arial", 18, SKFontStyle.Bold); + using var paint = new SKPaint { Color = SKColors.White, IsAntialias = true }; + float x = canvas.GetTextCenter("Pressed!", image.Width, font); + canvas.DrawTextLine("Pressed!", font, paint, new SKPoint(x, image.Height / 2f - 10)); + canvas.Dispose(); + await Connection.SetImageAsync(image); + } + } + + public async override void KeyReleased(KeyPayload payload) + { + await Connection.SetDefaultImageAsync(); + } + + public override void OnTick() { } + public override void Dispose() { } + public override void ReceivedSettings(ReceivedSettingsPayload payload) { } + public override void ReceivedGlobalSettings(ReceivedGlobalSettingsPayload payload) { } +} +``` diff --git a/NUGET.md b/NUGET.md index 0ecaa5b..40f1ec0 100644 --- a/NUGET.md +++ b/NUGET.md @@ -4,83 +4,58 @@ [![NuGet](https://img.shields.io/nuget/v/streamdeck-tools.svg?style=flat)](https://www.nuget.org/packages/streamdeck-tools) -**Author's website and contact information:** [https://barraider.com](https://barraider.com) -** Samples of plugins using this framework: [Samples][1] +**Author's website and contact information:** [https://barraider.com](https://barraider.com) -### Version 7.0 (Beta) -- **Cross-platform support** -- plugins can now run on Windows and macOS -- **SkiaSharp graphics** -- new cross-platform drawing APIs (`SkiaTools`, `SkiaGraphicsTools`, `SkiaExtensionMethods`) -- **.NET 10 support** -- targets `netstandard2.0`, `net48`, `net8.0`, `net10.0` -- All System.Drawing APIs marked `[Obsolete]` with pointers to SkiaSharp replacements (still works on Windows) +## Quick Start -**Upgrading from v6.x?** See the [Migration Guide](https://github.com/BarRaider/streamdeck-tools/blob/master/MigrateTo7.0.md) for step-by-step instructions and code recipes. +1. Derive from `KeypadBase` (keys), `EncoderBase` (dials), or `KeyAndEncoderBase` (both) +2. Decorate with `[PluginActionId("your.action.uuid")]` +3. Call `SDWrapper.Run(args)` in `Main()` -## Features -- Sample plugin now included in this project on Github -- Simplified working with filenames from the Stream Deck SDK. See ***"Working with files"*** section below -- Built-in integration with NLog. Use `Logger.LogMessage()` for logging. -- Just call the `SDWrapper.Run()` and the library will take care of all the overhead -- Just have your plugin inherit PluginBase and implement the basic functionality. Use the PluginActionId to specify the UUID from the manifest file. (see samples on github page) -- Simplified receiving Global Settings updates through the new `ReceivedGlobalSettings` method -- Simplified receiving updates from the Property Inspector through the new `ReceivedSettings` method along with the new `Tools.AutoPopulateSettings()` method. See the ***"Auto-populating plugin settings"*** section below. -- Introduced a new attribute called PluginActionId to indicate the Action's UUID (See below) -- Added support to switching plugin profiles. -- The DeviceId that the plugin is running on is now accessible from the `Connection` object -- Added new MD5 functions in the `Tools` helper class -- Optimized SetImage to not resubmit an image that was just posted to the device. Can be overridden with new property in Connection.SetImage() function. -- ExtensionMethods for Brush/Color/Graphics objects -- Helper functions in the `Tools` and `GraphicTools` classes +```csharp +[PluginActionId("com.example.myplugin")] +public class MyAction : KeypadBase +{ + public MyAction(ISDConnection connection, InitialPayload payload) : base(connection, payload) { } + + public async override void KeyPressed(KeyPayload payload) + { + using SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas); + canvas.Clear(SKColors.DarkBlue); + using var font = SkiaTools.CreateFont("Arial", 18, SKFontStyle.Bold); + using var paint = new SKPaint { Color = SKColors.White, IsAntialias = true }; + canvas.DrawTextLine("Hello!", font, paint, new SKPoint(30, 60)); + canvas.Dispose(); + await Connection.SetImageAsync(image); + } + + public override void KeyReleased(KeyPayload payload) { } + public override void OnTick() { } + public override void Dispose() { } + public override void ReceivedSettings(ReceivedSettingsPayload payload) { } + public override void ReceivedGlobalSettings(ReceivedGlobalSettingsPayload payload) { } +} +``` -## How do I use this? -A list of plugins already using this library can be found [here][1] +## Features -This library wraps all the communication with the Stream Deck App, allowing you to focus on actually writing the Plugin's logic. -After creating a C# Console application, using this library requires two steps: +- Encapsulates all Stream Deck communication -- just implement your action logic +- Cross-platform SkiaSharp-based graphics API (`SkiaTools`, `SkiaGraphicsTools`, `SkiaExtensionMethods`) +- Target frameworks: netstandard2.0, net48, net8.0, net10.0 +- Built-in NLog integration via `Logger.LogMessage()` +- Auto-populate settings from the Property Inspector with `Tools.AutoPopulateSettings()` +- Global Settings support +- `PluginActionId` attribute for action UUID mapping +- Stream Deck+ support (keys, dials, and combined) -1. Create a class that inherits the PluginBase abstract class. -Implement your logic, focusing on the methods provided in the base class. -Follow the samples [here][1] for more details -**New:** In version 2.x - use the `PluginActionId` attribute to indicate the action UUID associated with this class (must match the UUID set in the manifest file) +## Migrating from v6.x -~~~~ -[PluginActionId("plugin.uuid.from.manifest.file")] -public class MyPlugin : PluginBase -{ - // Create this constructor in your plugin and pass the objects to the PluginBase class - public MyPlugin(SDConnection connection, InitialPayload payload) : base(connection, payload) - { - .... - // TODO: Use the payload.Settings to see the various settings set in the Property Inspector (in my samples, I create a private class that holds the settings) - // Other relevant settings in the payload include the actual position of the plugin on the Stream Deck - - // Note: By passing the `connection` object back to the PluginBase (using the `base` in the constructor), you now have access to a property called `Connection` - // throughout your plugin. - } - .... - - // TODO: Implement all the remaining abstract functions from PluginBase (or just leave them empty if you don't need them) - - // An example of how easy it is to populate settings in StreamDeck-Tools v2 - public override void ReceivedSettings(ReceivedSettingsPayload payload) - { - Tools.AutoPopulateSettings(settings, payload.Settings); // "settings" is a private class that holds the settings for your plugin's instance. - } -} -~~~~ - -2. In your program.cs, just pass the args you received to the SDWrapper.Run() function, and you're done! -**Note:** This process is much easier than the one used in 1.x and is based on using the `PluginActionId` attribute, as shown in Step 1 above. -Example: -~~~~ -class Program -{ - static void Main(string[] args) - { - SDWrapper.Run(args); - } -} -~~~~ +See the full [Migration Guide](https://github.com/BarRaider/streamdeck-tools/blob/master/MIGRATION.md) for type mapping tables, code recipes, and step-by-step instructions. -3. There is no step 3 - that's it! The abstract functions from PluginBase that are implemented in MyPlugin hold all the basics needed for a plugin to work. You can always listen to additional events using the `Connection` property. +## Resources -[1]: https://github.com/BarRaider/streamdeck-tools/blob/master/samples.md \ No newline at end of file +- [GitHub Repository](https://github.com/BarRaider/streamdeck-tools) +- [Wiki](https://github.com/BarRaider/streamdeck-tools/wiki) +- [Sample Plugins](https://github.com/BarRaider/streamdeck-tools/blob/master/samples.md) +- [EasyPI](https://github.com/BarRaider/streamdeck-easypi) +- [Discord](http://discord.barraider.com) diff --git a/README.md b/README.md index 6848413..754647d 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,18 @@ **Author's website and contact information:** [https://barraider.com](https://barraider.com) -# Version 7.0 now in Beta! -- **Cross-platform support** -- plugins can now run on Windows and macOS -- **SkiaSharp graphics** -- new `SkiaTools`, `SkiaGraphicsTools`, and `SkiaExtensionMethods` classes provide cross-platform drawing APIs -- **New `SetImageAsync` overloads** -- accept `SKBitmap` or `byte[]` (PNG) in addition to base64 strings -- **SkiaSharp properties on `TitleParameters`** -- `TitleSKColor`, `TitleTypeface`, `FontStyleToSKFontStyle()` -- **.NET 10 target** added (`netstandard2.0`, `net48`, `net8.0`, `net10.0`) -- All System.Drawing APIs marked `[Obsolete]` with pointers to SkiaSharp replacements (still fully functional on Windows) +# Introducing Stream Deck Tools Version 7.0 +- **Cross-platform support**: New SkiaSharp-based API surface (`SkiaTools`, `SkiaGraphicsTools`, `SkiaExtensionMethods`) for Windows + macOS. +- **Target frameworks**: netstandard2.0, net48, net8.0, net10.0. +- All `System.Drawing`-based APIs marked `[Obsolete]` with migration guidance. +- New `SetImageAsync(SKBitmap)` and `SetImageAsync(byte[])` overloads on `ISDConnection`. +- New `DrawTextLine` extension method on `SKCanvas` with Y-as-top semantics (matching `System.Drawing.Graphics.DrawString`). +- **Breaking change (macOS):** `TitleParameters.FontFamily` throws `PlatformNotSupportedException` on non-Windows. Use `TitleParameters.FontFamilyName` (string) or `TitleParameters.TitleTypeface` (SKTypeface) instead. +- New `TitleParameters.FontFamilyName`, `TitleSKColor`, `TitleTypeface`, `FontStyleToSKFontStyle()` for cross-platform rendering. +- `PluginBase` is now `[Obsolete]` -- use `KeypadBase`, `EncoderBase`, or `KeyAndEncoderBase`. -**Migration guide:** See [MigrateTo7.0.md](MigrateTo7.0.md) for step-by-step upgrade instructions, type mapping tables, and code recipes. This file is also designed to work with AI coding assistants (Cursor, Copilot, etc.) -- point your AI at it and ask it to migrate your plugin. +## Migration Guide: +- See [MIGRATION.md](MIGRATION.md) for the full migration guide with code recipes and API mapping tables. (**Pro-Tip:** Use this file to let Cursor/Claude do the migration for you). # Stream Deck+ Support Instead of `PluginBase`, Derive from either `KeypadBase` (if you don't support dials), `EncoderBase` (for only dials), `KeyAndEncoderBase` (for both keys and dials) @@ -43,15 +46,16 @@ Introducing our new [wiki](https://github.com/BarRaider/streamdeck-tools/wiki) p # Change Log -### Version 7.0 (Beta) +### Version 7.0 - **Cross-platform support**: New SkiaSharp-based API surface (`SkiaTools`, `SkiaGraphicsTools`, `SkiaExtensionMethods`) for Windows + macOS. - **Target frameworks**: netstandard2.0, net48, net8.0, net10.0. - All `System.Drawing`-based APIs marked `[Obsolete]` with migration guidance. - New `SetImageAsync(SKBitmap)` and `SetImageAsync(byte[])` overloads on `ISDConnection`. - New `DrawTextLine` extension method on `SKCanvas` with Y-as-top semantics (matching `System.Drawing.Graphics.DrawString`). -- **Breaking change (macOS):** `TitleParameters.FontFamily` now throws `PlatformNotSupportedException` on non-Windows. Use `TitleParameters.FontFamilyName` (string) or `TitleParameters.TitleTypeface` (SKTypeface) instead. On Windows, `FontFamily` still works but produces an `[Obsolete]` compiler warning. -- New `TitleParameters.FontFamilyName` property: cross-platform string replacement for `FontFamily.Name`. -- `TitleParameters.TitleSKColor`, `TitleParameters.TitleTypeface`, `TitleParameters.FontStyleToSKFontStyle()` added for cross-platform rendering. +- **Breaking change (macOS):** `TitleParameters.FontFamily` throws `PlatformNotSupportedException` on non-Windows. Use `TitleParameters.FontFamilyName` (string) or `TitleParameters.TitleTypeface` (SKTypeface) instead. On Windows, `FontFamily` still works but produces an `[Obsolete]` compiler warning. +- New `TitleParameters.FontFamilyName`, `TitleSKColor`, `TitleTypeface`, `FontStyleToSKFontStyle()` for cross-platform rendering. +- `PluginBase` is now `[Obsolete]` -- use `KeypadBase`, `EncoderBase`, or `KeyAndEncoderBase`. +- **[Migration Guide](MIGRATION.md)**: Full guide with type mapping tables, code recipes, and migration tiers. ### Version 6.4 - Support for Stream Deck Plus XL, Galleon 100 SD diff --git a/barraider-sdtools/README.md b/barraider-sdtools/README.md deleted file mode 100644 index 57db0bf..0000000 --- a/barraider-sdtools/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# BarRaider's Stream Deck Tools - -#### C# library that wraps all the communication with the Stream Deck App, allowing you to focus on actually writing the Plugin's logic. - -[![Build Status](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnetcore.yml/badge.svg)](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnetcore.yml)  [![NuGet](https://img.shields.io/nuget/v/streamdeck-tools.svg?style=flat)](https://www.nuget.org/packages/streamdeck-tools) - -**Author's website and contact information:** [https://barraider.com](https://barraider.com) - -# Stream Deck+ Support -Instead of `PluginBase`, Derive from either `KeypadBase` (if you don't support dials), `EncoderBase` (for only dials), `KeyAndEncoderBase` (for both keys and dials) - -# Getting Started -Introducing our new [wiki](https://github.com/BarRaider/streamdeck-tools/wiki) packed with usage instructions, examples and more. - -# Dev Discussions / Support -**Discord:** Discuss in #developers-chat in [Bar Raiders](http://discord.barraider.com) - -## Downloadable Resources -* [StreamDeck-Tools Template](https://github.com/BarRaider/streamdeck-tools/raw/master/utils/StreamDeck-Tools%20Template.vsix) for Visual Studio (2019/2022) - Automatically creates a project with all the files needed to compile a plugin. This is the best way to start a new plugin! -* [Install.bat](https://github.com/BarRaider/streamdeck-tools/blob/master/utils/install.bat) - Script that quickly uninstalls and reinstalls your plugin on the streamdeck (edit the batch file for more details). Put the install.bat file in your BIN folder (same folder that has Debug/Release sub-folders) -* [EasyPI](https://github.com/BarRaider/streamdeck-easypi) - Additional library used to easily pass information from the PI (Property Inspector) to your plugin. -* [Profiles](https://barraider.com/profiles) Downloadable empty profiles for the XL (32-key), Classic (15-key), Mini (6-key) and Mobile devices at https://barraider.com/profiles - -## Library Features -- Encapsulates all the communicating with the Stream Deck, getting a plugin working on the Stream Deck only requires implementing the PluginBase class. -- Sample plugin now included in this project on Github -- Built-in integration with NLog. Use `Logger.LogMessage()` for logging. -- Auto-populate user settings which were modified by the Property Inspector -- Access the Global Settings from anywhere in your code -- Simplified working with filenames from the Stream Deck SDK. -- `PluginActionId` attribute let's you easily associate your code to a specific action defined in the manifest.json -- Large set of helper functions to simplify creating images and sending them to the Stream Deck. - -# Change Log - -### Version 6.4 -- Support for Stream Deck Plus XL, Galleon 100 SD - -### Version 6.3 -- Support for new Stream Deck types - -### Version 6.2 -- Support for .NET 8.0 - -### Version 6.1 -- Support for new `DialDown` and `DialUp` events. -- Removed support for deprecated `DialPress` event - -### Version 6.0 -1. Merged streamdeck-client-csharp package into library to allow better logging of errors -2. Added support for SD+ SDK -3. Increased timeout of connection to Stream Deck due to the Stream Deck taking longer than before to reply on load -4. Added error catching to prevent 3rd party plugin exception to impact communication - - -### Version 3.2 is out! -- Created new `ISDConnection` interface which is now implemented by SDConnection and used by PluginAction. -- GlobalSettingsManager now has a short delay before calling GetGlobalSettings(), to reduce spamming the Stream Deck SDK. -- Updated dependencies to latest version - -### Version 3.1 is out! -- Updated Logger class to include process name and thread id - -### Version 3.0 is out! -- Updated file handling in `Tools.AutoPopulateSettings` and `Tools.FilenameFromPayload` methods -- Removed obsolete MD5 functions, use SHA512 functions instead -- `Tools.CenterText` function now has optional out `textFitsImage` value to verify the text does not exceed the image width -- New `Tools.FormatBytes` function converts bytes to human-readable value -- New `Graphics.GetFontSizeWhereTextFitsImage` function helps locate the best size for a text to fit an image on 1 line -- Updated dependency packages to latest versions -- Bug fix where FileNameProperty attribute - -### Version 2.7 is out! -- Fully wrapped all Stream Deck events (All part of the SDConneciton class). See ***"Subscribing to events"*** section below -- Added extension methods for multiple classes related to brushes/colors -- Added additional methods under the Tools class, including AddTextPathToGraphics which can be used to correctly position text on a key image based on the Text Settings in the Property Inspector see ***"Showing Title based on settings from Property Inspector"*** section below. -- Additional error checking -- Updated dependency packages to latest versions -- Sample plugin now included in this project on Github - -### 2019-11-17 -- Updated Install.bat (above) to newer version - -### Version 2.6 is out! -- Added new MD5 functions in the `Tools` helper class -- Optimized SetImage to not resubmit an image that was just posted to the device. Can be overridden with new property in Connection.SetImage() function. - diff --git a/barraider-sdtools/barraider-sdtools.csproj b/barraider-sdtools/barraider-sdtools.csproj index 54b24dc..039790a 100644 --- a/barraider-sdtools/barraider-sdtools.csproj +++ b/barraider-sdtools/barraider-sdtools.csproj @@ -17,8 +17,8 @@ Feel free to contact me for more information: https://barraider.com 7.0.0.0 7.0.0.0 - 7.0.0-beta.4 - 7.0.0-beta.4 - Fix DrawAndMeasureString Y-positioning to match System.Drawing semantics (Y = top of text, not baseline). Add DrawTextLine as canonical replacement with correct line spacing via font.Spacing. + 7.0.0 + 7.0.0 - Cross-platform SkiaSharp API surface (SkiaTools, SkiaGraphicsTools, SkiaExtensionMethods). Target frameworks: netstandard2.0, net48, net8.0, net10.0. All System.Drawing APIs marked [Obsolete] with migration guidance. New SetImageAsync(SKBitmap) and SetImageAsync(byte[]) overloads. DrawTextLine extension for SKCanvas. TitleParameters cross-platform properties (FontFamilyName, TitleSKColor, TitleTypeface). See MIGRATION.md for the full migration guide. BarRaider.SdTools StreamDeckTools BRLogo_460.png @@ -50,7 +50,7 @@ Feel free to contact me for more information: https://barraider.comTrue - + PreserveNewest From a1882248fbd98e0155a57b46ad94b80296454531 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:28:28 +0300 Subject: [PATCH 28/32] Update sample plugin with new infra --- SamplePlugin/PluginAction.cs | 34 ++++++++++---------------------- SamplePlugin/SamplePlugin.csproj | 7 ++----- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/SamplePlugin/PluginAction.cs b/SamplePlugin/PluginAction.cs index 6afb17d..f57c428 100644 --- a/SamplePlugin/PluginAction.cs +++ b/SamplePlugin/PluginAction.cs @@ -4,11 +4,7 @@ using Newtonsoft.Json.Linq; using SkiaSharp; using System; -using System.Collections.Generic; -using System.Drawing; using System.IO; -using System.Linq; -using System.Text; using System.Threading.Tasks; namespace SamplePlugin @@ -110,31 +106,21 @@ public override void Dispose() public async override void KeyPressed(KeyPayload payload) { Logger.Instance.LogMessage(TracingLevel.INFO, "Key Pressed"); - TitleParameters tp = new TitleParameters(new FontFamily("Arial"), FontStyle.Bold, 20, Color.White, true, TitleVerticalAlignment.Middle); - using (Image image = Tools.GenerateGenericKeyImage(out Graphics graphics)) + using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) { - graphics.FillRectangle(new SolidBrush(Color.White), 0, 0, image.Width, image.Height); - graphics.AddTextPath(tp, image.Height, image.Width, "Test"); - graphics.Dispose(); - + canvas.Clear(SKColors.DarkBlue); + using var font = SkiaTools.CreateFont("Arial", 18, SKFontStyle.Bold); + using var paint = new SKPaint { Color = SKColors.White, IsAntialias = true }; + float x = canvas.GetTextCenter("Pressed!", image.Width, font); + canvas.DrawTextLine("Pressed!", font, paint, new SKPoint(x, image.Height / 2f - 10)); + canvas.Dispose(); await Connection.SetImageAsync(image); } } - public async override void KeyReleased(KeyPayload payload) + public async override void KeyReleased(KeyPayload payload) { - // Cross-platform approach using SkiaSharp (works on Windows, macOS, Linux) - using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) - { - canvas.Clear(SKColors.White); - using (var font = SkiaTools.CreateFont("Arial", 20, SKFontStyle.Bold)) - using (var paint = new SKPaint { Color = SKColors.Black, IsAntialias = true }) - { - canvas.DrawText("Cross-Platform", 10, 50, font, paint); - } - canvas.Dispose(); - await Connection.SetImageAsync(image); - } + await Connection.SetDefaultImageAsync(); } public override void OnTick() { } @@ -156,4 +142,4 @@ private Task SaveSettings() #endregion } -} \ No newline at end of file +} diff --git a/SamplePlugin/SamplePlugin.csproj b/SamplePlugin/SamplePlugin.csproj index 270478c..9d45018 100644 --- a/SamplePlugin/SamplePlugin.csproj +++ b/SamplePlugin/SamplePlugin.csproj @@ -1,12 +1,10 @@ Exe - net48 + net10.0 false com.test.sdtools AnyCPU;x64 - true - true bin\Release\com.test.sdtools.sdPlugin\ @@ -21,7 +19,6 @@ bin\Debug\com.test.sdtools.sdPlugin\ - PreserveNewest @@ -89,4 +86,4 @@ - \ No newline at end of file + From a8b720020f19a0eae58aaa6b57e90fdb300a5392 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:43:22 +0300 Subject: [PATCH 29/32] Release StreamDeck-Tools v7.0.0 with migration guide - Bump version from 7.0.0-beta.4 to 7.0.0 - Add MigrateTo7.0.md: comprehensive migration guide with type mapping, method tables, code recipes, tiers, deprecation timeline - Update README.md with v7.0 release notes and migration guide link - Update NUGET.md with modern quick-start and v7.0 features - Restore barraider-sdtools/README.md with v7.0 changelog entry - Update SamplePlugin to net10.0 with SkiaSharp-only KeyPressed - Fix csproj README path reference (../README.md) Made-with: Cursor --- MIGRATION.md | 469 ------------------------------------ MigrateTo7.0.md | 465 +++++++++++++++++++++++++---------- NUGET.md | 2 +- README.md | 2 +- barraider-sdtools/README.md | 98 ++++++++ 5 files changed, 433 insertions(+), 603 deletions(-) delete mode 100644 MIGRATION.md create mode 100644 barraider-sdtools/README.md diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index e839376..0000000 --- a/MIGRATION.md +++ /dev/null @@ -1,469 +0,0 @@ -# StreamDeck-Tools v7.0 Migration Guide - -Migration guide for upgrading from `StreamDeck-Tools` v6.x to v7.0, including the new cross-platform SkiaSharp API surface. - ---- - -## What Changed in v7.0 - -### Target Frameworks - -| Before (v6.x) | After (v7.0) | -|---|---| -| `netstandard2.0` + `net48` | `netstandard2.0` + `net48` + `net8.0` + `net10.0` | - -The library retains `netstandard2.0` for broad compatibility and `net48` for .NET Framework consumers. `net8.0` (LTS) and `net10.0` (current) are added for modern .NET. `net9.0` is not included (EOL May 2026). - -### New Dependency: SkiaSharp 3.119.2 - -The library now ships with [SkiaSharp](https://github.com/mono/SkiaSharp) (MIT license) as a cross-platform graphics backend. SkiaSharp works on Windows, macOS, and Linux without GDI+. - -### Dual API Surface - -Every public method that accepted or returned `System.Drawing` types now has a SkiaSharp equivalent in a parallel class: - -| Legacy Class | SkiaSharp Replacement | -|---|---| -| `Tools` | `SkiaTools` | -| `GraphicsTools` | `SkiaGraphicsTools` | -| `ExtensionMethods` (on `Image`/`Graphics`) | `SkiaExtensionMethods` (on `SKBitmap`/`SKCanvas`) | - -### System.Drawing APIs Marked `[Obsolete]` - -All public methods using `System.Drawing` types are now marked `[Obsolete]` with messages pointing to their SkiaSharp replacements. They still compile and work on Windows but will **not** work on macOS/Linux with .NET 8+. - -### New `SetImageAsync` Overloads - -`ISDConnection` and `SDConnection` now support: - -| Method | Status | Platform | -|---|---|---| -| `SetImageAsync(SKBitmap, ...)` | **New** | Cross-platform | -| `SetImageAsync(byte[], ...)` | **New** | Cross-platform (raw PNG bytes) | -| `SetImageAsync(string, ...)` | Unchanged | Cross-platform (base64) | -| `SetImageAsync(Image, ...)` | `[Obsolete]` | Windows-only | - -### TitleParameters Changes - -`TitleParameters` retains its existing constructors and properties unchanged. New read-only SkiaSharp properties are added: - -| Property / Method | Type | Description | -|---|---|---| -| `FontFamilyName` | `string` | Cross-platform font family name. Replaces `FontFamily.Name`. | -| `TitleSKColor` | `SKColor` | Derived from `TitleColor`. | -| `TitleTypeface` | `SKTypeface` | Derived from `FontFamilyName` + `FontStyle`. Cached. | -| `FontStyleToSKFontStyle()` | `SKFontStyle` | Converts `System.Drawing.FontStyle` to `SKFontStyle`. | - -> **Breaking change (macOS):** `TitleParameters.FontFamily` (the `System.Drawing.FontFamily` property) is `[Obsolete]` and **throws `PlatformNotSupportedException`** on non-Windows. This is a hard crash, not just a warning. Any code that accesses `.FontFamily` (including in `OnTitleParametersDidChange` handlers) must switch to `.FontFamilyName` or `.TitleTypeface`. - -### DrawTextLine - -New extension method `SKCanvas.DrawTextLine(string, SKFont, SKPaint, SKPoint)` draws text treating Y as the **top of the text** (matching `System.Drawing.Graphics.DrawString` behavior) and returns the Y position for the next line. - -Use `DrawTextLine` instead of raw `SKCanvas.DrawText` when stacking multiple lines -- `DrawText` uses baseline Y which leads to positioning bugs. - -`DrawAndMeasureString` is also available on `SKCanvas` and delegates to `DrawTextLine`. - -### PluginBase Deprecation - -`PluginBase` is `[Obsolete]`. Use `KeypadBase` (keys only), `EncoderBase` (dials only), or `KeyAndEncoderBase` (both). - ---- - -## Type Mapping Reference - -| System.Drawing Type | SkiaSharp Type | Notes | -|---|---|---| -| `Image` / `Bitmap` | `SKBitmap` | `IDisposable`. Use `using` blocks. | -| `Graphics` | `SKCanvas` | Created from `SKBitmap`. `IDisposable`. | -| `Color` | `SKColor` | Struct, no disposal needed. | -| `Font` | `SKFont` | `IDisposable`. Holds typeface + size. | -| `FontFamily` | `SKTypeface` | Use `SKTypeface.FromFamilyName(...)`. | -| `FontStyle` | `SKFontStyle` | `SKFontStyle.Bold`, `SKFontStyle.Normal`, etc. | -| `SolidBrush` | `SKPaint` | Set `Style = SKPaintStyle.Fill` and `Color`. | -| `Pen` | `SKPaint` | Set `Style = SKPaintStyle.Stroke` and `StrokeWidth`. | -| `PointF` | `SKPoint` | | -| `Rectangle` / `RectangleF` | `SKRect` / `SKRectI` | | -| `ColorTranslator.FromHtml(...)` | `SkiaTools.ColorFromHex(...)` | Or `SKColor.TryParse(...)` directly. | -| `Image.FromFile(...)` | `SkiaTools.LoadImage(path)` | | -| `Image.FromStream(...)` | `SkiaTools.LoadImage(stream)` | | - ---- - -## Method-by-Method Migration - -### Tools -> SkiaTools - -| Legacy (`Tools.*`) | Replacement (`SkiaTools.*`) | Notes | -|---|---|---| -| `GenerateKeyImage(DeviceType, out Graphics)` | `GenerateKeyImage(DeviceType, out SKCanvas)` | Returns `SKBitmap` + `SKCanvas` | -| `GenerateGenericKeyImage(out Graphics)` | `GenerateGenericKeyImage(out SKCanvas)` | Returns `SKBitmap` + `SKCanvas` | -| `ImageToBase64(Image, bool)` | `ImageToBase64(SKBitmap, bool)` | | -| `Base64StringToImage(string)` returns `Image` | `Base64StringToImage(string)` returns `SKBitmap` | | -| `FileToBase64(string, bool)` | `FileToBase64(string, bool)` | Identical signature | -| `LoadImage(string)` returns `Image` | `LoadImage(string)` returns `SKBitmap` | | -| `LoadImage(Stream)` returns `Image` | `LoadImage(Stream)` returns `SKBitmap` | | -| `ImageToSHA512(Image)` | `ImageToSHA512(SKBitmap)` | | -| `CreateFont(...)` returns `Font` | `CreateFont(string, float, SKFontStyle?)` returns `SKFont` | Style defaults to Normal | -| *(no equivalent)* | `ColorFromHex(string)` returns `SKColor` | New helper | - -### GraphicsTools -> SkiaGraphicsTools - -| Legacy (`GraphicsTools.*`) | Replacement (`SkiaGraphicsTools.*`) | Notes | -|---|---|---| -| `ColorFromHex(string)` returns `Color` | `ColorFromHex(string)` returns `SKColor` | | -| `GenerateColorShades(string, int, int)` returns `Color` | `GenerateColorShades(string, int, int)` returns `SKColor` | | -| `ResizeImage(Image, int, int)` | `ResizeImage(SKBitmap, int, int)` | Returns `SKBitmap` | -| `ExtractRectangle(Image, ...)` | `ExtractRectangle(SKBitmap, ...)` | Returns `SKBitmap` | -| `CreateOpacityImage(Image, float)` | `CreateOpacityImage(SKBitmap, float)` | Returns `SKBitmap` | -| `DrawMultiLinedText(...)` with `Font`, `Color`, `PointF` | `DrawMultiLinedText(...)` with `SKFont`, `SKColor`, `SKPoint` | Returns `SKBitmap[]` | -| `WrapStringToFitImage(string, TitleParameters, leftPad, rightPad, imageWidth)` | `WrapStringToFitImage(string, SKFont, imageWidth, leftPad, rightPad)` | Takes `SKFont` instead of `TitleParameters`. **Parameter order changed.** | - -### ExtensionMethods -> SkiaExtensionMethods - -| Legacy | Replacement | Notes | -|---|---|---| -| `Image.ToPngByteArray()` | `SKBitmap.ToPngByteArray()` | | -| `Image.ToBase64(bool)` | `SKBitmap.ToBase64(bool)` | | -| `Image.ToByteArray()` (BMP) | *(removed)* | Use `ToPngByteArray()` instead | -| `Graphics.DrawAndMeasureString(string, Font, Brush, PointF)` | `SKCanvas.DrawTextLine(string, SKFont, SKPaint, SKPoint)` | **Preferred.** Y = top of text. Returns next-line Y. | -| `Graphics.GetTextCenter(...)` | `SKCanvas.GetTextCenter(...)` | Same semantics | -| `Graphics.GetFontSizeWhereTextFitsImage(...)` | `SKCanvas.GetFontSizeWhereTextFitsImage(...)` | Same semantics | -| `Graphics.AddTextPath(TitleParameters, ...)` | `SKCanvas.AddTextPath(TitleParameters, ...)` | Uses `TitleSKColor` and `TitleTypeface` internally | -| `string.SplitToFitKey(TitleParameters, ...)` | `string.SplitToFitKey(TitleParameters, SKFont, ...)` | **Requires explicit `SKFont` parameter** | -| `Color.ToHex()` | Use `SKColor` directly | | -| `Brush.ToHex()` | Use `SKPaint.Color` directly | | - -### Connection - -| Legacy | Replacement | Notes | -|---|---|---| -| `SetImageAsync(Image, ...)` | `SetImageAsync(SKBitmap, ...)` | Direct replacement | -| | `SetImageAsync(byte[], ...)` | Alternative: pass raw PNG bytes | - ---- - -## Getting an `SKFont` from `TitleParameters` - -Several methods (`WrapStringToFitImage`, `SplitToFitKey`, `GetTextCenter`, etc.) now require an `SKFont`. Create one from `TitleParameters`: - -```csharp -using var font = new SKFont(titleParameters.TitleTypeface, (float)titleParameters.FontSizeInPixelsScaledToDefaultImage); -``` - -The `SKFont` is `IDisposable` -- use a `using` statement or dispose it manually. - ---- - -## Disposal Patterns - -`SKBitmap`, `SKCanvas`, `SKFont`, and `SKPaint` are all `IDisposable`. Wrap them in `using` statements. - -`SetImageAsync(SKBitmap)` encodes the bitmap to PNG bytes synchronously before the async send, so it is safe to dispose the bitmap immediately after the `await`: - -```csharp -using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) -{ - // ... draw on canvas ... - canvas.Dispose(); - await Connection.SetImageAsync(image); -} -// image is disposed here -- safe, encoding already completed -``` - -For bitmaps stored as fields (e.g., cached key images), dispose them in your action's `Dispose()` method. - ---- - -## Migration Recipes - -### Recipe 1: Basic Key Image Rendering - -**Before (System.Drawing):** -```csharp -using (Image image = Tools.GenerateGenericKeyImage(out Graphics graphics)) -{ - graphics.FillRectangle(new SolidBrush(Color.White), 0, 0, image.Width, image.Height); - graphics.DrawString("Hello", new Font("Arial", 20), new SolidBrush(Color.Black), new PointF(10, 50)); - graphics.Dispose(); - await Connection.SetImageAsync(image); -} -``` - -**After (SkiaSharp):** -```csharp -using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) -{ - canvas.Clear(SKColors.White); - using var font = SkiaTools.CreateFont("Arial", 20); - using var paint = new SKPaint { Color = SKColors.Black, IsAntialias = true }; - canvas.DrawTextLine("Hello", font, paint, new SKPoint(10, 50)); - canvas.Dispose(); - await Connection.SetImageAsync(image); -} -``` - -### Recipe 2: Title Text with TitleParameters - -**Before:** -```csharp -TitleParameters tp = new TitleParameters(new FontFamily("Arial"), FontStyle.Bold, 20, Color.White, true, TitleVerticalAlignment.Middle); -using (Image image = Tools.GenerateGenericKeyImage(out Graphics graphics)) -{ - graphics.AddTextPath(tp, image.Height, image.Width, "My Title"); - graphics.Dispose(); - await Connection.SetImageAsync(image); -} -``` - -**After:** -```csharp -TitleParameters tp = new TitleParameters(new FontFamily("Arial"), FontStyle.Bold, 20, Color.White, true, TitleVerticalAlignment.Middle); -using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) -{ - canvas.AddTextPath(tp, image.Height, image.Width, "My Title"); - canvas.Dispose(); - await Connection.SetImageAsync(image); -} -``` - -> `AddTextPath` on `SKCanvas` uses `TitleSKColor` and `TitleTypeface` internally -- no changes needed beyond switching from `Graphics` to `SKCanvas`. - -### Recipe 3: Color Parsing - -**Before:** -```csharp -Color c = ColorTranslator.FromHtml("#FF0000"); -// or -Color c = GraphicsTools.ColorFromHex("#FF0000"); -``` - -**After:** -```csharp -SKColor c = SkiaTools.ColorFromHex("#FF0000"); -// or -SKColor c = SkiaGraphicsTools.ColorFromHex("#FF0000"); -// or -SKColor.TryParse("#FF0000", out SKColor c); -``` - -### Recipe 4: Image Loading and Base64 - -**Before:** -```csharp -Image img = Tools.LoadImage("icon.png"); -string base64 = Tools.ImageToBase64(img, true); -await Connection.SetImageAsync(base64); -``` - -**After:** -```csharp -using SKBitmap img = SkiaTools.LoadImage("icon.png"); -string base64 = SkiaTools.ImageToBase64(img, true); -await Connection.SetImageAsync(base64); -``` - -Or skip the base64 step entirely: -```csharp -using SKBitmap img = SkiaTools.LoadImage("icon.png"); -await Connection.SetImageAsync(img); -``` - -### Recipe 5: Image Resizing - -**Before:** -```csharp -Image resized = GraphicsTools.ResizeImage(original, 72, 72); -``` - -**After:** -```csharp -SKBitmap resized = SkiaGraphicsTools.ResizeImage(original, 72, 72); -``` - -### Recipe 6: Opacity - -**Before:** -```csharp -Image faded = GraphicsTools.CreateOpacityImage(original, 0.5f); -``` - -**After:** -```csharp -SKBitmap faded = SkiaGraphicsTools.CreateOpacityImage(original, 0.5f); -``` - -### Recipe 7: Font Creation - -**Before:** -```csharp -Font font = new Font("Arial", 14, FontStyle.Bold, GraphicsUnit.Pixel); -``` - -**After:** -```csharp -using SKFont font = SkiaTools.CreateFont("Arial", 14, SKFontStyle.Bold); -``` - -### Recipe 8: Text Centering - -**Before:** -```csharp -float x = graphics.GetTextCenter("Hello", imageWidth, font, out bool fits); -``` - -**After:** -```csharp -float x = canvas.GetTextCenter("Hello", imageWidth, skFont, out bool fits); -``` - -### Recipe 9: Multi-Line Text Layout with DrawTextLine - -Use `DrawTextLine` with layout constants tuned for the default 144x144 key image: - -```csharp -const int MARGIN_LEFT = 8; -const int MARGIN_TOP = 3; -const int CONTENT_TOP_OFFSET = 10; - -using SKBitmap img = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas); -float y = CONTENT_TOP_OFFSET; - -using var font = SkiaTools.CreateFont("Arial", 22, SKFontStyle.Bold); -using var paint = new SKPaint { Color = SKColors.White, IsAntialias = true }; - -y = canvas.DrawTextLine("Title", font, paint, new SKPoint(MARGIN_LEFT, y)) + MARGIN_TOP; -y = canvas.DrawTextLine("Line 2", font, paint, new SKPoint(MARGIN_LEFT, y)) + MARGIN_TOP; -y = canvas.DrawTextLine("Line 3", font, paint, new SKPoint(MARGIN_LEFT, y)) + MARGIN_TOP; - -canvas.Dispose(); -await Connection.SetImageAsync(img); -``` - -Each `DrawTextLine` call returns the Y position for the next line based on `font.Spacing`. Adding `MARGIN_TOP` provides a small gap between lines. - ---- - -## Migration Tiers - -Based on analysis of real-world plugins: - -### Tier 1: No Direct System.Drawing Usage (~52% of plugins) - -Plugins that only use library helpers (`GenerateKeyImage`, `SetImageAsync`, `SplitToFitKey`, etc.) without importing `System.Drawing` in their own code. - -**What to do:** -1. Upgrade `StreamDeck-Tools` NuGet to v7.0 -2. Build -- you will see `[Obsolete]` warnings. Everything still works. -3. When ready, follow the recipes above to switch to SkiaSharp APIs and eliminate warnings. - -### Tier 2: Common System.Drawing Patterns (~22% of plugins) - -Plugins that use `SolidBrush`, `ColorTranslator.FromHtml`, `Font`, `Image.FromFile`, or `Graphics.DrawString` in their own code. Migration is mechanical renaming using the mapping tables above. - -**What to do:** -1. Upgrade the NuGet package -2. Add `using SkiaSharp;` to files with warnings -3. Replace System.Drawing types with SkiaSharp equivalents using the type mapping and recipes - -### Tier 3: Deep GDI+ Usage (~26% of plugins) - -Plugins that use GDI+ APIs without library wrappers: - -| GDI+ API | SkiaSharp Equivalent | -|---|---| -| `GraphicsPath`, `AddString`, `StringFormat` | `SKPath`, `SKCanvas.DrawTextOnPath` | -| `LockBits`, `BitmapData`, `SetPixel` | `SKBitmap.GetPixels()`, `SKBitmap.SetPixel()` | -| `RotateTransform`, matrix operations | `SKCanvas.RotateDegrees()`, `SKCanvas.SetMatrix()` | -| `Graphics.FromImage` rendering pipelines | `new SKCanvas(bitmap)` | - -**What to do:** -1. Upgrade the NuGet package -2. Add a direct `SkiaSharp` NuGet reference to your plugin project -3. Rewrite GDI+ drawing logic using SkiaSharp native APIs - -### Quick Triage Flowchart - -``` -Does your plugin import System.Drawing in its own code? - | - +-- NO --> Tier 1: Upgrade the NuGet package. Done. - | - +-- YES --> Does it use GraphicsPath, LockBits, RotateTransform, or SetPixel? - | - +-- NO --> Tier 2: Mechanical rename using the mapping tables. - | - +-- YES --> Tier 3: Rewrite GDI+ logic with SkiaSharp. -``` - ---- - -## Deprecation Timeline - -| Release | What Happens | -|---|---| -| **v7.0** (current) | All System.Drawing APIs marked `[Obsolete]` with replacement guidance. Everything still compiles and works on Windows. SkiaSharp APIs available in parallel. | -| **v7.x** | Stability and feedback period. No removals. Additional helpers may be added based on community feedback. | -| **v8.0** (future) | System.Drawing APIs evaluated for removal. Removal only after sufficient migration window. | - ---- - -## Compatibility Notes - -- All System.Drawing APIs continue to work on **Windows** across all four TFMs. -- For **macOS/Linux** with `net8.0`/`net10.0`, you **must** use the SkiaSharp APIs. System.Drawing throws `PlatformNotSupportedException`. -- `TitleParameters` constructor and properties are **unchanged**. The SkiaSharp properties are purely additive. -- Exact pixel parity between System.Drawing and SkiaSharp text rendering is not guaranteed. Functional parity is the target. -- SkiaSharp 3.119.2 is MIT-licensed. No licensing impact on plugin developers. - ---- - -## Required `using` Directives - -When migrating to SkiaSharp APIs, add these to your files: - -```csharp -using SkiaSharp; -using BarRaider.SdTools; // SkiaTools, SkiaGraphicsTools, SkiaExtensionMethods -``` - ---- - -## Complete Sample: Cross-Platform Plugin Action - -```csharp -using BarRaider.SdTools; -using BarRaider.SdTools.Wrappers; -using SkiaSharp; -using System.Threading.Tasks; - -[PluginActionId("com.example.myplugin")] -public class MyAction : KeypadBase -{ - public MyAction(ISDConnection connection, InitialPayload payload) : base(connection, payload) { } - - public async override void KeyPressed(KeyPayload payload) - { - using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) - { - canvas.Clear(SKColors.DarkBlue); - using var font = SkiaTools.CreateFont("Arial", 18, SKFontStyle.Bold); - using var paint = new SKPaint { Color = SKColors.White, IsAntialias = true }; - float x = canvas.GetTextCenter("Pressed!", image.Width, font); - canvas.DrawTextLine("Pressed!", font, paint, new SKPoint(x, image.Height / 2f - 10)); - canvas.Dispose(); - await Connection.SetImageAsync(image); - } - } - - public async override void KeyReleased(KeyPayload payload) - { - await Connection.SetDefaultImageAsync(); - } - - public override void OnTick() { } - public override void Dispose() { } - public override void ReceivedSettings(ReceivedSettingsPayload payload) { } - public override void ReceivedGlobalSettings(ReceivedGlobalSettingsPayload payload) { } -} -``` diff --git a/MigrateTo7.0.md b/MigrateTo7.0.md index 537009f..bf7d2eb 100644 --- a/MigrateTo7.0.md +++ b/MigrateTo7.0.md @@ -1,36 +1,9 @@ # Migrating to StreamDeck-Tools v7.0 -This guide helps you upgrade your Stream Deck plugin from StreamDeck-Tools v6.x to v7.0. +This guide helps you upgrade your Stream Deck plugin from StreamDeck-Tools v6.x to v7.0, including the new cross-platform SkiaSharp API surface. > **AI-assisted migration:** This document is designed to work well with AI coding assistants (Cursor, Copilot, etc.). Point your AI at this file and ask it to migrate your plugin -- the tables and recipes below give it everything it needs. -## What's New in v7.0 - -- **Cross-platform support** -- plugins can now run on both Windows and macOS -- **SkiaSharp graphics** -- new cross-platform drawing APIs alongside the existing System.Drawing ones -- **.NET 10 support** -- library targets `netstandard2.0`, `net48`, `net8.0`, and `net10.0` -- **Self-contained deployment** -- ship your plugin with the runtime included, no user install required - -## Do I Need to Change My Code? - -Maybe not! Here's a quick check: - -``` -Does your plugin use System.Drawing directly in its own code? - │ - ├── NO → Just upgrade the NuGet package. Build. Done. - │ You'll see [Obsolete] warnings -- these are informational. - │ Everything still works on Windows. - │ - └── YES → Does it use GraphicsPath, LockBits, RotateTransform, or SetPixel? - │ - ├── NO → Straightforward rename using the tables below. - │ - └── YES → Rewrite those sections using SkiaSharp directly. -``` - -Most plugins (roughly half) fall into the "just upgrade" category. - ## Step 1: Upgrade the NuGet Package ``` @@ -40,7 +13,7 @@ Update-Package StreamDeck-Tools Or set the version manually in your `.csproj`: ```xml - + ``` Build your project. Everything should compile. You'll see `[Obsolete]` warnings on System.Drawing methods -- these point you to the SkiaSharp replacements. @@ -55,32 +28,91 @@ using SkiaSharp; The `BarRaider.SdTools` namespace already contains `SkiaTools`, `SkiaGraphicsTools`, and `SkiaExtensionMethods`. -## Step 3: Replace System.Drawing Calls +--- + +## What Changed in v7.0 -### Class Mapping +### Target Frameworks -| Legacy Class | Replacement | +| Before (v6.x) | After (v7.0) | |---|---| -| `Tools` (image/font methods) | `SkiaTools` | +| `netstandard2.0` + `net48` | `netstandard2.0` + `net48` + `net8.0` + `net10.0` | + +The library retains `netstandard2.0` for broad compatibility and `net48` for .NET Framework consumers. `net8.0` (LTS) and `net10.0` (current) are added for modern .NET. `net9.0` is not included (EOL May 2026). + +### New Dependency: SkiaSharp 3.119.2 + +The library now ships with [SkiaSharp](https://github.com/mono/SkiaSharp) (MIT license) as a cross-platform graphics backend. SkiaSharp works on Windows, macOS, and Linux without GDI+. + +### Dual API Surface + +Every public method that accepted or returned `System.Drawing` types now has a SkiaSharp equivalent in a parallel class: + +| Legacy Class | SkiaSharp Replacement | +|---|---| +| `Tools` | `SkiaTools` | | `GraphicsTools` | `SkiaGraphicsTools` | | `ExtensionMethods` (on `Image`/`Graphics`) | `SkiaExtensionMethods` (on `SKBitmap`/`SKCanvas`) | -### Type Mapping +### System.Drawing APIs Marked `[Obsolete]` + +All public methods using `System.Drawing` types are now marked `[Obsolete]` with messages pointing to their SkiaSharp replacements. They still compile and work on Windows but will **not** work on macOS/Linux with .NET 8+. + +### New `SetImageAsync` Overloads -| System.Drawing | SkiaSharp | Notes | +`ISDConnection` and `SDConnection` now support: + +| Method | Status | Platform | +|---|---|---| +| `SetImageAsync(SKBitmap, ...)` | **New** | Cross-platform | +| `SetImageAsync(byte[], ...)` | **New** | Cross-platform (raw PNG bytes) | +| `SetImageAsync(string, ...)` | Unchanged | Cross-platform (base64) | +| `SetImageAsync(Image, ...)` | `[Obsolete]` | Windows-only | + +### TitleParameters Changes + +`TitleParameters` retains its existing constructors and properties unchanged. New read-only SkiaSharp properties are added: + +| Property / Method | Type | Description | +|---|---|---| +| `FontFamilyName` | `string` | Cross-platform font family name. Replaces `FontFamily.Name`. | +| `TitleSKColor` | `SKColor` | Derived from `TitleColor`. | +| `TitleTypeface` | `SKTypeface` | Derived from `FontFamilyName` + `FontStyle`. Cached. | +| `FontStyleToSKFontStyle()` | `SKFontStyle` | Converts `System.Drawing.FontStyle` to `SKFontStyle`. | + +> **Breaking change (macOS):** `TitleParameters.FontFamily` (the `System.Drawing.FontFamily` property) is `[Obsolete]` and **throws `PlatformNotSupportedException`** on non-Windows. This is a hard crash, not just a warning. Any code that accesses `.FontFamily` (including in `OnTitleParametersDidChange` handlers) must switch to `.FontFamilyName` or `.TitleTypeface`. + +### DrawTextLine + +New extension method `SKCanvas.DrawTextLine(string, SKFont, SKPaint, SKPoint)` draws text treating Y as the **top of the text** (matching `System.Drawing.Graphics.DrawString` behavior) and returns the Y position for the next line. + +Use `DrawTextLine` instead of raw `SKCanvas.DrawText` when stacking multiple lines -- `DrawText` uses baseline Y which leads to positioning bugs. + +`DrawAndMeasureString` is also available on `SKCanvas` and delegates to `DrawTextLine`. + +### PluginBase Deprecation + +`PluginBase` is `[Obsolete]`. Use `KeypadBase` (keys only), `EncoderBase` (dials only), or `KeyAndEncoderBase` (both). + +--- + +## Type Mapping Reference + +| System.Drawing Type | SkiaSharp Type | Notes | |---|---|---| -| `Image` / `Bitmap` | `SKBitmap` | Disposable | -| `Graphics` | `SKCanvas` | Created from `SKBitmap` | -| `Color` | `SKColor` | Struct, no disposal | -| `Font` | `SKFont` | Disposable | -| `FontFamily` | `SKTypeface` | `SKTypeface.FromFamilyName(...)` | -| `FontStyle` | `SKFontStyle` | `SKFontStyle.Bold`, `.Normal`, etc. | -| `SolidBrush` | `SKPaint` | `Style = SKPaintStyle.Fill` | -| `Pen` | `SKPaint` | `Style = SKPaintStyle.Stroke` | +| `Image` / `Bitmap` | `SKBitmap` | `IDisposable`. Use `using` blocks. | +| `Graphics` | `SKCanvas` | Created from `SKBitmap`. `IDisposable`. | +| `Color` | `SKColor` | Struct, no disposal needed. | +| `Font` | `SKFont` | `IDisposable`. Holds typeface + size. | +| `FontFamily` | `SKTypeface` | Use `SKTypeface.FromFamilyName(...)`. | +| `FontStyle` | `SKFontStyle` | `SKFontStyle.Bold`, `SKFontStyle.Normal`, etc. | +| `SolidBrush` | `SKPaint` | Set `Style = SKPaintStyle.Fill` and `Color`. | +| `Pen` | `SKPaint` | Set `Style = SKPaintStyle.Stroke` and `StrokeWidth`. | | `PointF` | `SKPoint` | | | `Rectangle` / `RectangleF` | `SKRect` / `SKRectI` | | -| `ColorTranslator.FromHtml(...)` | `SkiaTools.ColorFromHex(...)` | | +| `ColorTranslator.FromHtml(...)` | `SkiaTools.ColorFromHex(...)` | Or `SKColor.TryParse(...)` directly. | | `Image.FromFile(...)` | `SkiaTools.LoadImage(path)` | | +| `Image.FromStream(...)` | `SkiaTools.LoadImage(stream)` | | ### Common Patterns @@ -90,36 +122,101 @@ The `BarRaider.SdTools` namespace already contains `SkiaTools`, `SkiaGraphicsToo | `ColorTranslator.FromHtml("#FF0000")` | `SkiaTools.ColorFromHex("#FF0000")` | | `new Font("Arial", 12)` | `SkiaTools.CreateFont("Arial", 12)` | | `Image.FromFile("icon.png")` | `SkiaTools.LoadImage("icon.png")` | -| `graphics.DrawString(text, font, brush, point)` | `canvas.DrawText(text, x, y, font, paint)` | +| `graphics.DrawString(text, font, brush, point)` | `canvas.DrawTextLine(text, font, paint, point)` | | `graphics.FillRectangle(brush, rect)` | `canvas.DrawRect(rect, paint)` | -### Method Migration +--- -| Legacy | Replacement | -|---|---| -| `Tools.GenerateKeyImage(type, out Graphics)` | `SkiaTools.GenerateKeyImage(type, out SKCanvas)` | -| `Tools.GenerateGenericKeyImage(out Graphics)` | `SkiaTools.GenerateGenericKeyImage(out SKCanvas)` | -| `Tools.ImageToBase64(Image, bool)` | `SkiaTools.ImageToBase64(SKBitmap, bool)` | -| `Tools.Base64StringToImage(string)` → `Image` | `SkiaTools.Base64StringToImage(string)` → `SKBitmap` | -| `Tools.FileToBase64(string, bool)` | `SkiaTools.FileToBase64(string, bool)` | -| `Tools.LoadImage(string/Stream)` → `Image` | `SkiaTools.LoadImage(string/Stream)` → `SKBitmap` | -| `Tools.CreateFont(...)` → `Font` | `SkiaTools.CreateFont(name, size, SKFontStyle?)` → `SKFont` | -| `GraphicsTools.ResizeImage(Image, w, h)` | `SkiaGraphicsTools.ResizeImage(SKBitmap, w, h)` | -| `GraphicsTools.CreateOpacityImage(Image, f)` | `SkiaGraphicsTools.CreateOpacityImage(SKBitmap, f)` | -| `Connection.SetImageAsync(Image)` | `Connection.SetImageAsync(SKBitmap)` | -| `graphics.AddTextPath(tp, h, w, text)` | `canvas.AddTextPath(tp, h, w, text)` | -| `graphics.GetTextCenter(text, w, font)` | `canvas.GetTextCenter(text, w, skFont)` | -| `text.SplitToFitKey(tp, ...)` | `text.SplitToFitKey(tp, skFont, ...)` | +## Method-by-Method Migration -> **Coordinate difference:** `Graphics.DrawString` positions text from the top-left corner. `SKCanvas.DrawText` uses the text **baseline**. You may need to adjust Y coordinates when migrating (add the font ascent to the Y value). +### Tools -> SkiaTools -> **SKFont from TitleParameters:** Some methods now require an `SKFont`. Create one from `TitleParameters` like this: `new SKFont(titleParameters.TitleTypeface, (float)titleParameters.FontSizeInPixelsScaledToDefaultImage)`. Remember to dispose it. +| Legacy (`Tools.*`) | Replacement (`SkiaTools.*`) | Notes | +|---|---|---| +| `GenerateKeyImage(DeviceType, out Graphics)` | `GenerateKeyImage(DeviceType, out SKCanvas)` | Returns `SKBitmap` + `SKCanvas` | +| `GenerateGenericKeyImage(out Graphics)` | `GenerateGenericKeyImage(out SKCanvas)` | Returns `SKBitmap` + `SKCanvas` | +| `ImageToBase64(Image, bool)` | `ImageToBase64(SKBitmap, bool)` | | +| `Base64StringToImage(string)` returns `Image` | `Base64StringToImage(string)` returns `SKBitmap` | | +| `FileToBase64(string, bool)` | `FileToBase64(string, bool)` | Identical signature | +| `LoadImage(string)` returns `Image` | `LoadImage(string)` returns `SKBitmap` | | +| `LoadImage(Stream)` returns `Image` | `LoadImage(Stream)` returns `SKBitmap` | | +| `ImageToSHA512(Image)` | `ImageToSHA512(SKBitmap)` | | +| `CreateFont(...)` returns `Font` | `CreateFont(string, float, SKFontStyle?)` returns `SKFont` | Style defaults to Normal | +| *(no equivalent)* | `ColorFromHex(string)` returns `SKColor` | New helper | + +### GraphicsTools -> SkiaGraphicsTools + +| Legacy (`GraphicsTools.*`) | Replacement (`SkiaGraphicsTools.*`) | Notes | +|---|---|---| +| `ColorFromHex(string)` returns `Color` | `ColorFromHex(string)` returns `SKColor` | | +| `GenerateColorShades(string, int, int)` returns `Color` | `GenerateColorShades(string, int, int)` returns `SKColor` | | +| `ResizeImage(Image, int, int)` | `ResizeImage(SKBitmap, int, int)` | Returns `SKBitmap` | +| `ExtractRectangle(Image, ...)` | `ExtractRectangle(SKBitmap, ...)` | Returns `SKBitmap` | +| `CreateOpacityImage(Image, float)` | `CreateOpacityImage(SKBitmap, float)` | Returns `SKBitmap` | +| `DrawMultiLinedText(...)` with `Font`, `Color`, `PointF` | `DrawMultiLinedText(...)` with `SKFont`, `SKColor`, `SKPoint` | Returns `SKBitmap[]` | +| `WrapStringToFitImage(string, TitleParameters, leftPad, rightPad, imageWidth)` | `WrapStringToFitImage(string, SKFont, imageWidth, leftPad, rightPad)` | Takes `SKFont` instead of `TitleParameters`. **Parameter order changed.** | -## Code Recipes +### ExtensionMethods -> SkiaExtensionMethods -### Key Image Rendering +| Legacy | Replacement | Notes | +|---|---|---| +| `Image.ToPngByteArray()` | `SKBitmap.ToPngByteArray()` | | +| `Image.ToBase64(bool)` | `SKBitmap.ToBase64(bool)` | | +| `Image.ToByteArray()` (BMP) | *(removed)* | Use `ToPngByteArray()` instead | +| `Graphics.DrawAndMeasureString(string, Font, Brush, PointF)` | `SKCanvas.DrawTextLine(string, SKFont, SKPaint, SKPoint)` | **Preferred.** Y = top of text. Returns next-line Y. | +| `Graphics.GetTextCenter(...)` | `SKCanvas.GetTextCenter(...)` | Same semantics | +| `Graphics.GetFontSizeWhereTextFitsImage(...)` | `SKCanvas.GetFontSizeWhereTextFitsImage(...)` | Same semantics | +| `Graphics.AddTextPath(TitleParameters, ...)` | `SKCanvas.AddTextPath(TitleParameters, ...)` | Uses `TitleSKColor` and `TitleTypeface` internally | +| `string.SplitToFitKey(TitleParameters, ...)` | `string.SplitToFitKey(TitleParameters, SKFont, ...)` | **Requires explicit `SKFont` parameter** | +| `Color.ToHex()` | Use `SKColor` directly | | +| `Brush.ToHex()` | Use `SKPaint.Color` directly | | + +### Connection + +| Legacy | Replacement | Notes | +|---|---|---| +| `SetImageAsync(Image, ...)` | `SetImageAsync(SKBitmap, ...)` | Direct replacement | +| | `SetImageAsync(byte[], ...)` | Alternative: pass raw PNG bytes | + +--- + +## Getting an `SKFont` from `TitleParameters` + +Several methods (`WrapStringToFitImage`, `SplitToFitKey`, `GetTextCenter`, etc.) now require an `SKFont`. Create one from `TitleParameters`: + +```csharp +using var font = new SKFont(titleParameters.TitleTypeface, (float)titleParameters.FontSizeInPixelsScaledToDefaultImage); +``` + +The `SKFont` is `IDisposable` -- use a `using` statement or dispose it manually. + +--- + +## Disposal Patterns + +`SKBitmap`, `SKCanvas`, `SKFont`, and `SKPaint` are all `IDisposable`. Wrap them in `using` statements. + +`SetImageAsync(SKBitmap)` encodes the bitmap to PNG bytes synchronously before the async send, so it is safe to dispose the bitmap immediately after the `await`: -**Before:** +```csharp +using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) +{ + // ... draw on canvas ... + canvas.Dispose(); + await Connection.SetImageAsync(image); +} +// image is disposed here -- safe, encoding already completed +``` + +For bitmaps stored as fields (e.g., cached key images), dispose them in your action's `Dispose()` method. + +--- + +## Migration Recipes + +### Recipe 1: Basic Key Image Rendering + +**Before (System.Drawing):** ```csharp using (Image image = Tools.GenerateGenericKeyImage(out Graphics graphics)) { @@ -130,22 +227,20 @@ using (Image image = Tools.GenerateGenericKeyImage(out Graphics graphics)) } ``` -**After:** +**After (SkiaSharp):** ```csharp using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) { canvas.Clear(SKColors.White); - using (var font = SkiaTools.CreateFont("Arial", 20)) - using (var paint = new SKPaint { Color = SKColors.Black, IsAntialias = true }) - { - canvas.DrawText("Hello", 10, 50, font, paint); - } + using var font = SkiaTools.CreateFont("Arial", 20); + using var paint = new SKPaint { Color = SKColors.Black, IsAntialias = true }; + canvas.DrawTextLine("Hello", font, paint, new SKPoint(10, 50)); canvas.Dispose(); await Connection.SetImageAsync(image); } ``` -### Title Text with TitleParameters +### Recipe 2: Title Text with TitleParameters **Before:** ```csharp @@ -169,13 +264,27 @@ using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) } ``` -### Color Parsing +> `AddTextPath` on `SKCanvas` uses `TitleSKColor` and `TitleTypeface` internally -- no changes needed beyond switching from `Graphics` to `SKCanvas`. -**Before:** `Color c = ColorTranslator.FromHtml("#FF0000");` +### Recipe 3: Color Parsing -**After:** `SKColor c = SkiaTools.ColorFromHex("#FF0000");` +**Before:** +```csharp +Color c = ColorTranslator.FromHtml("#FF0000"); +// or +Color c = GraphicsTools.ColorFromHex("#FF0000"); +``` -### Image Loading +**After:** +```csharp +SKColor c = SkiaTools.ColorFromHex("#FF0000"); +// or +SKColor c = SkiaGraphicsTools.ColorFromHex("#FF0000"); +// or +SKColor.TryParse("#FF0000", out SKColor c); +``` + +### Recipe 4: Image Loading and Base64 **Before:** ```csharp @@ -186,82 +295,178 @@ await Connection.SetImageAsync(base64); **After:** ```csharp -using (SKBitmap img = SkiaTools.LoadImage("icon.png")) -{ - string base64 = SkiaTools.ImageToBase64(img, true); - await Connection.SetImageAsync(base64); -} +using SKBitmap img = SkiaTools.LoadImage("icon.png"); +string base64 = SkiaTools.ImageToBase64(img, true); +await Connection.SetImageAsync(base64); ``` -### Font Creation +Or skip the base64 step entirely: +```csharp +using SKBitmap img = SkiaTools.LoadImage("icon.png"); +await Connection.SetImageAsync(img); +``` -**Before:** `Font font = new Font("Arial", 14, FontStyle.Bold, GraphicsUnit.Pixel);` +### Recipe 5: Image Resizing + +**Before:** +```csharp +Image resized = GraphicsTools.ResizeImage(original, 72, 72); +``` **After:** ```csharp -using (SKFont font = SkiaTools.CreateFont("Arial", 14, SKFontStyle.Bold)) -{ - // use font for drawing -} +SKBitmap resized = SkiaGraphicsTools.ResizeImage(original, 72, 72); ``` -## Going Cross-Platform (Optional) +### Recipe 6: Opacity -If you want your plugin to run on both Windows and macOS: +**Before:** +```csharp +Image faded = GraphicsTools.CreateOpacityImage(original, 0.5f); +``` -### 1. Target .NET 10 with Self-Contained Deployment +**After:** +```csharp +SKBitmap faded = SkiaGraphicsTools.CreateOpacityImage(original, 0.5f); +``` -```xml - - net10.0 - true - true - partial - win-x64;osx-x64 - +### Recipe 7: Font Creation + +**Before:** +```csharp +Font font = new Font("Arial", 14, FontStyle.Bold, GraphicsUnit.Pixel); ``` -`osx-x64` is recommended for macOS -- it runs natively on Intel and via Rosetta 2 on Apple Silicon, covering all Mac users with a single binary. +**After:** +```csharp +using SKFont font = SkiaTools.CreateFont("Arial", 14, SKFontStyle.Bold); +``` -### 2. Update manifest.json +### Recipe 8: Text Centering -```json -{ - "CodePath": "com.your.plugin", - "CodePathWin": "win-x64/com.your.plugin.exe", - "CodePathMac": "osx-x64/com.your.plugin", - "OS": [ - { "Platform": "windows", "MinimumVersion": "10" }, - { "Platform": "mac", "MinimumVersion": "13" } - ] -} +**Before:** +```csharp +float x = graphics.GetTextCenter("Hello", imageWidth, font, out bool fits); +``` + +**After:** +```csharp +float x = canvas.GetTextCenter("Hello", imageWidth, skFont, out bool fits); +``` + +### Recipe 9: Multi-Line Text Layout with DrawTextLine + +Use `DrawTextLine` with layout constants tuned for the default 144x144 key image: + +```csharp +const int MARGIN_LEFT = 8; +const int MARGIN_TOP = 3; +const int CONTENT_TOP_OFFSET = 10; + +using SKBitmap img = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas); +float y = CONTENT_TOP_OFFSET; + +using var font = SkiaTools.CreateFont("Arial", 22, SKFontStyle.Bold); +using var paint = new SKPaint { Color = SKColors.White, IsAntialias = true }; + +y = canvas.DrawTextLine("Title", font, paint, new SKPoint(MARGIN_LEFT, y)) + MARGIN_TOP; +y = canvas.DrawTextLine("Line 2", font, paint, new SKPoint(MARGIN_LEFT, y)) + MARGIN_TOP; +y = canvas.DrawTextLine("Line 3", font, paint, new SKPoint(MARGIN_LEFT, y)) + MARGIN_TOP; + +canvas.Dispose(); +await Connection.SetImageAsync(img); ``` -### 3. Publish for Both Platforms +Each `DrawTextLine` call returns the Y position for the next line based on `font.Spacing`. Adding `MARGIN_TOP` provides a small gap between lines. + +--- + +## Migration Tiers + +Based on analysis of real-world plugins: + +### Tier 1: No Direct System.Drawing Usage (~52% of plugins) + +Plugins that only use library helpers (`GenerateKeyImage`, `SetImageAsync`, `SplitToFitKey`, etc.) without importing `System.Drawing` in their own code. + +**What to do:** +1. Upgrade `StreamDeck-Tools` NuGet to v7.0 +2. Build -- you will see `[Obsolete]` warnings. Everything still works. +3. When ready, follow the recipes above to switch to SkiaSharp APIs and eliminate warnings. + +### Tier 2: Common System.Drawing Patterns (~22% of plugins) + +Plugins that use `SolidBrush`, `ColorTranslator.FromHtml`, `Font`, `Image.FromFile`, or `Graphics.DrawString` in their own code. Migration is mechanical renaming using the mapping tables above. + +**What to do:** +1. Upgrade the NuGet package +2. Add `using SkiaSharp;` to files with warnings +3. Replace System.Drawing types with SkiaSharp equivalents using the type mapping and recipes + +### Tier 3: Deep GDI+ Usage (~26% of plugins) + +Plugins that use GDI+ APIs without library wrappers: + +| GDI+ API | SkiaSharp Equivalent | +|---|---| +| `GraphicsPath`, `AddString`, `StringFormat` | `SKPath`, `SKCanvas.DrawTextOnPath` | +| `LockBits`, `BitmapData`, `SetPixel` | `SKBitmap.GetPixels()`, `SKBitmap.SetPixel()` | +| `RotateTransform`, matrix operations | `SKCanvas.RotateDegrees()`, `SKCanvas.SetMatrix()` | +| `Graphics.FromImage` rendering pipelines | `new SKCanvas(bitmap)` | + +**What to do:** +1. Upgrade the NuGet package +2. Add a direct `SkiaSharp` NuGet reference to your plugin project +3. Rewrite GDI+ drawing logic using SkiaSharp native APIs + +### Quick Triage Flowchart -```powershell -dotnet publish -c Release -r win-x64 -dotnet publish -c Release -r osx-x64 +``` +Does your plugin import System.Drawing in its own code? + | + +-- NO --> Tier 1: Upgrade the NuGet package. Done. + | + +-- YES --> Does it use GraphicsPath, LockBits, RotateTransform, or SetPixel? + | + +-- NO --> Tier 2: Mechanical rename using the mapping tables. + | + +-- YES --> Tier 3: Rewrite GDI+ logic with SkiaSharp. ``` -Assemble both outputs into your `.sdPlugin` folder with `win-x64/` and `osx-x64/` subdirectories, alongside shared assets (manifest, images, Property Inspector). +--- ## Deprecation Timeline | Release | What Happens | |---|---| -| **v7.0** (current) | System.Drawing APIs marked `[Obsolete]` with guidance. Everything still works on Windows. SkiaSharp APIs available in parallel. | -| **v7.x** | Stability period. No removals. | -| **v8.0** (future) | System.Drawing APIs evaluated for removal based on adoption. | +| **v7.0** (current) | All System.Drawing APIs marked `[Obsolete]` with replacement guidance. Everything still compiles and works on Windows. SkiaSharp APIs available in parallel. | +| **v7.x** | Stability and feedback period. No removals. Additional helpers may be added based on community feedback. | +| **v8.0** (future) | System.Drawing APIs evaluated for removal. Removal only after sufficient migration window. | + +--- ## Compatibility Notes -- System.Drawing APIs continue to work on **Windows** across all TFMs. -- For **macOS/Linux**, you must use the SkiaSharp APIs. System.Drawing throws `PlatformNotSupportedException` on non-Windows. -- `TitleParameters` is unchanged. The new SkiaSharp properties (`TitleSKColor`, `TitleTypeface`) are additive. -- SkiaSharp 3.x is MIT-licensed. No licensing impact. +- All System.Drawing APIs continue to work on **Windows** across all four TFMs. +- For **macOS/Linux** with `net8.0`/`net10.0`, you **must** use the SkiaSharp APIs. System.Drawing throws `PlatformNotSupportedException`. +- `TitleParameters` constructor and properties are **unchanged**. The SkiaSharp properties are purely additive. +- Exact pixel parity between System.Drawing and SkiaSharp text rendering is not guaranteed. Functional parity is the target. +- SkiaSharp 3.119.2 is MIT-licensed. No licensing impact on plugin developers. + +--- + +## Required `using` Directives -## Complete Example: Cross-Platform Plugin Action +When migrating to SkiaSharp APIs, add these to your files: + +```csharp +using SkiaSharp; +using BarRaider.SdTools; // SkiaTools, SkiaGraphicsTools, SkiaExtensionMethods +``` + +--- + +## Complete Sample: Cross-Platform Plugin Action ```csharp using BarRaider.SdTools; @@ -279,14 +484,10 @@ public class MyAction : KeypadBase using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas)) { canvas.Clear(SKColors.DarkBlue); - - using (var font = SkiaTools.CreateFont("Arial", 18, SKFontStyle.Bold)) - using (var paint = new SKPaint { Color = SKColors.White, IsAntialias = true }) - { - float x = canvas.GetTextCenter("Pressed!", image.Width, font); - canvas.DrawText("Pressed!", x, image.Height / 2f, font, paint); - } - + using var font = SkiaTools.CreateFont("Arial", 18, SKFontStyle.Bold); + using var paint = new SKPaint { Color = SKColors.White, IsAntialias = true }; + float x = canvas.GetTextCenter("Pressed!", image.Width, font); + canvas.DrawTextLine("Pressed!", font, paint, new SKPoint(x, image.Height / 2f - 10)); canvas.Dispose(); await Connection.SetImageAsync(image); } diff --git a/NUGET.md b/NUGET.md index 40f1ec0..7638bb1 100644 --- a/NUGET.md +++ b/NUGET.md @@ -50,7 +50,7 @@ public class MyAction : KeypadBase ## Migrating from v6.x -See the full [Migration Guide](https://github.com/BarRaider/streamdeck-tools/blob/master/MIGRATION.md) for type mapping tables, code recipes, and step-by-step instructions. +See the full [Migration Guide](https://github.com/BarRaider/streamdeck-tools/blob/master/MigrateTo7.0.md) for type mapping tables, code recipes, and step-by-step instructions. ## Resources diff --git a/README.md b/README.md index 754647d..a3d45f8 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Introducing our new [wiki](https://github.com/BarRaider/streamdeck-tools/wiki) p - **Breaking change (macOS):** `TitleParameters.FontFamily` throws `PlatformNotSupportedException` on non-Windows. Use `TitleParameters.FontFamilyName` (string) or `TitleParameters.TitleTypeface` (SKTypeface) instead. On Windows, `FontFamily` still works but produces an `[Obsolete]` compiler warning. - New `TitleParameters.FontFamilyName`, `TitleSKColor`, `TitleTypeface`, `FontStyleToSKFontStyle()` for cross-platform rendering. - `PluginBase` is now `[Obsolete]` -- use `KeypadBase`, `EncoderBase`, or `KeyAndEncoderBase`. -- **[Migration Guide](MIGRATION.md)**: Full guide with type mapping tables, code recipes, and migration tiers. +- **[Migration Guide](MigrateTo7.0.md)**: Full guide with type mapping tables, code recipes, and migration tiers. ### Version 6.4 - Support for Stream Deck Plus XL, Galleon 100 SD diff --git a/barraider-sdtools/README.md b/barraider-sdtools/README.md new file mode 100644 index 0000000..3d4e7d0 --- /dev/null +++ b/barraider-sdtools/README.md @@ -0,0 +1,98 @@ +# BarRaider's Stream Deck Tools + +#### C# library that wraps all the communication with the Stream Deck App, allowing you to focus on actually writing the Plugin's logic. + +[![Build Status](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnetcore.yml/badge.svg)](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnetcore.yml)  [![NuGet](https://img.shields.io/nuget/v/streamdeck-tools.svg?style=flat)](https://www.nuget.org/packages/streamdeck-tools) + +**Author's website and contact information:** [https://barraider.com](https://barraider.com) + +# Stream Deck+ Support +Instead of `PluginBase`, Derive from either `KeypadBase` (if you don't support dials), `EncoderBase` (for only dials), `KeyAndEncoderBase` (for both keys and dials) + +# Getting Started +Introducing our new [wiki](https://github.com/BarRaider/streamdeck-tools/wiki) packed with usage instructions, examples and more. + +# Dev Discussions / Support +**Discord:** Discuss in #developers-chat in [Bar Raiders](http://discord.barraider.com) + +## Downloadable Resources +* [StreamDeck-Tools Template](https://github.com/BarRaider/streamdeck-tools/raw/master/utils/StreamDeck-Tools%20Template.vsix) for Visual Studio (2019/2022) - Automatically creates a project with all the files needed to compile a plugin. This is the best way to start a new plugin! +* [Install.bat](https://github.com/BarRaider/streamdeck-tools/blob/master/utils/install.bat) - Script that quickly uninstalls and reinstalls your plugin on the streamdeck (edit the batch file for more details). Put the install.bat file in your BIN folder (same folder that has Debug/Release sub-folders) +* [EasyPI](https://github.com/BarRaider/streamdeck-easypi) - Additional library used to easily pass information from the PI (Property Inspector) to your plugin. +* [Profiles](https://barraider.com/profiles) Downloadable empty profiles for the XL (32-key), Classic (15-key), Mini (6-key) and Mobile devices at https://barraider.com/profiles + +## Library Features +- Encapsulates all the communicating with the Stream Deck, getting a plugin working on the Stream Deck only requires implementing the PluginBase class. +- Sample plugin now included in this project on Github +- Built-in integration with NLog. Use `Logger.LogMessage()` for logging. +- Auto-populate user settings which were modified by the Property Inspector +- Access the Global Settings from anywhere in your code +- Simplified working with filenames from the Stream Deck SDK. +- `PluginActionId` attribute let's you easily associate your code to a specific action defined in the manifest.json +- Large set of helper functions to simplify creating images and sending them to the Stream Deck. + +# Change Log + +### Version 7.0 +- **Cross-platform support**: New SkiaSharp-based API surface (`SkiaTools`, `SkiaGraphicsTools`, `SkiaExtensionMethods`) for Windows + macOS. +- **Target frameworks**: netstandard2.0, net48, net8.0, net10.0. +- All `System.Drawing`-based APIs marked `[Obsolete]` with migration guidance. +- New `SetImageAsync(SKBitmap)` and `SetImageAsync(byte[])` overloads on `ISDConnection`. +- New `DrawTextLine` extension method on `SKCanvas` with Y-as-top semantics (matching `System.Drawing.Graphics.DrawString`). +- **Breaking change (macOS):** `TitleParameters.FontFamily` throws `PlatformNotSupportedException` on non-Windows. Use `TitleParameters.FontFamilyName` (string) or `TitleParameters.TitleTypeface` (SKTypeface) instead. +- New `TitleParameters.FontFamilyName`, `TitleSKColor`, `TitleTypeface`, `FontStyleToSKFontStyle()` for cross-platform rendering. +- `PluginBase` is now `[Obsolete]` -- use `KeypadBase`, `EncoderBase`, or `KeyAndEncoderBase`. +- See [MigrateTo7.0.md](MigrateTo7.0.md) for the full migration guide with code recipes and API mapping tables. + +### Version 6.4 +- Support for Stream Deck Plus XL, Galleon 100 SD + +### Version 6.3 +- Support for new Stream Deck types + +### Version 6.2 +- Support for .NET 8.0 + +### Version 6.1 +- Support for new `DialDown` and `DialUp` events. +- Removed support for deprecated `DialPress` event + +### Version 6.0 +1. Merged streamdeck-client-csharp package into library to allow better logging of errors +2. Added support for SD+ SDK +3. Increased timeout of connection to Stream Deck due to the Stream Deck taking longer than before to reply on load +4. Added error catching to prevent 3rd party plugin exception to impact communication + + +### Version 3.2 is out! +- Created new `ISDConnection` interface which is now implemented by SDConnection and used by PluginAction. +- GlobalSettingsManager now has a short delay before calling GetGlobalSettings(), to reduce spamming the Stream Deck SDK. +- Updated dependencies to latest version + +### Version 3.1 is out! +- Updated Logger class to include process name and thread id + +### Version 3.0 is out! +- Updated file handling in `Tools.AutoPopulateSettings` and `Tools.FilenameFromPayload` methods +- Removed obsolete MD5 functions, use SHA512 functions instead +- `Tools.CenterText` function now has optional out `textFitsImage` value to verify the text does not exceed the image width +- New `Tools.FormatBytes` function converts bytes to human-readable value +- New `Graphics.GetFontSizeWhereTextFitsImage` function helps locate the best size for a text to fit an image on 1 line +- Updated dependency packages to latest versions +- Bug fix where FileNameProperty attribute + +### Version 2.7 is out! +- Fully wrapped all Stream Deck events (All part of the SDConneciton class). See ***"Subscribing to events"*** section below +- Added extension methods for multiple classes related to brushes/colors +- Added additional methods under the Tools class, including AddTextPathToGraphics which can be used to correctly position text on a key image based on the Text Settings in the Property Inspector see ***"Showing Title based on settings from Property Inspector"*** section below. +- Additional error checking +- Updated dependency packages to latest versions +- Sample plugin now included in this project on Github + +### 2019-11-17 +- Updated Install.bat (above) to newer version + +### Version 2.6 is out! +- Added new MD5 functions in the `Tools` helper class +- Optimized SetImage to not resubmit an image that was just posted to the device. Can be overridden with new property in Connection.SetImage() function. + From bba216e6868dccc9ec296359f23e54042bf8b009 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:54:56 +0300 Subject: [PATCH 30/32] Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a3d45f8..72462fe 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,9 @@ Introducing our new [wiki](https://github.com/BarRaider/streamdeck-tools/wiki) p - **Breaking change (macOS):** `TitleParameters.FontFamily` throws `PlatformNotSupportedException` on non-Windows. Use `TitleParameters.FontFamilyName` (string) or `TitleParameters.TitleTypeface` (SKTypeface) instead. On Windows, `FontFamily` still works but produces an `[Obsolete]` compiler warning. - New `TitleParameters.FontFamilyName`, `TitleSKColor`, `TitleTypeface`, `FontStyleToSKFontStyle()` for cross-platform rendering. - `PluginBase` is now `[Obsolete]` -- use `KeypadBase`, `EncoderBase`, or `KeyAndEncoderBase`. -- **[Migration Guide](MigrateTo7.0.md)**: Full guide with type mapping tables, code recipes, and migration tiers. + +## Migration Guide: +- See **[Migration Guide](MigrateTo7.0.md)**: or the full migration guide with code recipes and API mapping tables. (**Pro-Tip:** Use this file to let Cursor/Claude do the migration for you). ### Version 6.4 - Support for Stream Deck Plus XL, Galleon 100 SD From 7c416bec384b358161188395dc40b2f7bfbbee67 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:55:38 +0300 Subject: [PATCH 31/32] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 72462fe..3780ee0 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,12 @@ - All `System.Drawing`-based APIs marked `[Obsolete]` with migration guidance. - New `SetImageAsync(SKBitmap)` and `SetImageAsync(byte[])` overloads on `ISDConnection`. - New `DrawTextLine` extension method on `SKCanvas` with Y-as-top semantics (matching `System.Drawing.Graphics.DrawString`). -- **Breaking change (macOS):** `TitleParameters.FontFamily` throws `PlatformNotSupportedException` on non-Windows. Use `TitleParameters.FontFamilyName` (string) or `TitleParameters.TitleTypeface` (SKTypeface) instead. +- **Breaking change (macOS):** `TitleParameters.FontFamily` throws `PlatformNotSupportedException` on non-Windows. Use `TitleParameters.FontFamilyName` (string) or `TitleParameters.TitleTypeface` (SKTypeface) instead. On Windows, `FontFamily` still works but produces an `[Obsolete]` compiler warning. - New `TitleParameters.FontFamilyName`, `TitleSKColor`, `TitleTypeface`, `FontStyleToSKFontStyle()` for cross-platform rendering. - `PluginBase` is now `[Obsolete]` -- use `KeypadBase`, `EncoderBase`, or `KeyAndEncoderBase`. ## Migration Guide: -- See [MIGRATION.md](MIGRATION.md) for the full migration guide with code recipes and API mapping tables. (**Pro-Tip:** Use this file to let Cursor/Claude do the migration for you). +- See **[Migration Guide](MigrateTo7.0.md)**: or the full migration guide with code recipes and API mapping tables. (**Pro-Tip:** Use this file to let Cursor/Claude do the migration for you). # Stream Deck+ Support Instead of `PluginBase`, Derive from either `KeypadBase` (if you don't support dials), `EncoderBase` (for only dials), `KeyAndEncoderBase` (for both keys and dials) From f6823b145f438b1ed9d44d6a6b740c5398f7cdb4 Mon Sep 17 00:00:00 2001 From: BarRaider <46548278+BarRaider@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:56:18 +0300 Subject: [PATCH 32/32] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3780ee0..c87e190 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ - `PluginBase` is now `[Obsolete]` -- use `KeypadBase`, `EncoderBase`, or `KeyAndEncoderBase`. ## Migration Guide: -- See **[Migration Guide](MigrateTo7.0.md)**: or the full migration guide with code recipes and API mapping tables. (**Pro-Tip:** Use this file to let Cursor/Claude do the migration for you). +- See **[Migration Guide](MigrateTo7.0.md)**: for the full migration guide with code recipes and API mapping tables. (**Pro-Tip:** Use this file to let Cursor/Claude do the migration for you). # Stream Deck+ Support Instead of `PluginBase`, Derive from either `KeypadBase` (if you don't support dials), `EncoderBase` (for only dials), `KeyAndEncoderBase` (for both keys and dials) @@ -57,7 +57,7 @@ Introducing our new [wiki](https://github.com/BarRaider/streamdeck-tools/wiki) p - `PluginBase` is now `[Obsolete]` -- use `KeypadBase`, `EncoderBase`, or `KeyAndEncoderBase`. ## Migration Guide: -- See **[Migration Guide](MigrateTo7.0.md)**: or the full migration guide with code recipes and API mapping tables. (**Pro-Tip:** Use this file to let Cursor/Claude do the migration for you). +- See **[Migration Guide](MigrateTo7.0.md)**: for the full migration guide with code recipes and API mapping tables. (**Pro-Tip:** Use this file to let Cursor/Claude do the migration for you). ### Version 6.4 - Support for Stream Deck Plus XL, Galleon 100 SD