fix: cold-start discovery + installer shortcuts + single-instance hardening
Some checks failed
CI / build-and-test (push) Failing after 26s

Three independent fixes bundled because all were chasing the same operator
report: 'I just installed, launched from the shortcut, no participants.'

1) NdiDiscoveryService: poll immediately, then ramp from 200ms to the
   configured interval over the first 3 seconds. PeriodicTimer.WaitForNext-
   TickAsync waits the full interval before its first tick, so for a 500ms
   discovery interval the operator stared at 'no ndi sources yet' for half
   a second on every cold start. Force-poll up front (catches the runtime
   cache), then run a fast inner loop for ~3s while mDNS replies trickle
   in. Both loops share a try/finally so the NDI finder is always disposed.

2) MainViewModel.IsDiscovering: new boolean, true for 8s after engine start
   AS LONG AS no participants have arrived. MainWindow.xaml swaps the
   empty-state copy on this binding:
     IsDiscovering=true  -> 'scanning for ndi sources...' (cyan dot)
     IsDiscovering=false -> 'no ndi sources visible -- is teams in a
                            meeting?' + Refresh CTA
   The old copy ('no ndi sources yet -- open teams and start a meeting')
   was being shown immediately at launch even when discovery just hadn't
   run yet, making the app look broken.

3) App.xaml.cs: single-instance mutex moved from Local\ to Global\. On
   admin-user boxes with UAC disabled, launches from different parents
   (elevated File Explorer, non-elevated shell, etc.) can land in slightly
   different security contexts and a Local\ name can be invisible to the
   sibling. Global\ namespace closes that hole — both processes see the
   same mutex regardless of integrity. Belt-and-braces against future
   dual-instance file/port contention.

4) installer/Package.wxs: add a Desktop shortcut component (per-machine
   feature, HKCU keypath per ICE38/ICE43). Operators who can't find the
   Start Menu entry get the Desktop icon. Both shortcuts target the
   installed exe, NOT a stale path under publish/.
This commit is contained in:
Zac Gaetano 2026-05-16 11:23:19 -04:00
parent f47edfb2f6
commit 09e5b59dfd
5 changed files with 195 additions and 34 deletions

View file

@ -40,6 +40,7 @@
<Feature Id="Main" Title="TeamsISO" Level="1"> <Feature Id="Main" Title="TeamsISO" Level="1">
<ComponentGroupRef Id="ApplicationFiles" /> <ComponentGroupRef Id="ApplicationFiles" />
<ComponentGroupRef Id="Shortcuts" /> <ComponentGroupRef Id="Shortcuts" />
<ComponentGroupRef Id="DesktopShortcut" />
<ComponentGroupRef Id="ArpEntry" /> <ComponentGroupRef Id="ArpEntry" />
</Feature> </Feature>
@ -112,8 +113,9 @@
</ComponentGroup> </ComponentGroup>
<!-- <!--
Start Menu shortcut to the WPF host. KeyPath sits on a registry Start Menu shortcut to the WPF host. KeyPath sits on a per-user
value so component identity is stable across upgrades. registry value because the .lnk file lives in a per-user Start Menu
folder (ICE38/ICE43 enforce this even for perMachine installs).
--> -->
<ComponentGroup Id="Shortcuts" Directory="WildDragonStartMenuFolder"> <ComponentGroup Id="Shortcuts" Directory="WildDragonStartMenuFolder">
<Component Id="StartMenuShortcut" Guid="*"> <Component Id="StartMenuShortcut" Guid="*">
@ -135,6 +137,29 @@
</Component> </Component>
</ComponentGroup> </ComponentGroup>
<!--
Desktop shortcut — belt-and-braces. Operators who lose the Start Menu
entry (or whose Start Menu doesn't materialize advertised shortcuts
cleanly) still get a Desktop icon. Same HKCU keypath rule as above.
-->
<StandardDirectory Id="DesktopFolder" />
<ComponentGroup Id="DesktopShortcut" Directory="DesktopFolder">
<Component Id="DesktopShortcutComponent" Guid="*">
<Shortcut Id="DesktopTeamsISO"
Name="TeamsISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]TeamsISO.exe"
WorkingDirectory="INSTALLFOLDER"
Icon="TeamsISOIcon" />
<RegistryValue Root="HKCU"
Key="Software\Wild Dragon\TeamsISO"
Name="DesktopShortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</ComponentGroup>
<!-- <!--
ARP icon registry entry. Optional — the MSI auto-fills most ARP ARP icon registry entry. Optional — the MSI auto-fills most ARP
fields from the Package element. We only need to point at the fields from the Package element. We only need to point at the

View file

