Skip to content
Closed
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
14 changes: 14 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project>
<!--
The .NET SDK does NOT emit a combined "NET10_0_WINDOWS" preprocessor symbol for the
net10.0-windows target framework — it defines NET10_0, WINDOWS, WINDOWS7_0, etc.
This codebase gates all Windows-only code (radio/audio modes, device enumeration,
the tune meter, WPF) with #if NET10_0_WINDOWS, so without this define that code
silently compiles out of every build. Define it for the windows TFM across all
projects so the Windows-only code actually compiles into the windows build (and
stays excluded from the cross-platform net10.0 / Linux / Docker build).
-->
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-windows'">
<DefineConstants>$(DefineConstants);NET10_0_WINDOWS</DefineConstants>
</PropertyGroup>
</Project>
6 changes: 3 additions & 3 deletions Resgrid.Audio.Core/AudioEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
using DtmfDetection.NAudio;
using NAudio.Wave;
using Resgrid.Audio.Core.Events;
using Serilog.Core;
using Serilog;

namespace Resgrid.Audio.Core
{
Expand All @@ -29,7 +29,7 @@ public interface IAudioEvaluator
public class AudioEvaluator : IAudioEvaluator
{
private static Object _lock = new Object();
private readonly Logger _logger;
private readonly ILogger _logger;

private Config _config;
private List<DtmfTone> _dtmfTone;
Expand All @@ -40,7 +40,7 @@ public class AudioEvaluator : IAudioEvaluator
public event EventHandler<EvaluatorEventArgs> EvaluatorFinished;
public event EventHandler<WatcherEventArgs> WatcherTriggered;

public AudioEvaluator(Logger logger)
public AudioEvaluator(ILogger logger)
{
_logger = logger;

Expand Down
10 changes: 9 additions & 1 deletion Resgrid.Audio.Core/Radio/RadioBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,18 @@ private void OnRadioAudio(object sender, short[] frame)
_emergency?.Process(frame);

bool open = IsSquelchOpen(frame);
SetReceiving(open);

// Only count as "receiving" when RX audio is actually forwarded to the channel —
// not merely when squelch opens, and not while AntiLoop suppresses forwarding during
// transmit (a squelch opening then is our own TX feedback, not real RX, and counting
// it would make OnChannelAudio's anti-loop gate suppress the in-progress transmit).
if (!open || (_settings.AntiLoop && _transmitting))
{
SetReceiving(false);
return;
}

SetReceiving(true);

// Copy + condition the audio before publishing.
var outgoing = (short[])frame.Clone();
Expand Down
6 changes: 3 additions & 3 deletions Resgrid.Audio.Core/WatcherAudioStorage.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using Serilog.Core;
using Serilog;

namespace Resgrid.Audio.Core
{
Expand All @@ -16,10 +16,10 @@ public interface IWatcherAudioStorage
public class WatcherAudioStorage : IWatcherAudioStorage
{
private static Object _lock = new Object();
private readonly Logger _logger;
private readonly ILogger _logger;
private static Dictionary<Guid, List<byte>> _watcherAudio;

public WatcherAudioStorage(Logger logger)
public WatcherAudioStorage(ILogger logger)
{
_logger = logger;
_watcherAudio = new Dictionary<Guid, List<byte>>();
Expand Down
11 changes: 9 additions & 2 deletions Resgrid.Audio.Relay/App.xaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
<Application x:Class="Resgrid.Audio.Relay.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources />
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ui:ThemesDictionary Theme="Dark" />
<ui:ControlsDictionary />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
140 changes: 139 additions & 1 deletion Resgrid.Audio.Relay/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,146 @@
using System.Windows;
using System;
using System.Linq;
using System.Threading;
using System.Windows;
using Microsoft.Extensions.DependencyInjection;
using Resgrid.Audio.Relay.Services;
using Resgrid.Audio.Relay.ViewModels;
using Resgrid.Audio.Relay.Views;
using Resgrid.Relay.Engine;
using Resgrid.Relay.Engine.Logging;
using Serilog;
using Wpf.Ui.Appearance;

namespace Resgrid.Audio.Relay
{
/// <summary>
/// Application entry point. Owns the single-instance guard, the DI container, theme
/// application and the start-to-tray vs start-visible decision. The engine is composed via
/// <see cref="ServiceCollectionExtensions.AddRelayEngine"/>; logging is fanned out to a
/// <see cref="UiLogBus"/> for the Logs screen.
/// </summary>
public partial class App : Application
{
private const string SingleInstanceMutexName = "Global\\ResgridRelayDesktop";

private Mutex _singleInstanceMutex;
private ServiceProvider _services;

/// <summary>Process-wide service provider, exposed for XAML-created view-models if needed.</summary>
public static IServiceProvider Services { get; private set; }

protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);

// (1) Single-instance guard — surface the existing instance instead of starting twice.
_singleInstanceMutex = new Mutex(initiallyOwned: true, SingleInstanceMutexName, out var createdNew);
if (!createdNew)
{
MessageBox.Show(
"Resgrid Relay is already running.",
"Resgrid Relay",
MessageBoxButton.OK,
MessageBoxImage.Information);
Shutdown();
return;
}

// (2) Build the DI container.
_services = BuildServiceProvider();
Services = _services;

// (3) Apply the WPF-UI theme.
ApplicationThemeManager.Apply(ApplicationTheme.Dark);

Check warning on line 54 in Resgrid.Audio.Relay/App.xaml.cs

View workflow job for this annotation

GitHub Actions / Build and test

This call site is reachable on all platforms. 'ApplicationTheme.Dark' is only supported on: 'Windows' 7.0 and later. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)

Check warning on line 54 in Resgrid.Audio.Relay/App.xaml.cs

View workflow job for this annotation

GitHub Actions / Build and test

This call site is reachable on all platforms. 'ApplicationThemeManager.Apply(ApplicationTheme, WindowBackdropType, bool)' is only supported on: 'Windows' 7.0 and later. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)

Check warning on line 54 in Resgrid.Audio.Relay/App.xaml.cs

View workflow job for this annotation

GitHub Actions / Build and test

This call site is reachable on all platforms. 'ApplicationThemeManager.Apply(ApplicationTheme, WindowBackdropType, bool)' is only supported on: 'Windows' 7.0 and later. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)

Check warning on line 54 in Resgrid.Audio.Relay/App.xaml.cs

View workflow job for this annotation

GitHub Actions / Build and test

This call site is reachable on all platforms. 'ApplicationTheme.Dark' is only supported on: 'Windows' 7.0 and later. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)

// (4) Resolve the shell. Start hidden to tray when launched with --minimized.
var startMinimized = e.Args.Any(a =>
string.Equals(a, "--minimized", StringComparison.OrdinalIgnoreCase) ||
string.Equals(a, "/minimized", StringComparison.OrdinalIgnoreCase));

var shell = _services.GetRequiredService<ShellWindow>();
MainWindow = shell;

if (startMinimized)
{
// Stay hidden — the tray icon (created with the window) keeps the app alive.
shell.StartHiddenToTray();
}
else
{
shell.Show();
}
}

private static ServiceProvider BuildServiceProvider()
{
var services = new ServiceCollection();

// Engine (per-mode service factory delegate).
services.AddRelayEngine();

// UI log bus + a Serilog logger fanned out to it (plus Debug for local diagnostics).
var logBus = new UiLogBus();
services.AddSingleton(logBus);

ILogger logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.UiBus(logBus)
.CreateLogger();
services.AddSingleton(logger);
Log.Logger = logger;

// App services.
services.AddSingleton<ConfigurationService>();
services.AddSingleton<DeviceEnumerationService>();
services.AddSingleton<RelayController>();

// View-models.
services.AddSingleton<ShellViewModel>();
services.AddTransient<DashboardViewModel>();
services.AddTransient<OperationsViewModel>();
services.AddTransient<ConfigurationViewModel>();
services.AddTransient<LogsViewModel>();
services.AddTransient<DevicesViewModel>();
services.AddTransient<AboutViewModel>();

// Views.
services.AddTransient<DashboardView>();
services.AddTransient<OperationsView>();
services.AddTransient<ConfigurationView>();
services.AddTransient<LogsView>();
services.AddTransient<DevicesView>();
services.AddTransient<AboutView>();

// Shell window.
services.AddSingleton<ShellWindow>();

return services.BuildServiceProvider();
}

protected override async void OnExit(ExitEventArgs e)
{
try
{
if (_services != null)
{
var controller = _services.GetService<RelayController>();
if (controller != null)
await controller.StopAllAsync().ConfigureAwait(true);

_services.Dispose();
}
}
catch
{
// Best-effort shutdown — never block process exit.
}
finally
{
Log.CloseAndFlush();
_singleInstanceMutex?.Dispose();
base.OnExit(e);
}
Comment on lines +121 to +143

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

python3 - <<'PY'
# We need to simulate or find documentation on WPF Application.OnExit behavior.
# Since we can't run the actual app in this sandbox to catch edge cases, 
# we rely on authoritative .NET documentation regarding the Application.Exit event / OnExit override.
# Key question: Does returning from OnExit allow the app to exit immediately, ignoring pending async tasks?

print("Checking WPF OnExit async void behavior...")
print("Documentation indicates that Application.OnExit is called when the application is about to be shut down.")
print("However, OnExit is not an async method. If implemented as async void, the framework does not await it.")
print("If the app continues execution after OnExit returns, the async continuation might not complete.")
print("Crucially, does the app exit immediately after OnExit returns, or does it wait?")
print("Standard behavior: The app shuts down after the handler returns. It does not automatically wait for async tasks.")
print("Therefore, the 'Quit' click sequence: Click -> Shutdown() -> OnExit triggers. OnExit returns immediately after awaiting starts?")
print("Actually, for async void, the 'await' yields, and the method returns. If the app exits, the process ends.")
print("The continuation after 'await' (Dispose, Log.Close) might not run.")
print("Conclusion: The original review comment is likely correct. Async void in critical lifecycle events like OnExit is dangerous because the runtime does not synchronize waiting for the task completion.")

# This is a reasoning check, not a code execution result, but confirms the logic.
print("Analysis Complete")
PY

Repository: Resgrid/Relay

Length of output: 1208


🏁 Script executed:

grep -r "Shutdown" Resgrid.Audio.Relay/App.xaml.cs || echo "Checking App.xaml.cs context..."

Repository: Resgrid/Relay

Length of output: 167


🏁 Script executed:

# Let's look at the file content around the OnExit method to see if there is a Shutdown call or if it relies on the OS/Message loop
cat -n Resgrid.Audio.Relay/App.xaml.cs | grep -A 20 -B 5 "OnExit"

Repository: Resgrid/Relay

Length of output: 940


🏁 Script executed:

# Check if there is a quit command that calls Shutdown explicitly vs just letting the window close
# We need to see the OnMainWindowClosed or TriggerQuit logic if it exists.
ast-grep run --pattern 'Application.Current.Shutdown($$$)' --lang csharp Resgrid.Audio.Relay/

Repository: Resgrid/Relay

Length of output: 230


🏁 Script executed:

python3 - <<'PY'
# Logic Check
# 1. ShellWindow.xaml.cs:113 calls Application.Current.Shutdown().
# 2. Shutdown() fires the Exit event (OnExit) and then terminates.
# 3. The base class (Application) expects OnExit to return synchronously.
# 4. async void OnExit starts, awaits controller.StopAllAsync().
# 5. This 'await' yields control.
# 6. Does base class OnExit handling respect the await? No. It usually treats it as a background task if not awaited.
# 7. Does the thread terminate immediately after Shutdown() starts?
#    - For a WPF app, the message loop ends when Shutdown() processes the exit.
#    - If OnExit returns (yields) before the await completes, the framework might consider the handler finished.
#    - However, in WPF, the Application.Exit event handler is expected to complete before the app fully exits *if* it was synchronous.
#    - With async void, the framework doesn't know to wait.
#    - Result: The application process might exit while the task is suspended, meaning the 'finally' block (Line 138-143) never runs.

print("Verdict: The concern is valid.")
print("The 'finally' block contains critical cleanup (Log, Mutex).")
print("If the app exits while awaiting, the 'finally' block is skipped, and the process is terminated.")
print("This leads to potential log truncation and mutex leaks.")
PY

Repository: Resgrid/Relay

Length of output: 394


🏁 Script executed:

# Check if there is a safe way to exit or if we can just rely on the OS.
# But the comment specifically claims "quit" path is at risk.
# Verify if Shutdown() is the only way to quit or if there are other triggers.
grep -n "Exit" Resgrid.Audio.Relay/App.xaml.cs

Repository: Resgrid/Relay

Length of output: 235


🏁 Script executed:

# Confirm if ShellWindow is the main window and how it handles close vs shutdown
# looking at the file around line 113
sed -n '105,120p' Resgrid.Audio.Relay/ShellWindow.xaml.cs

Repository: Resgrid/Relay

Length of output: 630


Asynchronous OnExit logic risks skipping cleanup

Application.OnExit is not awaited by the WPF runtime. When Application.Current.Shutdown() is called, it invokes this method and proceeds to terminate the process immediately upon the method's return (yielding at await controller.StopAllAsync()). This causes the finally block to be skipped, leaving Serilog logs unflushed and the single-instance mutex unreleased.

Move the StopAllAsync logic into the explicit quit command in ShellWindow.xaml.cs (calling it before Shutdown()), and revert OnExit to be strictly synchronous with only cleanup tasks.

Original Concerned Code
protected override async void OnExit(ExitEventArgs e)
{
	try
	{
		if (_services != null)
		{
			var controller = _services.GetService<RelayController>();
			if (controller != null)
				await controller.StopAllAsync().ConfigureAwait(true);

			_services.Dispose();
		}
	}
	catch
	{
		// Best-effort shutdown — never block process exit.
	}
	finally
	{
		Log.CloseAndFlush();
		_singleInstanceMutex?.Dispose();
		base.OnExit(e);
	}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resgrid.Audio.Relay/App.xaml.cs` around lines 121 - 143, `App.OnExit` is
doing asynchronous work in an `async void` override, which can return before
cleanup runs and skip the `finally` block. Move the
`RelayController.StopAllAsync` shutdown sequence out of `OnExit` and into the
explicit quit flow in `ShellWindow` before `Application.Current.Shutdown()`,
then make `OnExit` synchronous so it only handles disposal and final cleanup
like `Log.CloseAndFlush` and `_singleInstanceMutex.Dispose`.

}
}
}
21 changes: 21 additions & 0 deletions Resgrid.Audio.Relay/Controls/LevelMeter.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<UserControl x:Class="Resgrid.Audio.Relay.Controls.LevelMeter"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
x:Name="Root"
d:DesignHeight="20" d:DesignWidth="200">
<Grid>
<ProgressBar x:Name="Bar"
Minimum="0" Maximum="100"
Height="14"
Foreground="#2ECC71"
Background="#22000000" />
<TextBlock x:Name="Label"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="10"
Foreground="#DDFFFFFF" />
</Grid>
</UserControl>
45 changes: 45 additions & 0 deletions Resgrid.Audio.Relay/Controls/LevelMeter.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System;
using System.Windows;
using System.Windows.Controls;

namespace Resgrid.Audio.Relay.Controls
{
/// <summary>
/// A simple input-level meter that maps a dBFS value (typically -80..0) onto a 0..100
/// <see cref="ProgressBar"/> using <c>(db + 80) / 80</c>, and shows the numeric value.
/// </summary>
public partial class LevelMeter : UserControl
{
public LevelMeter()
{
InitializeComponent();
UpdateVisual(Dbfs);
}

public static readonly DependencyProperty DbfsProperty = DependencyProperty.Register(
nameof(Dbfs),
typeof(double),
typeof(LevelMeter),
new FrameworkPropertyMetadata(-80.0, OnDbfsChanged));

/// <summary>Current input level in dBFS. Clamped to the -80..0 display range.</summary>
public double Dbfs
{
get => (double)GetValue(DbfsProperty);
set => SetValue(DbfsProperty, value);
}

private static void OnDbfsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((LevelMeter)d).UpdateVisual((double)e.NewValue);
}

private void UpdateVisual(double db)
{
// Map dBFS to a 0..100 percentage: -80 dBFS -> 0, 0 dBFS -> 100.
var percent = Math.Clamp((db + 80.0) / 80.0 * 100.0, 0.0, 100.0);
Bar.Value = percent;
Label.Text = $"{db,5:0.0} dBFS";
}
}
}
Loading
Loading