Compare commits

..

No commits in common. "bab29b02abe15891ff2d5868d5835c28ae6bc332" and "d2c0c2159f2e143673fd6b4b57def6512c37ded1" have entirely different histories.

15 changed files with 31 additions and 808 deletions

View file

@ -59,14 +59,14 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: test-results
path: '**/test-results.trx'
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage-report/

View file

@ -1,132 +0,0 @@
name: Release
# Triggered by pushing an annotated tag of the form v1.2.3 (or any v-prefixed
# semver). The job runs on a Windows runner because building the WiX MSI
# requires the WiX SDK which is supported on Windows; the engine + console
# can in principle be built on Linux, but for simplicity we do everything in
# one job here so the publish output paths line up for the installer.
on:
push:
tags:
- 'v*.*.*'
jobs:
build-msi:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # tags + full history needed to derive the version
- name: Derive version from tag
id: ver
shell: pwsh
run: |
# GITHUB_REF is refs/tags/vX.Y.Z; strip the prefix.
$tag = "${env:GITHUB_REF}".Replace('refs/tags/', '')
$version = $tag.TrimStart('v')
"tag=$tag" >> $env:GITHUB_OUTPUT
"version=$version" >> $env:GITHUB_OUTPUT
Write-Host "Building version $version (tag $tag)"
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Restore (Windows solution filter)
run: dotnet restore TeamsISO.Windows.slnf
- name: Build (Release, treat warnings as errors)
run: dotnet build TeamsISO.Windows.slnf --configuration Release --no-restore /p:Version=${{ steps.ver.outputs.version }}
- name: Run unit tests (excluding requires=ndi)
run: >
dotnet test TeamsISO.Windows.slnf
--configuration Release
--no-build
--filter "Category!=ndi&requires!=ndi"
- name: Publish TeamsISO.App (framework-dependent, win-x64)
run: >
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj
--configuration Release
--runtime win-x64
--self-contained false
--output publish/TeamsISO
/p:Version=${{ steps.ver.outputs.version }}
- name: Publish TeamsISO.Console (framework-dependent, win-x64)
run: >
dotnet publish src/TeamsISO.Console/TeamsISO.Console.csproj
--configuration Release
--runtime win-x64
--self-contained false
--output publish/TeamsISO-Console
/p:Version=${{ steps.ver.outputs.version }}
- name: Build MSI installer
run: >
dotnet build installer/TeamsISO.Installer.wixproj
--configuration Release
/p:Version=${{ steps.ver.outputs.version }}
- name: Locate MSI
id: msi
shell: pwsh
run: |
$msi = Get-ChildItem -Path installer/bin -Recurse -Filter '*.msi' | Select-Object -First 1
if (-not $msi) { throw "No MSI produced under installer/bin." }
"path=$($msi.FullName)" >> $env:GITHUB_OUTPUT
"name=$($msi.Name)" >> $env:GITHUB_OUTPUT
Write-Host "MSI: $($msi.FullName) ($($msi.Length) bytes)"
- name: Upload MSI as workflow artifact
uses: actions/upload-artifact@v3
with:
name: ${{ steps.msi.outputs.name }}
path: ${{ steps.msi.outputs.path }}
# Forgejo doesn't ship a stable upload-release-asset action, so we use
# the REST API directly. This: (1) finds the release that the tag push
# auto-created, (2) uploads the MSI as an asset on it. Requires that
# the repo's "Create a release on tag push" setting is on, OR that the
# release was created beforehand. If no release exists, we create one.
- name: Attach MSI to release
shell: pwsh
env:
FORGE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FORGE_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ steps.ver.outputs.tag }}
MSI_PATH: ${{ steps.msi.outputs.path }}
MSI_NAME: ${{ steps.msi.outputs.name }}
run: |
$headers = @{ Authorization = "token $env:FORGE_TOKEN" }
# Find the release for this tag.
try {
$release = Invoke-RestMethod -Method Get `
-Uri "$env:FORGE_API/releases/tags/$env:TAG" -Headers $headers
} catch {
Write-Host "No release found for $env:TAG; creating one."
$body = @{
tag_name = $env:TAG
name = "TeamsISO $env:TAG"
body = "Automated build from tag $env:TAG."
draft = $false
prerelease = $env:TAG -match '-(alpha|beta|rc)'
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post `
-Uri "$env:FORGE_API/releases" -Headers $headers `
-ContentType 'application/json' -Body $body
}
# Upload the MSI as an asset.
$uploadUri = "$env:FORGE_API/releases/$($release.id)/assets?name=$env:MSI_NAME"
curl.exe -fSL `
-H "Authorization: token $env:FORGE_TOKEN" `
-H "Content-Type: application/octet-stream" `
--upload-file "$env:MSI_PATH" `
"$uploadUri"
Write-Host "Asset $env:MSI_NAME attached to release $env:TAG."

View file

@ -1,50 +0,0 @@
# Releasing TeamsISO
The release workflow at `.forgejo/workflows/release.yml` runs on **annotated tag pushes
matching `v*.*.*`**. It builds, tests, publishes, packages an MSI, and uploads the
MSI as a release asset.
## Prerequisites
- A **Windows runner** registered to this Forgejo instance. WiX MSI builds require
Windows; the existing CI runs on Linux for unit tests, but releases need a
separate Windows runner. Register one with `forgejo-runner register` against a
Windows host that has the .NET 8 SDK + WiX SDK access (the WiX SDK pulls itself
via NuGet at build time, so no separate install).
- The repository's **Create release on tag push** setting on (default), or skip it —
the workflow will create the release if one doesn't exist.
## Cutting a release
```sh
# Bump the version in Directory.Build.props if you haven't already.
git tag -a v1.0.0 -m "TeamsISO 1.0.0"
git push origin v1.0.0
```
The workflow will:
1. Restore + build `TeamsISO.Windows.slnf` in Release with the tag's version.
2. Run unit tests (the `requires=ndi` integration tier is skipped — it needs a
real NDI runtime which a CI runner won't have).
3. Publish `TeamsISO.App` and `TeamsISO.Console` for `win-x64`,
framework-dependent (.NET 8 Desktop runtime is the user's responsibility).
4. Build `installer/TeamsISO.Installer.wixproj`, producing
`TeamsISO-Setup-<version>.msi`.
5. Upload the MSI as a workflow artifact (downloadable from the run page).
6. Attach the MSI to the GitHub-style Release for the tag, creating the release
first if it doesn't exist. Pre-release flag is set automatically when the
tag contains `-alpha`, `-beta`, or `-rc`.
## Code signing (TODO)
The `wixproj` has a `SignOutput` property hook but no actual cert wiring. For a
v1.0 release, sign the MSI with an EV cert before publishing:
1. Add a `SIGNING_CERT_BASE64` and `SIGNING_CERT_PASSWORD` to repo Secrets.
2. Decode the cert into the runner's cert store at the start of the workflow.
3. Set `/p:SignOutput=true` on the `dotnet build` of the wixproj and configure
`signtool` invocation (the installer project will need a custom target).
Until that lands, downstream users will see the standard Windows SmartScreen
warning on first launch — annoying but not blocking for early adopters.

View file

@ -75,15 +75,8 @@ public partial class App : Application
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);
_loggerFactory = EngineLogging.CreateConsole(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

View file

@ -3,33 +3,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels"
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="TeamsISO"
Height="780" Width="1280"
MinHeight="640" MinWidth="1080"
Background="{DynamicResource Wd.Canvas}"
WindowStyle="None"
ResizeMode="CanResize"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="ClearType">
<!--
Chromeless window chrome — removes the standard Windows title bar so we can
draw our own header that matches the Teams flush look. CaptionHeight=44 makes
the top 44px of the window act as a drag region. ResizeBorderThickness=6
keeps edge-resize working. UseAeroCaptionButtons=False disables the OS-drawn
min/max/close (we draw our own).
-->
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome
CaptionHeight="44"
ResizeBorderThickness="6"
CornerRadius="0"
GlassFrameThickness="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Window.Resources>
<conv:BoolToVisibilityConverter x:Key="BoolToVis"/>
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
@ -187,66 +168,21 @@
</Border>
</StackPanel>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
VerticalAlignment="Center">
<Border Style="{StaticResource Wd.Pill}"
Background="{DynamicResource Wd.Status.LiveBg}"
VerticalAlignment="Center"
Margin="0,0,16,0">
<StackPanel Orientation="Horizontal">
<Ellipse Width="7" Height="7"
Fill="{DynamicResource Wd.Status.Live}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding StatusText}"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Margin="8,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!--
Custom window control buttons. WindowChrome.IsHitTestVisibleInChrome
tells the chrome that clicks here are real clicks, not drag-region
clicks, so the buttons fire instead of starting a window drag.
-->
<Button x:Name="MinimizeButton"
Style="{StaticResource Wd.Button.Caption}"
Click="OnMinimize"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
ToolTip="Minimize">
<Path Data="M 0,5 L 10,5"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"/>
</Button>
<Button x:Name="MaximizeButton"
Style="{StaticResource Wd.Button.Caption}"
Click="OnMaximizeRestore"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
ToolTip="Maximize">
<Path x:Name="MaximizeIcon"
Data="M 0,0 L 10,0 L 10,10 L 0,10 Z"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Fill="Transparent"
Width="10" Height="10"
Stretch="None"/>
</Button>
<Button x:Name="CloseButton"
Style="{StaticResource Wd.Button.CaptionClose}"
Click="OnClose"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
ToolTip="Close">
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"/>
</Button>
</StackPanel>
<Border Grid.Column="1"
Style="{StaticResource Wd.Pill}"
Background="{DynamicResource Wd.Status.LiveBg}"
VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<Ellipse Width="7" Height="7"
Fill="{DynamicResource Wd.Status.Live}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding StatusText}"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Margin="8,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</Border>
@ -340,34 +276,9 @@
<DataGridTemplateColumn Header="Source" Width="*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding SourceMachine}"
Style="{StaticResource Wd.Text.Mono}"/>
<TextBlock Text="{Binding IncomingResolution}"
Style="{StaticResource Wd.Text.Mono}"
FontSize="10"
Foreground="{DynamicResource Wd.Text.Tertiary}"/>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Live" Width="100">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel VerticalAlignment="Center">
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="11">
<Run Text="↓ "/>
<Run Text="{Binding FramesIn}"/>
</TextBlock>
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="10"
Foreground="{DynamicResource Wd.Text.Tertiary}">
<Run Text="↑ "/>
<Run Text="{Binding FramesOut}"/>
</TextBlock>
</StackPanel>
<TextBlock Text="{Binding SourceMachine}"
Style="{StaticResource Wd.Text.Mono}"
VerticalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>

View file

@ -1,5 +1,4 @@
using System.Windows;
using System.Windows.Shapes;
using TeamsISO.App.ViewModels;
namespace TeamsISO.App;
@ -9,37 +8,10 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
StateChanged += OnWindowStateChanged;
}
public MainWindow(MainViewModel viewModel) : this()
{
DataContext = viewModel;
}
/// <summary>Custom min button — chrome'd window has no system caption buttons.</summary>
private void OnMinimize(object sender, RoutedEventArgs e) =>
WindowState = WindowState.Minimized;
/// <summary>Toggles maximize/restore. Bound to the maximize button + double-click on the drag region.</summary>
private void OnMaximizeRestore(object sender, RoutedEventArgs e) =>
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
/// <summary>Custom close button.</summary>
private void OnClose(object sender, RoutedEventArgs e) => Close();
/// <summary>
/// Swap the maximize-button glyph between the "single rectangle" (when normal) and the
/// "two-overlapping-rectangles" (when maximized) variants, matching the Windows 11
/// caption-button conventions.
/// </summary>
private void OnWindowStateChanged(object? sender, EventArgs e)
{
if (FindName("MaximizeIcon") is not Path icon) return;
icon.Data = WindowState == WindowState.Maximized
// Two-rectangle "restore" glyph
? System.Windows.Media.Geometry.Parse("M 2,0 L 10,0 L 10,8 M 0,2 L 8,2 L 8,10 L 0,10 Z")
// Single-rectangle "maximize" glyph
: System.Windows.Media.Geometry.Parse("M 0,0 L 10,0 L 10,10 L 0,10 Z");
}
}

View file

@ -186,58 +186,6 @@
</Setter>
</Style>
<!--
Window-caption buttons: minimize / maximize / close. Match the Windows 11
Teams look — slim 46x32, hover-tinted, Close turns red on hover.
-->
<Style x:Key="Wd.Button.Caption" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Width" Value="46"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.SurfaceHover}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.SurfaceActive}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Wd.Button.CaptionClose" TargetType="Button" BasedOn="{StaticResource Wd.Button.Caption}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#C42B1C"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#A52A1F"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Rail-icon button: square 48x48, used on the Teams-style left rail -->
<Style x:Key="Wd.Button.RailIcon" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>

View file

@ -18,7 +18,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
private readonly Dispatcher _dispatcher;
private readonly IDisposable _participantsSub;
private readonly IDisposable _alertsSub;
private readonly DispatcherTimer _statsTimer;
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
private string _statusText = "Starting…";
@ -50,33 +49,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
{
AlertBanner.Current = alert;
});
// 1 Hz stats poll — pull live frame counters from each running pipeline and
// push them onto the per-participant view models. Cheap (just reads volatile
// fields on the engine side) and runs on the UI dispatcher so SetField is safe.
_statsTimer = new DispatcherTimer(DispatcherPriority.Background, _dispatcher)
{
Interval = TimeSpan.FromSeconds(1),
};
_statsTimer.Tick += OnStatsTick;
_statsTimer.Start();
}
private void OnStatsTick(object? sender, EventArgs e)
{
foreach (var vm in Participants)
{
try
{
var stats = _controller.GetStats(vm.Id);
vm.UpdateStats(stats);
}
catch
{
// Stats are advisory; never let a transient read failure
// tear down the timer or surface an error to the user.
}
}
}
public async Task InitializeAsync(CancellationToken cancellationToken)
@ -127,8 +99,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
public void Dispose()
{
_statsTimer.Stop();
_statsTimer.Tick -= OnStatsTick;
_participantsSub.Dispose();
_alertsSub.Dispose();
}

View file

@ -1,6 +1,5 @@
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats;
namespace TeamsISO.App.ViewModels;
@ -36,29 +35,6 @@ public sealed class ParticipantViewModel : ObservableObject
set => SetField(ref _isEnabled, value);
}
private long _framesIn;
private long _framesOut;
private string _incomingResolution = "—";
/// <summary>Number of frames the receiver has captured so far.</summary>
public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }
/// <summary>Number of frames the sender has emitted so far.</summary>
public long FramesOut { get => _framesOut; set => SetField(ref _framesOut, value); }
/// <summary>Source resolution as "WxH", or em-dash when no frames have been seen yet.</summary>
public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); }
/// <summary>Updates the live stats display from a controller-side snapshot.</summary>
public void UpdateStats(IsoHealthStats stats)
{
FramesIn = stats.FramesIn;
FramesOut = stats.FramesOut;
IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0
? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
: "—";
}
public bool IsProcessing
{
get => _isProcessing;

View file

@ -97,15 +97,12 @@ public sealed class IsoController : IIsoController
public IsoHealthStats GetStats(Guid participantId)
{
IsoPipeline? pipeline;
lock (_gate)
{
if (!_pipelines.TryGetValue(participantId, out pipeline))
return IsoHealthStats.Empty;
return _pipelines.TryGetValue(participantId, out var pipeline)
? IsoHealthStats.Empty // production wires pipeline.Stats; Phase B-1 leaves this stub
: IsoHealthStats.Empty;
}
// GetStats() is thread-safe and fast; pull outside the gate so a slow stats
// read doesn't serialize the controller's other operations.
return pipeline.GetStats();
}
public async Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken cancellationToken)

View file

@ -5,35 +5,11 @@ using Serilog.Extensions.Logging;
namespace TeamsISO.Engine.Logging;
/// <summary>
/// Convenience factory for an <see cref="ILoggerFactory"/> wired to Serilog. Two flavors:
/// <see cref="CreateConsole"/> for headless / Console-mode use (writes only to stdout),
/// and <see cref="CreateDefault"/> for the WPF host (writes to stdout AND to a rolling
/// daily file under <c>%LOCALAPPDATA%\TeamsISO\Logs</c> so support has something to ask for
/// when things break).
/// Convenience factory for an <see cref="ILoggerFactory"/> wired to Serilog's console sink.
/// Phase A wires console-only; Phase C will add the rolling-file sink under %APPDATA%\TeamsISO\logs\.
/// </summary>
public static class EngineLogging
{
/// <summary>
/// Default filename for the rolling-file sink. Serilog rotates on size and date with
/// the suffix it inserts before the extension, so "teamsiso.log" becomes
/// "teamsiso20260508.log", "teamsiso20260508_001.log", etc.
/// </summary>
private const string LogFileName = "teamsiso.log";
/// <summary>
/// Default directory for the rolling-file sink:
/// <c>%LOCALAPPDATA%\TeamsISO\Logs\</c>. Created on first write if it doesn't exist.
/// </summary>
public static string DefaultLogDirectory =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO",
"Logs");
/// <summary>
/// Console-only factory. Used by TeamsISO.Console where stdout is the right surface
/// and a file sink would be redundant with shell redirection.
/// </summary>
public static ILoggerFactory CreateConsole(LogLevel minimum = LogLevel.Information)
{
var serilog = new LoggerConfiguration()
@ -45,50 +21,6 @@ public static class EngineLogging
return new SerilogLoggerFactory(serilog, dispose: true);
}
/// <summary>
/// Default factory for the desktop host. Writes to the console (visible when launched
/// from a console attach) and to a rolling daily file at <see cref="DefaultLogDirectory"/>.
/// File path is logged at startup to the console sink so users can find it.
/// </summary>
/// <param name="minimum">Minimum log level for both sinks.</param>
/// <param name="logDirectoryOverride">Optional alternate log directory; null uses the default.</param>
public static ILoggerFactory CreateDefault(
LogLevel minimum = LogLevel.Information,
string? logDirectoryOverride = null)
{
var dir = logDirectoryOverride ?? DefaultLogDirectory;
Directory.CreateDirectory(dir); // idempotent; safe under contention
var logPath = Path.Combine(dir, LogFileName);
var serilog = new LoggerConfiguration()
.MinimumLevel.Is(MapLevel(minimum))
.Enrich.WithProperty("Component", "TeamsISO.Engine")
.WriteTo.Console(outputTemplate:
"[{Timestamp:HH:mm:ss} {Level:u3}] [{Component}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File(
path: logPath,
outputTemplate:
"[{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level:u3}] [{Component}] {Message:lj}{NewLine}{Exception}",
rollingInterval: Serilog.RollingInterval.Day,
retainedFileCountLimit: 14, // two weeks of history is plenty
fileSizeLimitBytes: 10 * 1024 * 1024, // 10 MB per file before roll-over
rollOnFileSizeLimit: true,
shared: false,
flushToDiskInterval: TimeSpan.FromMilliseconds(250)) // flush often so support tools see live tails
.CreateLogger();
// Set the Serilog static singleton too. SerilogLoggerFactory writes through the
// explicit logger we pass in, but anything in the engine that reaches for
// Serilog.Log.* directly would otherwise miss our sinks. Belt & suspenders.
Serilog.Log.Logger = serilog;
var factory = new SerilogLoggerFactory(serilog, dispose: true);
// Surface the path at startup so support has it without digging.
factory.CreateLogger("TeamsISO.Engine")
.LogInformation("Diagnostic logs writing to: {LogDirectory}", dir);
return factory;
}
private static Serilog.Events.LogEventLevel MapLevel(LogLevel level) => level switch
{
LogLevel.Trace => Serilog.Events.LogEventLevel.Verbose,

View file

@ -11,7 +11,7 @@ namespace TeamsISO.Engine.Pipeline;
/// </summary>
public sealed class IsoPipeline : IAsyncDisposable
{
private Func<CancellationToken, Task> _runInner;
private readonly Func<CancellationToken, Task> _runInner;
private readonly ExponentialBackoff _backoff;
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
private readonly ILogger<IsoPipeline> _logger;
@ -20,44 +20,10 @@ public sealed class IsoPipeline : IAsyncDisposable
private Task? _supervisorTask;
private int _consecutiveFailures;
// Refs to the currently-live receiver and sender, set by the inner loop on each
// restart. Reads via Volatile.Read are safe from any thread (UI's stats poll).
private NdiReceiver? _liveReceiver;
private NdiSender? _liveSender;
private RawFrame? _lastReceivedFrame;
private DateTimeOffset? _lastReceivedAt;
public Guid ParticipantId { get; }
public IsoState State { get; private set; } = IsoState.Idle;
public int ConsecutiveFailures => _consecutiveFailures;
/// <summary>
/// Snapshot of the pipeline's current health. Safe to call from any thread; values
/// are inherently a moment-in-time view and may change immediately. Returns
/// <see cref="Domain.IsoHealthStats.Empty"/> when no inner pipeline is currently
/// running (e.g. between supervisor restarts or after final failure).
/// </summary>
public Domain.IsoHealthStats GetStats()
{
var receiver = Volatile.Read(ref _liveReceiver);
var sender = Volatile.Read(ref _liveSender);
var lastFrame = Volatile.Read(ref _lastReceivedFrame);
var lastAt = _lastReceivedAt;
if (receiver is null || sender is null)
return Domain.IsoHealthStats.Empty;
return new Domain.IsoHealthStats(
FramesIn: receiver.FramesCaptured,
FramesOut: sender.FramesSent,
FramesDropped: 0, // FrameProcessor currently doesn't surface drops; wire later
FramesDuplicated: 0, // same — last-frame re-emits aren't counted yet
LastFrameAt: lastAt,
IncomingFps: 0, // running rate computation is a follow-up
IncomingWidth: lastFrame?.Width ?? 0,
IncomingHeight: lastFrame?.Height ?? 0);
}
/// <summary>
/// Test ctor. The caller supplies the inner runner directly so failures and lifetimes
/// can be controlled from a unit test.
@ -88,32 +54,10 @@ public sealed class IsoPipeline : IAsyncDisposable
Func<TimeSpan, CancellationToken, Task> delay,
ILoggerFactory loggerFactory)
: this(config.ParticipantId,
// The inner-runner closure captures `this` so the receiver/sender
// wired by RunInnerPipelineAsync can be published to instance fields
// for stats reads.
default(Func<CancellationToken, Task>)!,
ct => RunInnerPipelineAsync(config, interop, scaler, frameClock, loggerFactory, ct),
backoff,
delay,
loggerFactory)
{
_runInner = ct => RunInnerPipelineAsync(
config, interop, scaler, frameClock, loggerFactory, ct,
onLive: (recv, send) =>
{
Volatile.Write(ref _liveReceiver, recv);
Volatile.Write(ref _liveSender, send);
},
onClear: () =>
{
Volatile.Write(ref _liveReceiver, null);
Volatile.Write(ref _liveSender, null);
},
onFrame: frame =>
{
Volatile.Write(ref _lastReceivedFrame, frame);
_lastReceivedAt = DateTimeOffset.UtcNow;
});
}
loggerFactory) { }
/// <summary>Starts the supervisor. Returns immediately; pipeline runs in the background.</summary>
public Task StartAsync()
@ -195,12 +139,6 @@ public sealed class IsoPipeline : IAsyncDisposable
/// <summary>
/// Default inner pipeline: spins up receiver → processor → sender on bounded channels
/// and awaits all three. Throws if any of them throws.
///
/// The optional <paramref name="onLive"/> / <paramref name="onClear"/> / <paramref name="onFrame"/>
/// callbacks let the outer <see cref="IsoPipeline"/> publish references to the live
/// receiver and sender (so it can read counters from any thread for health stats)
/// and observe the most recent received frame (so source resolution / last-seen-at
/// can be surfaced in the UI). All three are no-ops by default.
/// </summary>
private static async Task RunInnerPipelineAsync(
IsoPipelineConfig config,
@ -208,10 +146,7 @@ public sealed class IsoPipeline : IAsyncDisposable
IFrameScaler scaler,
IFrameClock frameClock,
ILoggerFactory loggerFactory,
CancellationToken ct,
Action<NdiReceiver, NdiSender>? onLive = null,
Action? onClear = null,
Action<RawFrame>? onFrame = null)
CancellationToken ct)
{
var rawChannel = Channel.CreateBounded<RawFrame>(new BoundedChannelOptions(config.RawChannelCapacity)
{
@ -226,20 +161,12 @@ public sealed class IsoPipeline : IAsyncDisposable
SingleWriter = true,
});
// Tap the raw frames as they flow into the channel so the host can show "last
// frame at" / source resolution without us re-implementing a probe.
var rawWriter = onFrame is null
? rawChannel.Writer
: new TappedChannelWriter<RawFrame>(rawChannel.Writer, onFrame);
using var receiver = new NdiReceiver(
interop, config.SourceName, rawWriter, loggerFactory.CreateLogger<NdiReceiver>());
interop, config.SourceName, rawChannel.Writer, loggerFactory.CreateLogger<NdiReceiver>());
using var sender = new NdiSender(
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>(),
config.OutputGroups);
onLive?.Invoke(receiver, sender);
var processor = new FrameProcessor(
config.Settings, scaler, new SolidFrameRenderer(),
frameClock, rawChannel.Reader, processedChannel.Writer,
@ -257,30 +184,9 @@ public sealed class IsoPipeline : IAsyncDisposable
{
rawChannel.Writer.TryComplete();
processedChannel.Writer.TryComplete();
onClear?.Invoke();
}
}
/// <summary>
/// Channel-writer wrapper that fires a callback on every successful write but
/// otherwise behaves identically to the inner writer. Used to tap the raw-frame
/// stream for stats without entangling the receiver with the stats API.
/// </summary>
private sealed class TappedChannelWriter<T> : ChannelWriter<T>
{
private readonly ChannelWriter<T> _inner;
private readonly Action<T> _onWrite;
public TappedChannelWriter(ChannelWriter<T> inner, Action<T> onWrite) { _inner = inner; _onWrite = onWrite; }
public override bool TryWrite(T item)
{
if (_inner.TryWrite(item)) { _onWrite(item); return true; }
return false;
}
public override ValueTask<bool> WaitToWriteAsync(CancellationToken ct = default)
=> _inner.WaitToWriteAsync(ct);
public override bool TryComplete(Exception? error = null) => _inner.TryComplete(error);
}
private static async Task ProcessorLoopAsync(FrameProcessor processor, IFrameClock clock, CancellationToken ct)
{
while (!ct.IsCancellationRequested)

View file

@ -9,7 +9,6 @@
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="System.Reactive" Version="6.0.0" />
</ItemGroup>

View file

@ -1,145 +0,0 @@
using System.Runtime.Versioning;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using TeamsISO.Engine.Domain;
using TeamsISO.Engine.NdiInterop;
using TeamsISO.Engine.Pipeline;
namespace TeamsISO.Engine.IntegrationTests;
/// <summary>
/// End-to-end pipeline test: synthesize a fake Teams source, run an
/// <see cref="IsoPipeline"/> against it, and assert the resulting normalized
/// output stream yields frames at the configured 1080p target size. This is
/// the closest unit-test analog to "did the engine actually do its job?"
/// — exercises NdiReceiver, FrameProcessor, ManagedNearestNeighborFrameScaler,
/// and NdiSender as a single chain through the production P/Invoke shim.
///
/// Marked [Trait("requires","ndi")] so default CI skips it; run locally with
/// dotnet test --filter requires=ndi
/// </summary>
[SupportedOSPlatform("windows")]
public class PipelineFrameRoundTripTests
{
[Fact]
[Trait("requires", "ndi")]
public async Task Pipeline_ReceivesFromTeamsLikeSource_AndEmitsAt1080pTarget()
{
// ─── 1. Source pump ─────────────────────────────────────────────────
// Spin up an NDI sender broadcasting an unambiguous, unique source
// name. We use the literal "Teams - <token>" form so this works even
// if a parallel test happens to be running at the same time.
var token = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
var sourceShortName = $"Teams - FrameRT_{token}";
var outputName = $"TEAMSISO_RT_{token}";
using var interop = new NdiInteropPInvoke(NullLogger<NdiInteropPInvoke>.Instance);
using var fakeSender = interop.CreateSender(sourceShortName);
// Synthesize 640x360 BGRA frames in cyan so the receive side has
// something obviously-not-zero to pick up. Pump at ~30 fps; the engine
// is configured for 59.94 fps target so it'll either re-emit our
// frames (slate threshold not yet reached) or interpolate via the
// last-frame re-emit path — either is fine for the dimensions check.
const int srcW = 640, srcH = 360;
var pixelBuffer = new byte[srcW * srcH * 4];
for (var i = 0; i < pixelBuffer.Length; i += 4)
{
pixelBuffer[i + 0] = 0xF0; // B
pixelBuffer[i + 1] = 0xED; // G
pixelBuffer[i + 2] = 0x97; // R → ~Wild Dragon cyan #97EDF0 in BGRA
pixelBuffer[i + 3] = 0xFF; // A
}
using var pumpCts = new CancellationTokenSource();
var pumpTask = Task.Run(() =>
{
var pixelMemory = new ReadOnlyMemory<byte>(pixelBuffer);
while (!pumpCts.IsCancellationRequested)
{
var frame = new ProcessedFrame(srcW, srcH, DateTime.UtcNow.Ticks, pixelMemory, PixelFormat.Bgra);
try { interop.SendFrame(fakeSender, frame); }
catch { /* sender may be torn down during cancellation */ }
Thread.Sleep(33); // ~30 fps source
}
}, pumpCts.Token);
try
{
// ─── 2. Wait for our fake source to be discoverable ─────────────
using var finder = interop.CreateFinder();
var fullSourceName = await WaitForSourceAsync(interop, finder, sourceShortName, TimeSpan.FromSeconds(5));
fullSourceName.Should().NotBeNullOrEmpty(
because: "fake Teams sender should be visible to a same-process finder within 5s");
// ─── 3. Build the production IsoPipeline against it ────────────
var settings = FrameProcessingSettings.Default; // 1080p, 59.94 fps, Pillarbox
var clock = new PeriodicTimerFrameClock(settings.FramerateHz);
var scaler = new ManagedNearestNeighborFrameScaler();
var config = new IsoPipelineConfig(Guid.NewGuid(), fullSourceName!, outputName, settings);
await using var pipeline = new IsoPipeline(
config, interop, scaler, clock,
ExponentialBackoff.Default,
(delay, ct) => Task.Delay(delay, ct),
NullLoggerFactory.Instance);
await pipeline.StartAsync();
// ─── 4. Wait for the pipeline's output sender to appear ────────
var outputFullName = await WaitForSourceAsync(interop, finder, outputName, TimeSpan.FromSeconds(5));
outputFullName.Should().NotBeNullOrEmpty(
because: "IsoPipeline must broadcast a TEAMSISO_* sender within 5s of StartAsync");
// ─── 5. Receive a frame from the normalized output ─────────────
using var receiver = interop.CreateReceiver(outputFullName!);
RawFrame? captured = null;
var captureDeadline = DateTime.UtcNow.AddSeconds(8);
while (DateTime.UtcNow < captureDeadline)
{
captured = interop.CaptureFrame(receiver, 1000);
if (captured is not null) break;
await Task.Delay(50);
}
captured.Should().NotBeNull(
because: "the pipeline output must yield at least one normalized frame within 8s");
// ─── 6. Assert the normalized dimensions match the target ──────
var (expectedW, expectedH) = settings.ResolutionSize;
captured!.Width.Should().Be(expectedW,
because: $"the pipeline normalizes to {expectedW}x{expectedH} per FrameProcessingSettings.Default");
captured.Height.Should().Be(expectedH);
// ─── 7. Stop pipeline cleanly ──────────────────────────────────
await pipeline.StopAsync();
}
finally
{
pumpCts.Cancel();
try { await pumpTask; }
catch (OperationCanceledException) { /* expected */ }
}
}
/// <summary>
/// Polls the finder until a source whose name contains <paramref name="needle"/>
/// appears, or the timeout elapses. Returns the full NDI name (machine + paren
/// short-name) on success, or null on timeout.
/// </summary>
private static async Task<string?> WaitForSourceAsync(
NdiInteropPInvoke interop,
TeamsISO.Engine.Interop.NdiFindHandle finder,
string needle,
TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
var sources = interop.GetCurrentSources(finder);
var match = sources.FirstOrDefault(s => s.Contains(needle, StringComparison.Ordinal));
if (match is not null) return match;
await Task.Delay(150);
}
return null;
}
}

View file

@ -1,54 +0,0 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using TeamsISO.Engine.Logging;
namespace TeamsISO.Engine.Tests.Logging;
public class EngineLoggingTests : IDisposable
{
private readonly string _dir;
public EngineLoggingTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"teamsiso-log-{Guid.NewGuid():N}");
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { /* best-effort */ }
}
[Fact]
public void CreateDefault_AllLoggers_WriteToFile()
{
// Multiple ILoggers from the same factory must all land in the file sink —
// catches regressions in CreateDefault wiring (e.g. if SerilogLoggerFactory
// swaps to Log.Logger silently and our static singleton isn't set).
var factory = EngineLogging.CreateDefault(LogLevel.Information, logDirectoryOverride: _dir);
factory.CreateLogger<EngineLoggingTests>().LogInformation("typed-logger-line");
factory.CreateLogger("Custom.Category").LogInformation("named-logger-line");
factory.Dispose(); // disposes the wrapped Serilog logger -> flush + close file
var logFiles = Directory.GetFiles(_dir, "*.log");
logFiles.Should().HaveCount(1);
var content = File.ReadAllText(logFiles[0]);
content.Should().Contain("typed-logger-line",
because: "logs from a typed CreateLogger<T> must reach the file sink");
content.Should().Contain("named-logger-line",
because: "logs from a named CreateLogger(string) must reach the file sink");
}
[Fact]
public void CreateDefault_LogsAtBelowMinimumLevel_AreSuppressed()
{
var factory = EngineLogging.CreateDefault(LogLevel.Warning, logDirectoryOverride: _dir);
factory.CreateLogger("X").LogInformation("info-should-be-suppressed");
factory.CreateLogger("X").LogWarning("warn-should-appear");
factory.Dispose();
var content = File.ReadAllText(Directory.GetFiles(_dir, "*.log").Single());
content.Should().NotContain("info-should-be-suppressed");
content.Should().Contain("warn-should-appear");
}
}