2026-05-08 01:05:26 -04:00
|
|
|
using System.Diagnostics;
|
|
|
|
|
using System.IO;
|
|
|
|
|
|
|
|
|
|
namespace TeamsISO.App.Services;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Small Win32 wrapper that launches the Microsoft Teams desktop client as a
|
|
|
|
|
/// subprocess of TeamsISO. First step toward Phase E.1 of the embedded-Teams
|
|
|
|
|
/// roadmap (see docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md):
|
|
|
|
|
/// the operator can launch Teams from within TeamsISO so they don't have to
|
|
|
|
|
/// switch apps to start a meeting.
|
|
|
|
|
///
|
|
|
|
|
/// The launcher tries (in order):
|
|
|
|
|
/// 1. ms-teams: URI (works for both classic and new Teams)
|
|
|
|
|
/// 2. MSTeams.exe in %LOCALAPPDATA%\Microsoft\WindowsApps\
|
|
|
|
|
/// 3. Teams.exe in %LOCALAPPDATA%\Microsoft\Teams\Update.exe (legacy)
|
|
|
|
|
///
|
|
|
|
|
/// Group-routing automation (writing NDI Access Manager config so Teams
|
|
|
|
|
/// broadcasts on a private group) is deferred to a follow-up — for v1.0 we
|
|
|
|
|
/// document the manual steps in RELEASING.md and trust the operator to set
|
|
|
|
|
/// them once per machine.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static class TeamsLauncher
|
|
|
|
|
{
|
feat: app icon, FPS, drops counter, --version, About dialog, Stop Teams toggle
Six related polish items, all building on tonight's groundwork.
1. App icon: teamsiso.ico generated from dragon-mark.png at 7 sizes (16-256), wired as ApplicationIcon in the WPF csproj, MainWindow.Icon, AboutWindow.Icon, and ARPPRODUCTICON in the WiX MSI. Taskbar / window / Add-Remove-Programs all show the dragon mark now.
2. Running incoming FPS: ring buffer of last 30 frame timestamps in IsoPipeline; ComputeFps() returns moving-average rate. Surfaced on IsoHealthStats.IncomingFps and shown in the Source column of the participants DataGrid as 'WxH · 59.94 fps'. Resets cleanly on every supervisor restart.
3. Drops counter: FrameProcessor.Stats already aggregated FramesDropped (closest-frame strategy when the receiver outpaces the processor) and FramesDuplicated; just plumbed _liveProcessor through IsoPipeline so GetStats() can read them. Exposed in the Live column under the in/out counters as a coral-tinted 'drop N'.
4. Console --version flag: prints engine version (with embedded git SHA), .NET version, OS, NDI runtime banner, expected prefix, exit-code legend, plus a wilddragon.net link. Useful for support tickets.
5. About dialog: chromeless modal with the dragon mark + version / .NET / OS / NDI runtime fields and a link to wilddragon.net. Triggered by clicking the rail logo.
6. Teams launcher Stop toggle: TeamsLauncher gains IsRunning() and StopAll(). The rail's Teams button now toggles — if Teams is up, ask to close all Teams windows via WM_CLOSE; otherwise launch as before. Confirms before stopping so we don't kill the user's call mid-transition.
Tests: 74/74 unit + 9/9 NDI integration green throughout. MSI builds clean and now embeds the dragon icon for ARP.
2026-05-08 13:50:19 -04:00
|
|
|
/// <summary>
|
|
|
|
|
/// Heuristic process-name candidates we'll consider as "the Teams process" when
|
|
|
|
|
/// the rail toggle wants to find a running instance. New MSTeams comes first.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static readonly string[] TeamsProcessNames =
|
|
|
|
|
{
|
|
|
|
|
"ms-teams", // new MSTeams binary basename
|
|
|
|
|
"msteams", // alternate basename observed on some installs
|
|
|
|
|
"Teams", // classic Teams desktop client
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// True if any process matching the known Teams binary basenames is running.
|
|
|
|
|
/// Used by the rail to decide whether to show "Launch Teams" vs "Stop Teams".
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static bool IsRunning() =>
|
|
|
|
|
TeamsProcessNames.Any(n => Process.GetProcessesByName(n).Length > 0);
|
|
|
|
|
|
2026-05-08 01:05:26 -04:00
|
|
|
/// <summary>
|
|
|
|
|
/// Launches Teams. Returns true if a launch was started successfully (the
|
|
|
|
|
/// process may take a few seconds to actually appear). False if every
|
|
|
|
|
/// fallback path failed.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static bool TryLaunch(out string? errorMessage)
|
|
|
|
|
{
|
|
|
|
|
errorMessage = null;
|
|
|
|
|
|
|
|
|
|
// Path 1: URI scheme. The shell handler picks whichever Teams client
|
|
|
|
|
// is registered (new MSTeams.exe takes priority on modern Windows).
|
|
|
|
|
if (TryStart("ms-teams:", useShell: true)) return true;
|
|
|
|
|
|
|
|
|
|
// Path 2: new Teams' WindowsApps shim.
|
|
|
|
|
var newTeams = Path.Combine(
|
|
|
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
|
|
|
"Microsoft", "WindowsApps", "ms-teams.exe");
|
|
|
|
|
if (File.Exists(newTeams) && TryStart(newTeams, useShell: false)) return true;
|
|
|
|
|
|
|
|
|
|
// Path 3: classic Teams Update.exe with --processStart hands off to
|
|
|
|
|
// the actual Teams.exe via Squirrel.
|
|
|
|
|
var classicUpdater = Path.Combine(
|
|
|
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
|
|
|
"Microsoft", "Teams", "Update.exe");
|
|
|
|
|
if (File.Exists(classicUpdater))
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
Process.Start(new ProcessStartInfo
|
|
|
|
|
{
|
|
|
|
|
FileName = classicUpdater,
|
|
|
|
|
Arguments = "--processStart \"Teams.exe\"",
|
|
|
|
|
UseShellExecute = false,
|
|
|
|
|
CreateNoWindow = true,
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
errorMessage = ex.Message;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
errorMessage ??= "No Microsoft Teams installation was found. Install Teams from https://www.microsoft.com/microsoft-teams and try again.";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
feat: app icon, FPS, drops counter, --version, About dialog, Stop Teams toggle
Six related polish items, all building on tonight's groundwork.
1. App icon: teamsiso.ico generated from dragon-mark.png at 7 sizes (16-256), wired as ApplicationIcon in the WPF csproj, MainWindow.Icon, AboutWindow.Icon, and ARPPRODUCTICON in the WiX MSI. Taskbar / window / Add-Remove-Programs all show the dragon mark now.
2. Running incoming FPS: ring buffer of last 30 frame timestamps in IsoPipeline; ComputeFps() returns moving-average rate. Surfaced on IsoHealthStats.IncomingFps and shown in the Source column of the participants DataGrid as 'WxH · 59.94 fps'. Resets cleanly on every supervisor restart.
3. Drops counter: FrameProcessor.Stats already aggregated FramesDropped (closest-frame strategy when the receiver outpaces the processor) and FramesDuplicated; just plumbed _liveProcessor through IsoPipeline so GetStats() can read them. Exposed in the Live column under the in/out counters as a coral-tinted 'drop N'.
4. Console --version flag: prints engine version (with embedded git SHA), .NET version, OS, NDI runtime banner, expected prefix, exit-code legend, plus a wilddragon.net link. Useful for support tickets.
5. About dialog: chromeless modal with the dragon mark + version / .NET / OS / NDI runtime fields and a link to wilddragon.net. Triggered by clicking the rail logo.
6. Teams launcher Stop toggle: TeamsLauncher gains IsRunning() and StopAll(). The rail's Teams button now toggles — if Teams is up, ask to close all Teams windows via WM_CLOSE; otherwise launch as before. Confirms before stopping so we don't kill the user's call mid-transition.
Tests: 74/74 unit + 9/9 NDI integration green throughout. MSI builds clean and now embeds the dragon icon for ARP.
2026-05-08 13:50:19 -04:00
|
|
|
/// <summary>
|
|
|
|
|
/// Asks every running Teams process to close gracefully via WM_CLOSE
|
|
|
|
|
/// (CloseMainWindow). Returns the count of processes that exited cleanly within
|
|
|
|
|
/// <paramref name="gracePeriod"/>. Stragglers are NOT force-killed — Teams' own
|
|
|
|
|
/// "are you sure" prompt may legitimately keep a process alive briefly, and we
|
|
|
|
|
/// don't want to nuke the user's call mid-transition.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static int StopAll(TimeSpan? gracePeriod = null)
|
|
|
|
|
{
|
|
|
|
|
var grace = gracePeriod ?? TimeSpan.FromSeconds(3);
|
|
|
|
|
var deadline = DateTime.UtcNow + grace;
|
|
|
|
|
var asked = 0;
|
|
|
|
|
foreach (var name in TeamsProcessNames)
|
|
|
|
|
{
|
|
|
|
|
foreach (var p in Process.GetProcessesByName(name))
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (p.HasExited) { p.Dispose(); continue; }
|
|
|
|
|
if (p.MainWindowHandle != IntPtr.Zero)
|
|
|
|
|
{
|
|
|
|
|
p.CloseMainWindow();
|
|
|
|
|
asked++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch { /* defensive: process may have died between enumeration and signal */ }
|
|
|
|
|
finally { p.Dispose(); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Best-effort wait so the rail can flip its icon promptly.
|
|
|
|
|
while (DateTime.UtcNow < deadline && IsRunning())
|
|
|
|
|
Thread.Sleep(150);
|
|
|
|
|
return asked;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 01:05:26 -04:00
|
|
|
private static bool TryStart(string target, bool useShell)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var info = new ProcessStartInfo
|
|
|
|
|
{
|
|
|
|
|
FileName = target,
|
|
|
|
|
UseShellExecute = useShell,
|
|
|
|
|
CreateNoWindow = true,
|
|
|
|
|
};
|
|
|
|
|
Process.Start(info);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|