Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e472c2e
Codebase audit: fix 44 verified bugs, races and hot-path allocations …
logicallysynced Jun 10, 2026
51ab641
Add 4.3.1 changelog entry + bump nick-fields/retry v3 -> v4 in CI (PR…
logicallysynced Jun 10, 2026
f9d43b6
Bump Sharlayan 9.0.39 -> 9.1.1-prerelease.58 + DrawnCards array migra…
logicallysynced Jun 11, 2026
075a976
Update changelog
logicallysynced Jun 11, 2026
2cb830a
Add Focus Target HP + Focus Target Castbar dynamic layers, layer-file…
logicallysynced Jun 11, 2026
3abaf2e
Delete dead BaseLayerProcessorFactory + DynamicLayerProcessorFactory …
logicallysynced Jun 11, 2026
939d020
Update changelog
logicallysynced Jun 11, 2026
1efdbfa
Add 12 lighting decorators (Snowstorm, Pinwheel, Blue Particles, Clou…
logicallysynced Jun 12, 2026
31685a1
Effect layers: own ledgroup per effect + fixed z-order for simultaneo…
logicallysynced Jun 12, 2026
f1e740a
Decorator iteration: pinwheel outward flow, reactive one-shot mode, w…
logicallysynced Jun 12, 2026
fd767bc
Add Casting Success + Status Inflicted effects, refresh Status Effect…
logicallysynced Jun 12, 2026
994012e
Status Effects palette: reconcile names against detrimental status re…
logicallysynced Jun 12, 2026
3d3c8f7
Status Effects palette: full detrimental catalogue from game data + p…
logicallysynced Jun 12, 2026
f6c01cc
Bump Sharlayan 9.1.1 -> 9.1.2-prerelease.59, gate Status Inflicted on…
logicallysynced Jun 13, 2026
b17abc6
Slow the Casting Success and Status Inflicted ripples (v4.3.10 -> v4.…
logicallysynced Jun 13, 2026
7b8eb0c
Status palette: default colours sampled from each status icon's centr…
logicallysynced Jun 13, 2026
7e95150
LIFX + Hue: retry state capture and restore so close reliably returns…
logicallysynced Jun 13, 2026
97a720e
Bump Sharlayan 9.1.2-prerelease.59 -> 9.1.2 stable (v4.3.13 -> v4.3.14)
logicallysynced Jun 13, 2026
38e517b
CI: bump actions/checkout v6 -> v7 (supersedes Dependabot PR #205)
logicallysynced Jul 4, 2026
a2db338
Isolate provider assembly loads so App Control blocks degrade instead…
logicallysynced Jul 4, 2026
e25ff6d
Add Nanoleaf smart-light provider (Beta) (v4.3.15 -> v4.3.16)
logicallysynced Jul 5, 2026
eb99394
Fix continuous crash in Nanoleaf mDNS discovery (v4.3.16 -> v4.3.17)
logicallysynced Jul 5, 2026
c26993c
Nanoleaf discovery: exception-free receive loop (v4.3.17 -> v4.3.18)
logicallysynced Jul 5, 2026
8d2dc37
Nanoleaf: full-implementation review fixes (v4.3.18 -> v4.3.19)
logicallysynced Jul 5, 2026
dfebcee
Nanoleaf REST client: no-throw pre-flight gate (v4.3.19 -> v4.3.20)
logicallysynced Jul 5, 2026
120f34f
Nanoleaf panel-identity persistence + parallel shutdown restore (v4.3…
logicallysynced Jul 5, 2026
1ea2910
PR review fixes: shutdown leak, effects-list race, palette renames, L…
logicallysynced Jul 6, 2026
9d6e82e
Sentry fixes: settings-load App Control fault, duplicate adopted-bulb…
logicallysynced Jul 7, 2026
582328d
Update changelog
logicallysynced Jul 7, 2026
5e743e1
Review fixes: working JIT isolation, teardown on every Setup exit, re…
logicallysynced Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Setup .NET 10
uses: actions/setup-dotnet@v5
Expand All @@ -59,7 +59,7 @@ jobs:
# build to absorb transient feed flakiness without needing a
# manual re-run.
- name: Restore
uses: nick-fields/retry@v3
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -242,4 +242,6 @@ _Pvt_Extensions/
ModelManifest.xml
*.snk

.fake
.fake
# Local design notes and ADRs - not part of the repo
/docs/
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

All notable changes to Chromatics are documented here.

## 4.3.24

- **New:** Nanoleaf smart-light support (Beta). Covers the panel family (Shapes, Canvas, Elements, Lines, Aurora) and other controllers that speak the Nanoleaf OpenAPI. Enable it from Settings → Device Providers and pair each controller with a one-time button press; effects render across the panels at their real physical positions. Layer assignments stay with their panels if you add or remove panels from the wall later. Essentials bulbs and strips are not supported.
- Chromatics now closes faster when several smart-light brands are enabled: LIFX, Hue, and Nanoleaf devices restore their original state in parallel instead of one brand at a time.
- Fixed a startup crash on PCs where Windows App Control or Smart App Control blocks one of the bundled device libraries. Chromatics now starts with that provider disabled, logs which file was blocked, and explains how to allow it. This also covers the case where the core lighting library (RGB.NET) itself is blocked during settings load.
- Fixed a crash when opening the Hue, LIFX, or Yeelight device picker with the same bulb saved twice in settings.

## 4.3.14

- Added new dynamic layers for Focus Target HP and Focus Target Castbar.
- Added new effects for Status Inflicted and Casting Success.
- Added new status effects category and items to Palette tab.
- Fixed a bug which caused the Experience Tracker layer to not work with Reaper and Sage.
- Fixed the Keybinds layer misreading hotbar actions with high action ids.
- Updated Sharlayan to latest version
- Various performance improvements and bug fixes

## 4.2.73

- Added support for FFXIV 7.51
Expand Down
314 changes: 308 additions & 6 deletions Chromatics.DecoratorHarnessUI/MainViewModel.cs

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions Chromatics.DecoratorHarnessUI/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,34 @@
<TextBlock Text="Wedge (degrees)"/>
<NumericUpDown Value="{Binding ParamWedgeDegrees}" Minimum="5" Maximum="180" Increment="5" FormatString="0"/>
</StackPanel>
<StackPanel Spacing="2" Margin="0,0,8,4" Width="140" IsVisible="{Binding ShowFanCount}">
<TextBlock Text="Fans"/>
<NumericUpDown Value="{Binding ParamFanCount}" Minimum="1" Maximum="16" FormatString="0"/>
</StackPanel>
<StackPanel Spacing="2" Margin="0,0,8,4" Width="140" IsVisible="{Binding ShowTwist}">
<TextBlock Text="Spiral Twist"/>
<NumericUpDown Value="{Binding ParamTwist}" Minimum="-2" Maximum="2" Increment="0.1" FormatString="0.##"/>
</StackPanel>
<StackPanel Spacing="2" Margin="0,0,8,4" Width="140" IsVisible="{Binding ShowOpacity}">
<TextBlock Text="Opacity (0-1)"/>
<NumericUpDown Value="{Binding ParamOpacity}" Minimum="0" Maximum="1" Increment="0.05" FormatString="0.##"/>
</StackPanel>
<StackPanel Spacing="2" Margin="0,0,8,4" Width="140" IsVisible="{Binding ShowStartKey}">
<TextBlock Text="Starting Key"/>
<ComboBox ItemsSource="{Binding StartKeys}" SelectedItem="{Binding ParamStartKey}" Width="130"/>
</StackPanel>
<StackPanel Spacing="2" Margin="0,0,8,4" Width="200" IsVisible="{Binding ShowSnowDir}">
<TextBlock Text="Snow Direction"/>
<ComboBox ItemsSource="{Binding SnowDirections}" SelectedItem="{Binding ParamSnowDir}" Width="190"/>
</StackPanel>
<StackPanel Spacing="2" Margin="0,0,8,4" Width="140" IsVisible="{Binding ShowOneShot}">
<TextBlock Text="One Shot"/>
<ToggleSwitch IsChecked="{Binding ParamOneShot}" OnContent="On" OffContent="Off"/>
</StackPanel>
<StackPanel Spacing="2" Margin="0,0,8,4" Width="140" IsVisible="{Binding ShowWireDir}">
<TextBlock Text="Pillar Mode"/>
<ComboBox ItemsSource="{Binding WireframeDirections}" SelectedItem="{Binding ParamWireDir}" Width="130"/>
</StackPanel>
</WrapPanel>

<Separator Margin="0,4"/>
Expand Down
2 changes: 1 addition & 1 deletion Chromatics.Tests/Chromatics.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Sharlayan" Version="9.0.39" />
<PackageReference Include="Sharlayan" Version="9.1.2" />
</ItemGroup>

</Project>
90 changes: 90 additions & 0 deletions Chromatics.Tests/Extensions/Nanoleaf/NanoleafDiscoveryLoopTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using Chromatics.Extensions.RGB.NET.Devices.Nanoleaf.Protocol;
using System.Runtime.ExceptionServices;

namespace Chromatics.Tests.Extensions.Nanoleaf;

// Pins the discovery loop's no-throw contract. The first shipped loop used
// 1-second cancellation slices, which threw OperationCanceledException every
// quiet second as control flow. The exceptions were caught, but a debugger
// with break-on-thrown enabled halts on each one - which presents to the
// developer as the app crashing seconds after discovery starts. A healthy
// sweep must not throw at all.
public class NanoleafDiscoveryLoopTests
{
[Fact]
public async Task DiscoverAsync_QuietSweep_ThrowsNoCancellationExceptions()
{
int cancellationThrows = 0;
EventHandler<FirstChanceExceptionEventArgs> recorder = (_, e) =>
{
// Match the production class only: test classes in this suite
// carry "NanoleafDiscovery" in their names and throw their own
// cancellations on parallel workers, so a loose substring counts
// their noise as ours.
if (e.Exception is OperationCanceledException &&
Environment.StackTrace.Contains("Protocol.NanoleafDiscovery"))
{
Interlocked.Increment(ref cancellationThrows);
}
};

AppDomain.CurrentDomain.FirstChanceException += recorder;
try
{
await NanoleafDiscovery.DiscoverAsync(TimeSpan.FromMilliseconds(2500));
}
finally
{
AppDomain.CurrentDomain.FirstChanceException -= recorder;
}

Assert.Equal(0, cancellationThrows);
}

// A failed manual-IP probe is the expected outcome for a wrong address,
// so it must report null without throwing anything - the first version
// threw TaskCanceledException from HttpClient.Timeout as its "nothing
// there" signal.
[Fact]
public async Task Probe_UnreachableOrWrongIp_ReturnsNullWithoutThrowing()
{
int throwsInProbe = 0;
EventHandler<FirstChanceExceptionEventArgs> recorder = (_, e) =>
{
// "<ProbeAsync>" matches only the production state machine; this
// test's own frames render as "<Probe_UnreachableOrWrongIp...>".
if (Environment.StackTrace.Contains("<ProbeAsync>"))
Interlocked.Increment(ref throwsInProbe);
};

AppDomain.CurrentDomain.FirstChanceException += recorder;
try
{
// Refused: loopback port with no listener answers immediately.
var refused = await NanoleafDiscovery.ProbeAsync("127.0.0.1", GetClosedTcpPort(), TimeSpan.FromSeconds(2));
// Blackhole: TEST-NET-1 is reserved, so the connect times out.
var timedOut = await NanoleafDiscovery.ProbeAsync("192.0.2.1", 16021, TimeSpan.FromMilliseconds(500));
// Not an IP at all.
var invalid = await NanoleafDiscovery.ProbeAsync("not-an-ip", 16021, TimeSpan.FromMilliseconds(100));

Assert.Null(refused);
Assert.Null(timedOut);
Assert.Null(invalid);
}
finally
{
AppDomain.CurrentDomain.FirstChanceException -= recorder;
}

Assert.Equal(0, throwsInProbe);
}

private static int GetClosedTcpPort()
{
var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
int port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using Chromatics.Extensions.RGB.NET.Devices.Nanoleaf.Protocol;
using System.Net;
using System.Net.Sockets;

namespace Chromatics.Tests.Extensions.Nanoleaf;

// Pins the root cause of the discovery crash and proves the fix.
//
// Mechanism: Windows queues an ICMP "port unreachable" received for any
// earlier send as a SocketException on the socket's NEXT receive, and keeps
// rethrowing it on every receive after that. The original discovery loop
// only caught OperationCanceledException, so one ICMP reset became a
// repeated throw from DiscoverAsync - the continuous crash reported when
// testing the provider. The fix disables that reporting per-socket via the
// SIO_UDP_CONNRESET ioctl (CreateDiscoverySocket) and handles
// SocketException per-iteration as a backstop.
public class NanoleafDiscoverySocketTests
{
private static int GetClosedUdpPort()
{
using var probe = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)probe.Client.LocalEndPoint!).Port;
// disposed on return - the port is closed again
}

// Documents the OS mechanism the bug rode on: a default-configured
// socket that sent to a closed port throws SocketException on receive.
// If this ever stops failing on a future Windows build, the ioctl is
// redundant but harmless.
[Fact]
public async Task DefaultSocket_ReceiveAfterIcmpReset_ThrowsSocketException()
{
using var udp = new UdpClient(AddressFamily.InterNetwork);
udp.Client.Bind(new IPEndPoint(IPAddress.Loopback, 0));

bool gotReset = false;
for (int i = 0; i < 5 && !gotReset; i++)
{
// Fresh closed port per attempt: another process grabbing the
// probed port between attempts must not starve the test of ICMP.
var closedTarget = new IPEndPoint(IPAddress.Loopback, GetClosedUdpPort());
await udp.SendAsync(new byte[] { 0 }, 1, closedTarget);
try
{
using var cts = new CancellationTokenSource(200);
await udp.ReceiveAsync(cts.Token);
}
catch (SocketException) { gotReset = true; }
catch (OperationCanceledException) { /* ICMP not queued yet; send again */ }
}

Assert.True(gotReset, "Expected Windows to surface the ICMP reset as a SocketException on receive.");
}

// The fix: the production discovery socket takes the same ICMP resets
// without throwing, and still receives real datagrams afterwards.
[Fact]
public async Task DiscoverySocket_SurvivesIcmpReset_AndStillReceives()
{
using var udp = NanoleafDiscovery.CreateDiscoverySocket();
int ourPort = ((IPEndPoint)udp.Client.LocalEndPoint!).Port;
var closedTarget = new IPEndPoint(IPAddress.Loopback, GetClosedUdpPort());

// Provoke the resets. A SocketException here fails the test - that
// is the point.
for (int i = 0; i < 5; i++)
{
await udp.SendAsync(new byte[] { 0 }, 1, closedTarget);
try
{
using var cts = new CancellationTokenSource(100);
await udp.ReceiveAsync(cts.Token);
}
catch (OperationCanceledException) { /* quiet is the expected outcome */ }
}

// The socket must still be usable for real traffic.
var payload = new byte[] { 0xAB, 0xCD };
using var sender = new UdpClient(AddressFamily.InterNetwork);
await sender.SendAsync(payload, payload.Length, new IPEndPoint(IPAddress.Loopback, ourPort));

using var recvCts = new CancellationTokenSource(2000);
var result = await udp.ReceiveAsync(recvCts.Token);
Assert.Equal(payload, result.Buffer);
}
}
62 changes: 62 additions & 0 deletions Chromatics.Tests/Extensions/Nanoleaf/NanoleafPanelSlotsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using Chromatics.Extensions.RGB.NET.Devices.Nanoleaf;

namespace Chromatics.Tests.Extensions.Nanoleaf;

// The slot table maps panelId -> LedId.Custom slot, and layer assignments
// reference LedIds, so the resolver's one job is keeping slots stable while
// panels come and go from the wall.
public class NanoleafPanelSlotsTests
{
[Fact]
public void FreshAdoption_AssignsSlotsInAscendingPanelIdOrder()
{
var (slots, added, missing) = NanoleafPanelSlots.Resolve(null, new[] { 30, 10, 20 });

Assert.Equal(new[] { 10, 20, 30 }, slots);
Assert.Equal(new[] { 10, 20, 30 }, added);
Assert.Empty(missing);
}

[Fact]
public void RemovedPanel_KeepsItsSlot_SoLaterPanelsDoNotShift()
{
var stored = new[] { 10, 20, 30 };

var (slots, added, missing) = NanoleafPanelSlots.Resolve(stored, new[] { 10, 30 });

// Panel 20 is gone but its slot survives as a tombstone: panel 30
// stays at slot 2 (LedId.Custom3), keeping its layer assignments.
Assert.Equal(new[] { 10, 20, 30 }, slots);
Assert.Empty(added);
Assert.Equal(new[] { 20 }, missing);
}

[Fact]
public void AddedPanel_AppendsAtTheEnd()
{
var stored = new[] { 10, 20, 30 };

var (slots, added, missing) = NanoleafPanelSlots.Resolve(stored, new[] { 10, 20, 30, 5, 40 });

// New panels never displace existing slots, even when their
// panelIds sort below existing ones.
Assert.Equal(new[] { 10, 20, 30, 5, 40 }, slots);
Assert.Equal(new[] { 5, 40 }, added);
Assert.Empty(missing);
}

[Fact]
public void ReturningPanel_LandsBackOnItsOldSlot()
{
var stored = new[] { 10, 20, 30 };

// Session with panel 20 absent - tombstone holds slot 1.
var (afterRemoval, _, _) = NanoleafPanelSlots.Resolve(stored, new[] { 10, 30 });
// Panel 20 re-attached: no additions, no missing, same table.
var (afterReturn, added, missing) = NanoleafPanelSlots.Resolve(afterRemoval, new[] { 10, 20, 30 });

Assert.Equal(new[] { 10, 20, 30 }, afterReturn);
Assert.Empty(added);
Assert.Empty(missing);
}
}
Loading
Loading