Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to Chromatics are documented here.

## 4.3.34

- The console tab now shows which version of Chromatics you are running at startup.
- A device provider that fails to start now says so in the console tab, naming the provider and the reason. Providers that fail no longer look like they loaded, and the rest of your devices carry on loading as normal.

## 4.3.33

- Fixed lighting stopping for the rest of the session when the Screen Capture base layer was in use and you returned to the title or character select screen.
- Yeelight, LIFX, Nanoleaf, and Alienware lights that are switched off or off the network at startup now log as device notices instead of errors.
- Improved the accuracy and speed of game-data reads on FFXIV patch 7.55, including inventory, chat log, job gauges, and player stats.

## 4.3.31

- Added support for FFXIV patch 7.55.
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.1.3" />
<PackageReference Include="Sharlayan" Version="9.2.0-prerelease.64" />
</ItemGroup>

</Project>
21 changes: 21 additions & 0 deletions Chromatics.Tests/Core/ScreenCaptureProcessorLifetimeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Chromatics.Layers;

namespace Chromatics.Tests.Core;

public class ScreenCaptureProcessorLifetimeTests
{
[Fact]
public void Instance_IsRebuiltAfterDispose()
{
// DisposeAll runs every time the player returns to the title screen and
// nulls the processor's surface. Handing the disposed object back out
// left the Screen Capture base layer attaching to a dead surface on
// every later tick, which killed lighting for the rest of the session.
var first = ScreenCaptureProcessor.Instance;
first.Dispose();

var second = ScreenCaptureProcessor.Instance;

Assert.NotSame(first, second);
}
}
53 changes: 53 additions & 0 deletions Chromatics.Tests/Helpers/NetworkFailureHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System;
using System.IO;
using System.Net.Http;
using System.Net.Sockets;
using Chromatics.Helpers;

namespace Chromatics.Tests.Helpers;

public class NetworkFailureHelperTests
{
[Fact]
public void ConnectionRefused_IsUnreachable()
{
var ex = new SocketException((int)SocketError.ConnectionRefused);

Assert.True(NetworkFailureHelper.IsUnreachable(ex));
}

[Fact]
public void SocketFailureWrappedInIoException_IsUnreachable()
{
var ex = new IOException("write failed", new SocketException((int)SocketError.HostUnreachable));

Assert.True(NetworkFailureHelper.IsUnreachable(ex));
}

[Fact]
public void HttpAndTimeoutFailures_AreUnreachable()
{
Assert.True(NetworkFailureHelper.IsUnreachable(new HttpRequestException("no route")));
Assert.True(NetworkFailureHelper.IsUnreachable(new TimeoutException()));
Assert.True(NetworkFailureHelper.IsUnreachable(new TaskCanceledException()));
}

[Fact]
public void SocketFailureInsideAggregate_IsUnreachable()
{
var ex = new AggregateException(
new InvalidOperationException("unrelated"),
new SocketException((int)SocketError.TimedOut));

Assert.True(NetworkFailureHelper.IsUnreachable(ex));
}

[Fact]
public void ApplicationBugs_AreNotUnreachable()
{
Assert.False(NetworkFailureHelper.IsUnreachable(new NullReferenceException()));
Assert.False(NetworkFailureHelper.IsUnreachable(new InvalidOperationException()));
Assert.False(NetworkFailureHelper.IsUnreachable(
new InvalidOperationException("outer", new ArgumentOutOfRangeException())));
}
}
2 changes: 1 addition & 1 deletion Chromatics.Tests/Helpers/UpdateNotesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public void EmptyOrWhitespaceNotes_ReturnEmpty()
{
Assert.Equal(string.Empty, UpdateService.TrimNotesToOwnSection("", "4.3.26"));
Assert.Equal(string.Empty, UpdateService.TrimNotesToOwnSection(" ", "4.3.26"));
Assert.Equal(string.Empty, UpdateService.TrimNotesToOwnSection(null, "4.3.26"));
Assert.Equal(string.Empty, UpdateService.TrimNotesToOwnSection(null!, "4.3.26"));
}

