fix: cold-start discovery + installer shortcuts + single-instance hardening
Some checks failed
CI / build-and-test (push) Failing after 26s
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:
parent
f47edfb2f6
commit
09e5b59dfd
5 changed files with 195 additions and 34 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -689,24 +689,54 @@
|
||||||
<!-- 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. -->
|
||||||
FontFamily="{StaticResource Wd.Font.Mono}"
|
<StackPanel Orientation="Horizontal"
|
||||||
FontSize="12"
|
HorizontalAlignment="Center"
|
||||||
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
Visibility="{Binding IsDiscovering, Converter={StaticResource BoolToVis}}">
|
||||||
HorizontalAlignment="Center"/>
|
<Ellipse Width="7" Height="7"
|
||||||
<Button Style="{StaticResource Wd.Button.Ghost}"
|
Fill="{DynamicResource Wd.Accent.Cyan}"
|
||||||
Command="{Binding RefreshDiscoveryCommand}"
|
VerticalAlignment="Center"
|
||||||
Content="Refresh discovery (Ctrl+R)"
|
Margin="0,0,10,0"/>
|
||||||
Padding="14,7"
|
<TextBlock Text="scanning for ndi sources…"
|
||||||
Margin="0,14,0,0"
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
HorizontalAlignment="Center"
|
FontSize="12"
|
||||||
FontFamily="{StaticResource Wd.Font.Mono}"
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
FontSize="11"
|
VerticalAlignment="Center"/>
|
||||||
ToolTip="Rebuild the NDI finder"/>
|
</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}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Command="{Binding RefreshDiscoveryCommand}"
|
||||||
|
Content="Refresh discovery (Ctrl+R)"
|
||||||
|
Padding="14,7"
|
||||||
|
Margin="0,14,0,0"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
ToolTip="Rebuild the NDI finder"/>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
|
||||||
|
|
@ -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.";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,30 +77,82 @@ 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)
|
||||||
{
|
{
|
||||||
using var timer = new PeriodicTimer(pollInterval);
|
// Wrap the whole method in try/finally so _finder is always disposed,
|
||||||
|
// including the cancellation paths inside the fast-poll loop.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (await timer.WaitForNextTickAsync(cancellationToken))
|
// 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))
|
||||||
{
|
{
|
||||||
if (Interlocked.Exchange(ref _refreshRequested, 0) == 1)
|
try
|
||||||
{
|
{
|
||||||
try
|
while (DateTimeOffset.UtcNow - startedAt < rampToFullAfter)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Rebuilding NDI finder on operator request.");
|
if (!await fastTimer.WaitForNextTickAsync(cancellationToken)) break;
|
||||||
_finder.Dispose();
|
if (Interlocked.Exchange(ref _refreshRequested, 0) == 1)
|
||||||
_finder = _interop.CreateFinder(_discoveryGroups);
|
{
|
||||||
_previous.Clear();
|
try
|
||||||
}
|
{
|
||||||
catch (Exception ex)
|
_logger.LogInformation("Rebuilding NDI finder on operator request.");
|
||||||
{
|
_finder.Dispose();
|
||||||
_logger.LogWarning(ex, "Finder refresh failed; continuing with existing finder.");
|
_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."); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try { PollOnce(); }
|
catch (OperationCanceledException) { return; }
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "Discovery poll failed; will retry on next tick."); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
using var timer = new PeriodicTimer(pollInterval);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
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) { /* expected */ }
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { /* expected */ }
|
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_finder.Dispose();
|
_finder.Dispose();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue