Adds Serilog.Sinks.File to TeamsISO.Engine and a new EngineLogging.CreateDefault() factory that writes to BOTH the existing console sink and a rolling daily file at %LOCALAPPDATA%\\TeamsISO\\Logs\\teamsiso<date>.log. The WPF host (TeamsISO.exe is a WinExe with no console attached at runtime) now uses CreateDefault so support has something to ask for when users file an issue. The Console build keeps using CreateConsole — stdout is the right surface there and shell redirection beats a competing on-disk sink. Files roll daily, cap at 10 MB before mid-day rollover, and only the most recent 14 are retained. Disk flush interval is 250 ms so a tail -f from another tool sees lines promptly. Path is announced via the first log line on every startup. Two unit tests gate the wiring: AllLoggers_WriteToFile (verifies both typed and named CreateLogger() reach the file) and LogsAtBelowMinimumLevel_AreSuppressed (regression guard for level filtering). 74/74 unit tests pass (was 72). Also adds a startup breadcrumb log line in App.OnStartup carrying the build version + PID so we can correlate a user's log file with a specific commit.
169 lines
6.6 KiB
C#
169 lines
6.6 KiB
C#
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
using System.Windows;
|
|
using System.Windows.Interop;
|
|
using System.Windows.Threading;
|
|
using Microsoft.Extensions.Logging;
|
|
using TeamsISO.App.ViewModels;
|
|
using TeamsISO.Engine.Controller;
|
|
using TeamsISO.Engine.Interop;
|
|
using TeamsISO.Engine.Logging;
|
|
using TeamsISO.Engine.NdiInterop;
|
|
using TeamsISO.Engine.Persistence;
|
|
using TeamsISO.Engine.Pipeline;
|
|
|
|
namespace TeamsISO.App;
|
|
|
|
public partial class App : Application
|
|
{
|
|
/// <summary>
|
|
/// Per-user mutex name. Including the SID-equivalent (the username) ensures two
|
|
/// different Windows users can each run TeamsISO on the same machine, while one
|
|
/// user can't spawn duplicate instances that would contend over the NDI runtime
|
|
/// and the shared %APPDATA%\TeamsISO\config.json.
|
|
/// </summary>
|
|
private static readonly string SingleInstanceMutexName =
|
|
$"Local\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}";
|
|
|
|
private System.Threading.Mutex? _singleInstanceMutex;
|
|
private ILoggerFactory? _loggerFactory;
|
|
private NdiInteropPInvoke? _interop;
|
|
private IsoController? _controller;
|
|
private MainViewModel? _viewModel;
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern uint RegisterWindowMessageW(string lpString);
|
|
[DllImport("user32.dll")]
|
|
private static extern int PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
|
[DllImport("user32.dll")]
|
|
private static extern int SendNotifyMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
|
private const IntPtr HWND_BROADCAST = -1;
|
|
|
|
protected override async void OnStartup(StartupEventArgs e)
|
|
{
|
|
base.OnStartup(e);
|
|
|
|
// Single-instance gate: if another TeamsISO is already running for this user,
|
|
// broadcast the bring-to-front message and exit silently. This prevents the
|
|
// NDI/config contention seen during testing where two finders, two senders
|
|
// with the same default name, and two writers to config.json all raced.
|
|
bool createdNew;
|
|
_singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true, SingleInstanceMutexName, out createdNew);
|
|
if (!createdNew)
|
|
{
|
|
var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
|
|
if (bringToFront != 0)
|
|
SendNotifyMessageW(HWND_BROADCAST, bringToFront, IntPtr.Zero, IntPtr.Zero);
|
|
Shutdown(0);
|
|
return;
|
|
}
|
|
|
|
// Listen for the broadcast — if a *new* instance launches and finds us already
|
|
// running, it'll send this message; we surface our window in response.
|
|
var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
|
|
ComponentDispatcher.ThreadFilterMessage += (ref System.Windows.Interop.MSG msg, ref bool handled) =>
|
|
{
|
|
if (msg.message == (int)bringToFrontMsg && MainWindow is not null)
|
|
{
|
|
if (MainWindow.WindowState == WindowState.Minimized) MainWindow.WindowState = WindowState.Normal;
|
|
MainWindow.Activate();
|
|
MainWindow.Topmost = true;
|
|
MainWindow.Topmost = false;
|
|
handled = true;
|
|
}
|
|
};
|
|
|
|
try
|
|
{
|
|
// WPF host: write to both console (visible if attached) and a rolling daily
|
|
// file under %LOCALAPPDATA%\TeamsISO\Logs so users have something to grab when
|
|
// they file an issue.
|
|
_loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
|
|
var logger = _loggerFactory.CreateLogger<App>();
|
|
logger.LogInformation(
|
|
"TeamsISO.App starting up. Build: {Version}. Process: {Pid}.",
|
|
typeof(App).Assembly.GetName().Version,
|
|
Environment.ProcessId);
|
|
|
|
// ---- Preflight: NDI runtime ----
|
|
try
|
|
{
|
|
_interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger<NdiInteropPInvoke>());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageBox.Show(
|
|
"TeamsISO could not initialize the NDI runtime.\n\n" +
|
|
"Install the NDI Runtime from https://ndi.video/tools/ and try again.\n\n" +
|
|
"Details: " + ex.Message,
|
|
"TeamsISO — NDI runtime missing",
|
|
MessageBoxButton.OK,
|
|
MessageBoxImage.Error);
|
|
Shutdown(2);
|
|
return;
|
|
}
|
|
|
|
// ---- Engine wiring ----
|
|
var configPath = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"TeamsISO", "config.json");
|
|
var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger<ConfigStore>());
|
|
|
|
var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix);
|
|
var scaler = new ManagedNearestNeighborFrameScaler();
|
|
|
|
var loggerFactoryRef = _loggerFactory;
|
|
var interopRef = _interop;
|
|
IsoPipeline PipelineFactory(IsoPipelineConfig config)
|
|
{
|
|
var clock = new PeriodicTimerFrameClock(config.Settings.FramerateHz);
|
|
return new IsoPipeline(
|
|
config, interopRef, scaler, clock,
|
|
ExponentialBackoff.Default,
|
|
(delay, ct) => Task.Delay(delay, ct),
|
|
loggerFactoryRef);
|
|
}
|
|
|
|
_controller = new IsoController(
|
|
_interop, PipelineFactory, configStore, probe, _loggerFactory);
|
|
|
|
_viewModel = new MainViewModel(_controller, Dispatcher);
|
|
var window = new MainWindow(_viewModel);
|
|
window.Show();
|
|
MainWindow = window;
|
|
|
|
await _viewModel.InitializeAsync(CancellationToken.None);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageBox.Show(
|
|
"TeamsISO failed to start.\n\nDetails: " + ex,
|
|
"TeamsISO — startup error",
|
|
MessageBoxButton.OK,
|
|
MessageBoxImage.Error);
|
|
Shutdown(1);
|
|
}
|
|
}
|
|
|
|
protected override async void OnExit(ExitEventArgs e)
|
|
{
|
|
try
|
|
{
|
|
_viewModel?.Dispose();
|
|
if (_controller is not null)
|
|
await _controller.DisposeAsync();
|
|
_interop?.Dispose();
|
|
_loggerFactory?.Dispose();
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort shutdown
|
|
}
|
|
finally
|
|
{
|
|
try { _singleInstanceMutex?.ReleaseMutex(); } catch { /* not owned */ }
|
|
_singleInstanceMutex?.Dispose();
|
|
}
|
|
base.OnExit(e);
|
|
}
|
|
}
|