[Fact]
Expand Down
4 changes: 2 additions & 2 deletions Chromatics/Chromatics.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<StartupObject>Chromatics.Program</StartupObject>
<Version>4.3.31.0</Version>
<Version>4.3.34.0</Version>
<Authors>Danielle Thompson</Authors>
<!-- ApplicationManifest is conditional: local Debug + Release builds embed
app.manifest (no fusion-identity <msix> element) so VS debug runs and
Expand Down Expand Up @@ -135,7 +135,7 @@
<PackageReference Include="RGB.NET.Devices.Wooting" Version="3.2.0" />
<PackageReference Include="RGB.NET.HID" Version="3.2.0" />
<PackageReference Include="RGB.NET.Presets" Version="3.2.0" />
<PackageReference Include="Sharlayan" Version="9.1.3" />
<PackageReference Include="Sharlayan" Version="9.2.0-prerelease.64" />
</ItemGroup>

<ItemGroup>
Expand Down
78 changes: 75 additions & 3 deletions Chromatics/Core/RGBController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1315,14 +1315,23 @@ public static bool LoadDeviceProvider(IRGBDeviceProvider provider, out Exception
showErrors = true;
#endif

provider.DevicesChanged += DevicesChanged;

var initError = LoadProviderWithDiagnostics(provider);

// Subscribed after the load so start-up failures are
// reported once, by the probe, with the provider named.
// This handler covers runtime errors from here on, and
// stays behind the user's preference — a load failure is
// actionable and reports either way.
if (showErrors)
provider.Exception += deviceExceptionEventHandler;

provider.DevicesChanged += DevicesChanged;

surface.Load(provider);
loadedDeviceProviders.Add(provider);

if (initError != null)
loadError ??= initError;

// Warn the user when a freshly-loaded provider gives us
// devices whose hardware/SDK can't accept per-LED writes
// (zone-only or single-colour fallback). Effects that
Expand Down Expand Up @@ -1421,6 +1430,69 @@ public static bool LoadDeviceProvider(IRGBDeviceProvider provider, out Exception

}

// Stand-in for surface.Load(provider) that reports what went wrong
// instead of swallowing it, without changing which failures are fatal.
//
// RGB.NET's Load calls Initialize(throwExceptions: false), and its
// Throw() only rethrows when that flag is set — otherwise it raises
// the Exception event and returns to the caller, which carries on.
// A provider whose native SDK is missing or refused to start ends up
// reporting success: the HID scan still lists the hardware, so the
// devices show up in the Mapping tab and never light. Nothing reaches
// the console tab and nothing reaches our caller.
//
// Initializing with throwExceptions: true lets the probe below decide
// per exception. Non-critical ones keep today's behaviour exactly
// (logged, provider keeps going); critical ones abort the provider and
// are returned to the caller. Either way the exception is caught here,
// so one bad provider never stops the others from loading.
private static Exception LoadProviderWithDiagnostics(IRGBDeviceProvider provider)
{
Exception captured = null;
var label = provider.GetType().Name;

void Probe(object sender, ExceptionEventArgs args)
{
captured ??= args.Exception;
args.Throw = args.IsCritical;
}

provider.Exception += Probe;
try
{
if (!provider.IsInitialized)
provider.Initialize(RGBDeviceType.All, throwExceptions: true);
}
catch (Exception ex)
{
captured ??= ex;
}
finally
{
provider.Exception -= Probe;
}

surface?.Attach(provider.Devices);

if (captured != null)
{
Logger.WriteConsole(Enums.LoggerTypes.Error,
$"[{label}] failed to start: {captured.Message}", forwardToSentry: false);
LogLogitechSdkHintIfNeeded(captured);
}
else if (provider.Devices.Count == 0)
{
// Loaded without complaint and handed us nothing. Benign for
// the smart-light providers when the user owns no bulbs, but
// for an SDK provider it usually means the vendor software
// isn't running, so say so rather than leave a dead toggle.
Logger.WriteConsole(Enums.LoggerTypes.Devices,
$"[{label}] loaded but reported no devices.", forwardToSentry: false);
}

return captured;
}

// Surface a one-time console warning per provider load when devices
// can't accept per-LED writes — zone-based or single-colour SDK
// fallbacks. Effects that depend on per-LED spatial position
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,10 @@ protected override IEnumerable<IRGBDevice> LoadDevices()
}
catch (Exception ex)
{
Logger.WriteConsole(LoggerTypes.Error,
$"[Alienware] failed to set up {def.Product}: {ex.Message}");
bool unreachable = Chromatics.Helpers.NetworkFailureHelper.IsUnreachable(ex);
Logger.WriteConsole(unreachable ? LoggerTypes.Devices : LoggerTypes.Error,
$"[Alienware] failed to set up {def.Product}: {ex.Message}",
forwardToSentry: !unreachable);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ private async Task<IEnumerable<IRGBDevice>> LoadDevicesAsync()
}
catch (Exception ex)
{
Logger.WriteConsole(LoggerTypes.Error, $"[LIFX] failed to set up {def.Label}: {ex.Message}");
bool unreachable = Chromatics.Helpers.NetworkFailureHelper.IsUnreachable(ex);
Logger.WriteConsole(unreachable ? LoggerTypes.Devices : LoggerTypes.Error,
$"[LIFX] failed to set up {def.Label}: {ex.Message}",
forwardToSentry: !unreachable);
}
}

