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">
|
||||
<ComponentGroupRef Id="ApplicationFiles" />
|
||||
<ComponentGroupRef Id="Shortcuts" />
|
||||
<ComponentGroupRef Id="DesktopShortcut" />
|
||||
<ComponentGroupRef Id="ArpEntry" />
|
||||
</Feature>
|
||||
|
||||
|
|
@ -112,8 +113,9 @@
|
|||
</ComponentGroup>
|
||||
|
||||
<!--
|
||||
Start Menu shortcut to the WPF host. KeyPath sits on a registry
|
||||
value so component identity is stable across upgrades.
|
||||
Start Menu shortcut to the WPF host. KeyPath sits on a per-user
|
||||
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">
|
||||
<Component Id="StartMenuShortcut" Guid="*">
|
||||
|
|
@ -135,6 +137,29 @@
|
|||
</Component>
|
||||
</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
|
||||
fields from the Package element. We only need to point at the
|
||||
|
|
|
|||
|
|
@ -27,13 +27,24 @@ namespace TeamsISO.App;
|
|||
public partial class App : Application
|
||||
{
|
||||
/// <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
|
||||
/// user can't spawn duplicate instances that would contend over the NDI runtime
|
||||
/// 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>
|
||||
private static readonly string SingleInstanceMutexName =
|
||||
$"Local\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}";
|
||||
$"Global\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}";
|
||||
|
||||
private System.Threading.Mutex? _singleInstanceMutex;
|
||||
private bool _ownsSingleInstanceMutex;
|
||||
|
|
|
|||
|
|
@ -689,11 +689,40 @@
|
|||
<!-- Empty-state placeholder. Renders when no NDI participants
|
||||
have been discovered yet. Mono sentence + one tertiary
|
||||
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"
|
||||
VerticalAlignment="Center"
|
||||
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}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||
|
|
@ -708,6 +737,7 @@
|
|||
FontSize="11"
|
||||
ToolTip="Rebuild the NDI finder"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,19 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
|
|||
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
|
||||
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
|
||||
// moved to MainViewModel.PresetCommands.cs.
|
||||
|
||||
|
|
@ -233,6 +246,21 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
|
|||
}
|
||||
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>
|
||||
/// Currently-enabled (live) ISO count — feeds the v2 transport strip's
|
||||
/// "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;
|
||||
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
|
||||
// live anymore. Subsequent enables after a full-zero gap restart the
|
||||
// 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)
|
||||
{
|
||||
StatusText = "Discovering NDI sources…";
|
||||
_engineStartedAt = DateTimeOffset.UtcNow;
|
||||
IsDiscovering = true;
|
||||
await _controller.StartAsync(cancellationToken);
|
||||
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,57 @@ public sealed class NdiDiscoveryService
|
|||
/// <summary>Long-running poll loop. Cancel the token to stop.</summary>
|
||||
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);
|
||||
try
|
||||
{
|
||||
|
|
@ -101,6 +152,7 @@ public sealed class NdiDiscoveryService
|
|||
}
|
||||
}
|
||||
catch (OperationCanceledException) { /* expected */ }
|
||||
}
|
||||
finally
|
||||
{
|
||||
_finder.Dispose();
|
||||
|
|
|
|||
Loading…
Reference in a new issue