@ -27,13 +27,24 @@ namespace TeamsISO.App;
public partial class App : Application public partial class App : Application
{ {
/// <summary> /// <summary>
/// Per-user mutex name. Including the SID-equivalent (the username) ensures two /// Per-user mutex name. Including the username (acting as a SID proxy) ensures two
/// different Windows users can each run TeamsISO on the same machine, while one /// 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 /// user can't spawn duplicate instances that would contend over the NDI runtime
/// and the shared %APPDATA%\TeamsISO\config.json. /// and the shared %APPDATA%\TeamsISO\config.json.
///
/// The "Global\" prefix puts the named object in the system-wide namespace
/// (not session-local or integrity-isolated). This matters because when an
/// admin user has UAC effectively disabled, launches from different parents
/// (elevated File Explorer, non-elevated shell, etc.) can land in slightly
/// different security contexts. A "Local\" mutex was being created in
/// different views per integrity level on some boxes, letting two TeamsISO
/// instances run concurrently — the second's REST surface couldn't bind port
/// 9755 (already held) and its Serilog file sink couldn't open the daily log
/// (already held with shared=false), producing a window that looked like
/// the app but had no engine attached. Global\ closes that gap.
/// </summary> /// </summary>
private static readonly string SingleInstanceMutexName = private static readonly string SingleInstanceMutexName =
$"Local\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}"; $"Global\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}";
private System.Threading.Mutex? _singleInstanceMutex; private System.Threading.Mutex? _singleInstanceMutex;
private bool _ownsSingleInstanceMutex; private bool _ownsSingleInstanceMutex;

View file

@ -689,11 +689,40 @@
<!-- Empty-state placeholder. Renders when no NDI participants <!-- Empty-state placeholder. Renders when no NDI participants
have been discovered yet. Mono sentence + one tertiary have been discovered yet. Mono sentence + one tertiary
Refresh button — no illustration, no mascot, per the v2 Refresh button — no illustration, no mascot, per the v2
shape brief's empty-states section. --> shape brief's empty-states section.
Two visual flavors gated by IsDiscovering (the VM holds
it true for ~8s after engine start, false thereafter):
- IsDiscovering=true → "Scanning for NDI sources…"
(neutral; cold-start can take
1-3s for mDNS to settle)
- IsDiscovering=false → the explanatory empty state
("open teams and start a
meeting") + Refresh CTA
This stops operators from staring at a "broken-looking"
empty table during the first second of every launch. -->
<StackPanel HorizontalAlignment="Center" <StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}, ConverterParameter=empty}"> Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}, ConverterParameter=empty}">
<TextBlock Text="no ndi sources yet — open teams and start a meeting" <!-- Discovering: cyan dot + neutral progress copy. -->
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center"
Visibility="{Binding IsDiscovering, Converter={StaticResource BoolToVis}}">
<Ellipse Width="7" Height="7"
Fill="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"
Margin="0,0,10,0"/>
<TextBlock Text="scanning for ndi sources…"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
VerticalAlignment="Center"/>
</StackPanel>
<!-- Not discovering (grace window expired with no sources):
the explanatory empty state. -->
<StackPanel HorizontalAlignment="Center"
Visibility="{Binding IsDiscovering, Converter={StaticResource BoolToVisInverse}}">
<TextBlock Text="no ndi sources visible — is teams in a meeting?"
FontFamily="{StaticResource Wd.Font.Mono}" FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12" FontSize="12"
Foreground="{DynamicResource Wd.Text.Tertiary}" Foreground="{DynamicResource Wd.Text.Tertiary}"
@ -708,6 +737,7 @@
FontSize="11" FontSize="11"
ToolTip="Rebuild the NDI finder"/> ToolTip="Rebuild the NDI finder"/>
</StackPanel> </StackPanel>
</StackPanel>
</Grid> </Grid>
</Border> </Border>
</Grid> </Grid>

View file

@ -31,6 +31,19 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new(); private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
private string _statusText = "Starting…"; private string _statusText = "Starting…";
/// <summary>
/// Wall-clock at which <see cref="InitializeAsync"/> kicked the engine. Used to
/// gate the "Scanning for NDI sources…" placeholder so it shows for a few
/// seconds after launch even when ParticipantCount == 0 (the bleak
/// "no ndi sources yet" empty state was being shown immediately and
/// operators assumed the app was broken before discovery had a chance to fire).
/// Null until InitializeAsync runs.
/// </summary>
private DateTimeOffset? _engineStartedAt;
/// <summary>How long after engine start to keep showing "Scanning…" instead of the empty-state copy.</summary>
private static readonly TimeSpan DiscoveryGracePeriod = TimeSpan.FromSeconds(8);
// _pendingPresetName / Deadline / Applied + the auto-apply path // _pendingPresetName / Deadline / Applied + the auto-apply path
// moved to MainViewModel.PresetCommands.cs. // moved to MainViewModel.PresetCommands.cs.
@ -233,6 +246,21 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
} }
private int _participantCount; private int _participantCount;
/// <summary>
/// True for the first <see cref="DiscoveryGracePeriod"/> after engine start.
/// The XAML uses this to swap the empty-state placeholder from the bleak
/// "no ndi sources yet — open teams and start a meeting" copy (which reads
/// as broken to operators who just launched into an active meeting) to a
/// neutral "Scanning for NDI sources…" status while NDI Find resolves
/// mDNS responses. Always false once participants populate.
/// </summary>
public bool IsDiscovering
{
get => _isDiscovering;
private set => SetField(ref _isDiscovering, value);
}
private bool _isDiscovering;
/// <summary> /// <summary>
/// Currently-enabled (live) ISO count — feeds the v2 transport strip's /// Currently-enabled (live) ISO count — feeds the v2 transport strip's
/// "LIVE N" readout. The number is cyan-tinted when non-zero to draw /// "LIVE N" readout. The number is cyan-tinted when non-zero to draw
@ -530,6 +558,19 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
ParticipantCount = totalParticipants; ParticipantCount = totalParticipants;
LiveCount = enabledCount; LiveCount = enabledCount;
// IsDiscovering gates the "Scanning for NDI sources…" placeholder.
// True for DiscoveryGracePeriod after engine start AS LONG AS we
// haven't seen any participants yet; once anything arrives we drop
// out of the discovering state immediately (back to the OK path).
if (totalParticipants == 0 && _engineStartedAt is { } startedAt)
{
IsDiscovering = DateTimeOffset.UtcNow - startedAt < DiscoveryGracePeriod;
}
else if (IsDiscovering)
{
IsDiscovering = false;
}
// Session timer — start on first ISO going live, reset when none are // Session timer — start on first ISO going live, reset when none are
// live anymore. Subsequent enables after a full-zero gap restart the // live anymore. Subsequent enables after a full-zero gap restart the
// timer rather than resuming, which is the operator's mental model: // timer rather than resuming, which is the operator's mental model:
@ -600,6 +641,8 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
public async Task InitializeAsync(CancellationToken cancellationToken) public async Task InitializeAsync(CancellationToken cancellationToken)
{ {
StatusText = "Discovering NDI sources…"; StatusText = "Discovering NDI sources…";
_engineStartedAt = DateTimeOffset.UtcNow;
IsDiscovering = true;
await _controller.StartAsync(cancellationToken); await _controller.StartAsync(cancellationToken);
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target."; StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";

View file

@ -77,6 +77,57 @@ public sealed class NdiDiscoveryService
/// <summary>Long-running poll loop. Cancel the token to stop.</summary> /// <summary>Long-running poll loop. Cancel the token to stop.</summary>
public async Task RunAsync(TimeSpan pollInterval, CancellationToken cancellationToken) public async Task RunAsync(TimeSpan pollInterval, CancellationToken cancellationToken)
{ {
// Wrap the whole method in try/finally so _finder is always disposed,
// including the cancellation paths inside the fast-poll loop.
try
{
// First poll happens IMMEDIATELY — PeriodicTimer.WaitForNextTickAsync
// waits the full interval before its first tick, which created a
// noticeable cold-start window where the UI showed "no ndi sources yet"
// for ~500ms (or whatever the interval is) before discovery had a chance
// to fire. Operators launching into a meeting that was already broadcasting
// saw an empty table and assumed it was broken. Poll once up front to
// pull whatever the NDI runtime has already cached, then settle into
// the regular poll cadence.
try { PollOnce(); }
catch (Exception ex) { _logger.LogWarning(ex, "Initial discovery poll failed; falling through to timer loop."); }
// Aggressive poll cadence for the first ~3 seconds so cold-start mDNS
// resolution surfaces quickly (mDNS responses can lag the initial socket
// setup by 200-1500ms depending on the network and the responder); after
// that ramp down to the operator-configured pollInterval.
var fastInterval = TimeSpan.FromMilliseconds(200);
var startedAt = DateTimeOffset.UtcNow;
var rampToFullAfter = TimeSpan.FromSeconds(3);
using (var fastTimer = new PeriodicTimer(fastInterval))
{
try
{
while (DateTimeOffset.UtcNow - startedAt < rampToFullAfter)
{
if (!await fastTimer.WaitForNextTickAsync(cancellationToken)) break;
if (Interlocked.Exchange(ref _refreshRequested, 0) == 1)
{
try
{
_logger.LogInformation("Rebuilding NDI finder on operator request.");
_finder.Dispose();
_finder = _interop.CreateFinder(_discoveryGroups);
_previous.Clear();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Finder refresh failed; continuing with existing finder.");
}
}
try { PollOnce(); }
catch (Exception ex) { _logger.LogWarning(ex, "Discovery poll failed; will retry on next tick."); }
}
}
catch (OperationCanceledException) { return; }
}
using var timer = new PeriodicTimer(pollInterval); using var timer = new PeriodicTimer(pollInterval);
try try
{ {
@ -101,6 +152,7 @@ public sealed class NdiDiscoveryService
} }
} }
catch (OperationCanceledException) { /* expected */ } catch (OperationCanceledException) { /* expected */ }
}
finally finally
{ {
_finder.Dispose(); _finder.Dispose();