using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; namespace TeamsISO.App.Services; /// /// 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. /// public static class TeamsLauncher { /// /// 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. /// private static readonly string[] TeamsProcessNames = { "ms-teams", // new MSTeams binary basename "msteams", // alternate basename observed on some installs "Teams", // classic Teams desktop client }; /// /// 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". /// public static bool IsRunning() => TeamsProcessNames.Any(n => Process.GetProcessesByName(n).Length > 0); /// /// 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. /// 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; } /// /// Asks every running Teams process to close gracefully via WM_CLOSE /// (CloseMainWindow). Returns the count of processes that exited cleanly within /// . 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. /// 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) { try { var info = new ProcessStartInfo { FileName = target, UseShellExecute = useShell, CreateNoWindow = true, }; Process.Start(info); return true; } catch { 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); /// /// 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. /// private static List FindTeamsTopLevelWindows() { var teamsPids = new HashSet( TeamsProcessNames .SelectMany(n => Process.GetProcessesByName(n)) .Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } })); if (teamsPids.Count == 0) return new List(); var windows = new List(); 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; } /// /// 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). /// public static int HideWindows() { var windows = FindTeamsTopLevelWindows(); foreach (var w in windows) ShowWindow(w, SW_HIDE); return windows.Count; } // ──────────────────────────────────────────────────────────────────── // 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); /// /// 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 () when an equivalent /// button exists. /// 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; } /// /// Restores every Teams top-level window from hidden state and brings the /// most recently used one to the foreground. Returns the count shown. /// 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( TeamsProcessNames .SelectMany(n => Process.GetProcessesByName(n)) .Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } })); var windows = new List(); 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; } }