Expand Down Expand Up @@ -162,7 +165,10 @@ private async Task<Dictionary<string, IPEndPoint>> ResolveEndpointsAsync(IList<L
}
catch (Exception ex)
{
Logger.WriteConsole(LoggerTypes.Error, $"[LIFX] discovery sweep failed: {ex.Message}");
bool unreachable = Chromatics.Helpers.NetworkFailureHelper.IsUnreachable(ex);
Logger.WriteConsole(unreachable ? LoggerTypes.Devices : LoggerTypes.Error,
$"[LIFX] discovery sweep failed: {ex.Message}",
forwardToSentry: !unreachable);
}

return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ private async Task<IEnumerable<IRGBDevice>> LoadDevicesAsync()
}
catch (Exception ex)
{
Logger.WriteConsole(LoggerTypes.Error, $"[Nanoleaf] failed to set up {def.Label}: {ex.Message}");
bool unreachable = Chromatics.Helpers.NetworkFailureHelper.IsUnreachable(ex);
Logger.WriteConsole(unreachable ? LoggerTypes.Devices : LoggerTypes.Error,
$"[Nanoleaf] failed to set up {def.Label}: {ex.Message}",
forwardToSentry: !unreachable);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,10 @@ private async Task<IEnumerable<IRGBDevice>> LoadDevicesAsync()
}
catch (Exception ex)
{
Logger.WriteConsole(LoggerTypes.Error,
$"[Yeelight] failed to set up {def.Label}: {ex.Message}");
bool unreachable = Chromatics.Helpers.NetworkFailureHelper.IsUnreachable(ex);
Logger.WriteConsole(unreachable ? LoggerTypes.Devices : LoggerTypes.Error,
$"[Yeelight] failed to set up {def.Label}: {ex.Message}",
forwardToSentry: !unreachable);
}
}

Expand All @@ -154,7 +156,10 @@ private async Task<Dictionary<string, IPEndPoint>> ResolveEndpointsAsync(IList<Y
}
catch (Exception ex)
{
Logger.WriteConsole(LoggerTypes.Error, $"[Yeelight] discovery sweep failed: {ex.Message}");
bool unreachable = Chromatics.Helpers.NetworkFailureHelper.IsUnreachable(ex);
Logger.WriteConsole(unreachable ? LoggerTypes.Devices : LoggerTypes.Error,
$"[Yeelight] discovery sweep failed: {ex.Message}",
forwardToSentry: !unreachable);
}
return result;
}
Expand Down
42 changes: 42 additions & 0 deletions Chromatics/Helpers/NetworkFailureHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;
using System.IO;
using System.Net.Http;
using System.Net.Sockets;

namespace Chromatics.Helpers
{
public static class NetworkFailureHelper
{
/// <summary>
/// True when an exception describes a network device that could not be
/// reached, rather than a fault in Chromatics. Callers log these for the
/// user but keep them out of Sentry, where a bulb switched off at the
/// wall would otherwise read as an application error.
/// </summary>
public static bool IsUnreachable(Exception ex)
{
for (var current = ex; current != null; current = current.InnerException)
{
switch (current)
{
case SocketException:
case TimeoutException:
case HttpRequestException:
case IOException:
case OperationCanceledException:
return true;
}

if (current is AggregateException aggregate)
{
foreach (var inner in aggregate.InnerExceptions)
{
if (IsUnreachable(inner)) return true;
}
}
}

return false;
}
}
}
Loading
Loading