Some checks failed
CI / build-and-test (push) Failing after 28s
Two new persisted preferences in DISPLAY settings, paired to give operators the 'launch TeamsISO, never see Teams' experience the user asked for: - LaunchTeamsOnStartup: TeamsISO auto-starts Teams in the background each launch (fire-and-forget background task in App.OnStartup, after the main window has materialized so a slow Teams launch doesn't delay the UI). - AutoHideTeamsWindows: as soon as Teams' windows materialize after launch, hide them. New TeamsLauncher.AutoHideAfterLaunchAsync runs a polling loop (250ms / up to 15s) that catches the splash, main window, and any follow-up panels Teams opens. Teams takes 2-5s to render its main window and the splash arrives separately, so a one-shot hide right after launch wouldn't be enough. When TeamsISO starts and Teams is already running (from a prior session), the auto-hide path still fires so the 'I only see TeamsISO' rule applies even when Teams was launched externally. Operator drives everything through the IN-CALL bar (mute / camera / share / leave / marker) + participants DataGrid (ISO routing). Eye-toggle in the rail still restores Teams windows on demand. Both toggles default to off — opt-in. Persisted via UIPreferences so they survive process restart.
402 lines
18 KiB
C#
402 lines
18 KiB
C#
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
|
|
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
|
|
{
|
|
/// <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);
|
|
|
|
/// <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; <paramref name="errorMessage"/> includes the
|
|
/// reasons each attempt was rejected so the operator can see why.
|
|
///
|
|
/// Path order matters:
|
|
/// 1. <c>ms-teams:</c> URI — new Teams (MSTeams AppX) registers this
|
|
/// handler at install. Activates through the AppX shell so the
|
|
/// stub <c>ms-teams.exe</c> in WindowsApps gets the right context.
|
|
/// 2. AppsFolder shell verb — direct AppX activation. Belt-and-braces
|
|
/// fallback if a misconfigured registry breaks the URI handler.
|
|
/// 3. Classic Teams Update.exe — pre-2024 Teams installations.
|
|
/// We deliberately DON'T try the bare <c>ms-teams.exe</c> WindowsApps
|
|
/// path: it's a 0-byte AppX placeholder that fails silently when invoked
|
|
/// without AppX activation context. Looked plausible, never worked.
|
|
/// </summary>
|
|
public static bool TryLaunch(out string? errorMessage)
|
|
{
|
|
errorMessage = null;
|
|
var attempts = new List<string>();
|
|
|
|
// Path 1: URI scheme. The shell handler picks the registered Teams
|
|
// (new MSTeams takes priority on modern Windows). UseShellExecute=true
|
|
// is required — Win32 Process creation can't open URIs directly.
|
|
if (TryStart("ms-teams:", useShell: true, out var err1)) return true;
|
|
attempts.Add($"ms-teams: URI → {err1}");
|
|
|
|
// Path 2: AppX activation via the explorer.exe shell. Modern Teams
|
|
// ships as MSTeams_8wekyb3d8bbwe; if other code on the box has
|
|
// clobbered the URI registration, this still works because it goes
|
|
// through the AppsFolder verb the OS itself uses for Start menu launches.
|
|
if (TryStart("explorer.exe", false, out var err2,
|
|
arguments: "shell:appsFolder\\MSTeams_8wekyb3d8bbwe!MSTeams"))
|
|
return true;
|
|
attempts.Add($"AppsFolder shell → {err2}");
|
|
|
|
// 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)
|
|
{
|
|
attempts.Add($"classic Update.exe → {ex.Message}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
attempts.Add($"classic Update.exe → not found at {classicUpdater}");
|
|
}
|
|
|
|
errorMessage = "No Microsoft Teams installation could be launched. " +
|
|
"Install Teams from https://www.microsoft.com/microsoft-teams and try again.\n\n" +
|
|
"Attempts:\n • " + string.Join("\n • ", attempts);
|
|
return false;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
private static bool TryStart(string target, bool useShell, out string error, string? arguments = null)
|
|
{
|
|
error = string.Empty;
|
|
try
|
|
{
|
|
var info = new ProcessStartInfo
|
|
{
|
|
FileName = target,
|
|
UseShellExecute = useShell,
|
|
CreateNoWindow = true,
|
|
};
|
|
if (arguments is not null) info.Arguments = arguments;
|
|
Process.Start(info);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
error = ex.Message;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
// Phase E.2 — window orchestration
|
|
//
|
|
// Once Teams is running, we want to be able to hide its main window so the
|
|
// operator only sees TeamsISO. We do this by enumerating top-level windows,
|
|
// matching against each Teams process Id, and ShowWindow(SW_HIDE)-ing each
|
|
// match. To restore we ShowWindow(SW_SHOW) and SetForegroundWindow.
|
|
//
|
|
// We deliberately don't use the Process.MainWindowHandle convenience because
|
|
// new MSTeams (WebView2-hosted) creates several top-level windows per
|
|
// process and Process picks an inconsistent one across launches; iterating
|
|
// via EnumWindows + GetWindowThreadProcessId catches every visible window
|
|
// owned by the process.
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
|
|
private const int SW_HIDE = 0;
|
|
private const int SW_SHOWNORMAL = 1;
|
|
private const int SW_SHOW = 5;
|
|
private const int SW_RESTORE = 9;
|
|
|
|
private const uint GW_OWNER = 4;
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
|
|
|
[DllImport("user32.dll", SetLastError = true)]
|
|
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
|
|
|
[DllImport("user32.dll")]
|
|
[return: MarshalAs(UnmanagedType.Bool)]
|
|
private static extern bool IsWindowVisible(IntPtr hWnd);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);
|
|
|
|
[DllImport("user32.dll")]
|
|
[return: MarshalAs(UnmanagedType.Bool)]
|
|
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
|
|
|
[DllImport("user32.dll")]
|
|
[return: MarshalAs(UnmanagedType.Bool)]
|
|
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
|
|
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
|
|
|
/// <summary>
|
|
/// Enumerate every visible top-level window owned by any running Teams
|
|
/// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is
|
|
/// not a tooltip or popup of another). Used by Hide/Show.
|
|
/// </summary>
|
|
private static List<IntPtr> FindTeamsTopLevelWindows()
|
|
{
|
|
var teamsPids = new HashSet<uint>(
|
|
TeamsProcessNames
|
|
.SelectMany(n => Process.GetProcessesByName(n))
|
|
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
|
|
if (teamsPids.Count == 0) return new List<IntPtr>();
|
|
|
|
var windows = new List<IntPtr>();
|
|
EnumWindows((hWnd, _) =>
|
|
{
|
|
if (!IsWindowVisible(hWnd)) return true;
|
|
if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true; // not top-level
|
|
GetWindowThreadProcessId(hWnd, out var pid);
|
|
if (teamsPids.Contains(pid)) windows.Add(hWnd);
|
|
return true;
|
|
}, IntPtr.Zero);
|
|
return windows;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hides every visible top-level Teams window. Returns the count hidden;
|
|
/// 0 means Teams isn't running or has no visible windows yet (it can take
|
|
/// a couple seconds after launch for the splash to materialize).
|
|
/// </summary>
|
|
public static int HideWindows()
|
|
{
|
|
var windows = FindTeamsTopLevelWindows();
|
|
foreach (var w in windows) ShowWindow(w, SW_HIDE);
|
|
return windows.Count;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fire-and-forget background watcher that polls every 250ms for up to
|
|
/// <paramref name="timeout"/> and hides any visible top-level Teams
|
|
/// windows it finds. Used after launch so the operator never sees the
|
|
/// Teams UI flash on screen — Teams takes 2-5s to splash + render its
|
|
/// main window, and the splash arrives separately from the main window
|
|
/// (so we keep polling past the first hide to catch follow-up windows).
|
|
///
|
|
/// Returns the Task so callers can await completion if they want, but
|
|
/// production code should fire-and-forget. Exceptions are swallowed —
|
|
/// failure to hide is harmless (user just sees Teams briefly).
|
|
/// </summary>
|
|
public static Task AutoHideAfterLaunchAsync(TimeSpan? timeout = null, CancellationToken ct = default)
|
|
{
|
|
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(15));
|
|
return Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
var hiddenAny = false;
|
|
while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline)
|
|
{
|
|
// Poll for visible windows. Each iteration may catch new
|
|
// ones — Teams sometimes opens a small splash, then a
|
|
// larger main window 1-2s later, then a "What's new"
|
|
// banner. Keep hiding until we've gone a full second
|
|
// with nothing new appearing.
|
|
var hidden = HideWindows();
|
|
if (hidden > 0)
|
|
{
|
|
hiddenAny = true;
|
|
// Settling delay: after we hide windows, wait a beat
|
|
// before polling again so we don't busy-loop while
|
|
// Teams' window manager catches up.
|
|
await Task.Delay(750, ct).ConfigureAwait(false);
|
|
}
|
|
else if (hiddenAny)
|
|
{
|
|
// We hid at least once; if the next poll finds
|
|
// nothing, Teams has settled. Bail early.
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
// Teams hasn't materialized yet; keep waiting.
|
|
await Task.Delay(250, ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException) { /* expected on cancel */ }
|
|
catch { /* defensive — auto-hide is best-effort, never breaks the app */ }
|
|
}, ct);
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// Keyboard-shortcut forwarding (PostMessage path).
|
|
//
|
|
// UIAutomation (TeamsControlBridge) is our preferred way to drive Teams
|
|
// because it works regardless of foreground/visibility state. PostMessage
|
|
// is a fallback for shortcuts that don't have a stable UIA-discoverable
|
|
// button — chat scroll, custom keymap actions, etc. Note: WebView2-hosted
|
|
// Teams (the modern client) frequently ignores PostMessage(WM_KEYDOWN) at
|
|
// its app-shortcut layer because shortcut routing happens after focus
|
|
// changes, not on raw key messages. Treat this as best-effort.
|
|
// ────────────────────────────────────────────────────────────────────
|
|
|
|
private const uint WM_KEYDOWN = 0x0100;
|
|
private const uint WM_KEYUP = 0x0101;
|
|
private const uint WM_CHAR = 0x0102;
|
|
private const uint WM_SYSKEYDOWN = 0x0104;
|
|
private const uint WM_SYSKEYUP = 0x0105;
|
|
|
|
[Flags]
|
|
public enum ShortcutModifiers
|
|
{
|
|
None = 0,
|
|
Ctrl = 1,
|
|
Shift = 2,
|
|
Alt = 4,
|
|
}
|
|
|
|
[DllImport("user32.dll")]
|
|
[return: MarshalAs(UnmanagedType.Bool)]
|
|
private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
|
|
|
/// <summary>
|
|
/// Sends a synthesized key press (modifier-down, key-down, key-up,
|
|
/// modifier-up) to the most recently used top-level Teams window via
|
|
/// PostMessage. Returns true if a window was found to send to. Note that
|
|
/// returning true doesn't guarantee Teams reacted — modern WebView2-based
|
|
/// Teams sometimes ignores synthesized key messages at the app-shortcut
|
|
/// layer. Prefer UIA (<see cref="TeamsControlBridge"/>) when an equivalent
|
|
/// button exists.
|
|
/// </summary>
|
|
public static bool SendShortcut(ShortcutModifiers modifiers, int virtualKey)
|
|
{
|
|
var windows = FindTeamsTopLevelWindows();
|
|
if (windows.Count == 0) return false;
|
|
var hwnd = windows[^1];
|
|
|
|
// Modifier key downs
|
|
if ((modifiers & ShortcutModifiers.Ctrl) != 0)
|
|
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)0x11, IntPtr.Zero); // VK_CONTROL
|
|
if ((modifiers & ShortcutModifiers.Shift) != 0)
|
|
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)0x10, IntPtr.Zero); // VK_SHIFT
|
|
if ((modifiers & ShortcutModifiers.Alt) != 0)
|
|
PostMessage(hwnd, WM_SYSKEYDOWN, (IntPtr)0x12, IntPtr.Zero); // VK_MENU
|
|
|
|
// Main key down + up
|
|
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)virtualKey, IntPtr.Zero);
|
|
PostMessage(hwnd, WM_KEYUP, (IntPtr)virtualKey, IntPtr.Zero);
|
|
|
|
// Modifier key ups (reverse order)
|
|
if ((modifiers & ShortcutModifiers.Alt) != 0)
|
|
PostMessage(hwnd, WM_SYSKEYUP, (IntPtr)0x12, IntPtr.Zero);
|
|
if ((modifiers & ShortcutModifiers.Shift) != 0)
|
|
PostMessage(hwnd, WM_KEYUP, (IntPtr)0x10, IntPtr.Zero);
|
|
if ((modifiers & ShortcutModifiers.Ctrl) != 0)
|
|
PostMessage(hwnd, WM_KEYUP, (IntPtr)0x11, IntPtr.Zero);
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restores every Teams top-level window from hidden state and brings the
|
|
/// most recently used one to the foreground. Returns the count shown.
|
|
/// </summary>
|
|
public static int ShowWindows()
|
|
{
|
|
// To find hidden windows too we still enumerate, but our IsWindowVisible
|
|
// filter would skip them. Re-implement here with the visible check off.
|
|
var teamsPids = new HashSet<uint>(
|
|
TeamsProcessNames
|
|
.SelectMany(n => Process.GetProcessesByName(n))
|
|
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
|
|
var windows = new List<IntPtr>();
|
|
EnumWindows((hWnd, _) =>
|
|
{
|
|
if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true;
|
|
GetWindowThreadProcessId(hWnd, out var pid);
|
|
if (teamsPids.Contains(pid)) windows.Add(hWnd);
|
|
return true;
|
|
}, IntPtr.Zero);
|
|
|
|
foreach (var w in windows) ShowWindow(w, SW_SHOW);
|
|
if (windows.Count > 0) SetForegroundWindow(windows[^1]);
|
|
return windows.Count;
|
|
}
|
|
}
|