using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; namespace DragonISO.App.Services; /// /// Small Win32 wrapper that launches the Microsoft Teams desktop client as a /// subprocess of DragonISO. 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 Dragon-ISO 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; includes the /// reasons each attempt was rejected so the operator can see why. /// /// Path order matters: /// 1. ms-teams: URI — new Teams (MSTeams AppX) registers this /// handler at install. Activates through the AppX shell so the /// stub ms-teams.exe 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 ms-teams.exe WindowsApps /// path: it's a 0-byte AppX placeholder that fails silently when invoked /// without AppX activation context. Looked plausible, never worked. /// public static bool TryLaunch(out string? errorMessage) { errorMessage = null; var attempts = new List(); // 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; } /// /// 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; } /// /// Hand a meeting URL off to the Teams shell handler. Accepts both the /// https://teams.microsoft.com/l/meetup-join/... web format and /// the msteams:/l/meetup-join/... deep-link form (either causes /// Teams to launch + join the meeting in one shot — the OS shell maps /// teams.microsoft.com URLs to the registered ms-teams: handler). /// /// Use case: operator pastes a meeting link they got over email / chat /// into Dragon-ISO's quick-join field instead of opening Teams, /// hunting down the calendar entry, and clicking Join. With auto-hide /// on, the Teams window flashes briefly then disappears; the operator /// is now in the meeting, driving routing from DragonISO. /// /// Returns true if the shell accepted the URL; false if URL is malformed /// or rejected. errorMessage populated on failure. /// public static bool TryJoinMeeting(string url, out string? errorMessage) { errorMessage = null; if (string.IsNullOrWhiteSpace(url)) { errorMessage = "URL is empty."; return false; } var trimmed = url.Trim(); // Defensive sanity-check: only accept URLs that obviously target // Teams. We don't want to invoke arbitrary shell handlers from a // clipboard paste — if someone pastes "calc.exe" into the input we // shouldn't launch it. Specifically: http(s) URLs must contain // "teams.microsoft.com" or "teams.live.com"; otherwise must start // with "msteams:". var lower = trimmed.ToLowerInvariant(); var looksLikeTeams = lower.StartsWith("msteams:") || (lower.StartsWith("http://") || lower.StartsWith("https://")) && (lower.Contains("teams.microsoft.com") || lower.Contains("teams.live.com")); if (!looksLikeTeams) { errorMessage = "Not a Microsoft Teams meeting URL. " + "Expected a https://teams.microsoft.com/l/meetup-join/... " + "or msteams:/l/meetup-join/... link."; return false; } if (TryStart(trimmed, useShell: true, out var err)) return true; errorMessage = err; return false; } 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 DragonISO. 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); [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern int GetWindowTextW(IntPtr hWnd, [Out] System.Text.StringBuilder lpString, int nMaxCount); [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern int GetWindowTextLengthW(IntPtr hWnd); private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); /// /// Returns the title bar text of Teams' most-recently-used top-level /// window, or empty string if Teams isn't running. Modern Teams puts /// the meeting title in the window title while in a call ("Meeting with /// Alice | Microsoft Teams"), so this is the cheapest way to surface /// meeting context to Dragon-ISO's UI without burning a UIA traversal. /// /// Includes hidden windows — operators using auto-hide still get the /// title surfaced, which is the whole point. /// public static string GetActiveWindowTitle() { try { 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 string.Empty; string longestTitle = string.Empty; EnumWindows((hWnd, _) => { if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true; GetWindowThreadProcessId(hWnd, out var pid); if (!teamsPids.Contains(pid)) return true; var len = GetWindowTextLengthW(hWnd); if (len <= 0) return true; var sb = new System.Text.StringBuilder(len + 1); GetWindowTextW(hWnd, sb, sb.Capacity); var title = sb.ToString(); // Teams creates a few top-level windows per process; the // call/meeting window has the longest title (other windows // tend to just be "Microsoft Teams"). Pick the longest one // as a heuristic for "most informative". if (title.Length > longestTitle.Length) longestTitle = title; return true; }, IntPtr.Zero); return longestTitle; } catch { return string.Empty; } } /// /// 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. /// /// /// Return the visible top-level windows owned by any Teams process. /// Exposed internal so can pick the /// "best" candidate to reparent without re-implementing the /// enumeration. Keep this in TeamsLauncher because the launch / /// hide / show paths use the same list. /// internal static List EnumerateTopLevelTeamsWindows() { 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 = EnumerateTopLevelTeamsWindows(); foreach (var w in windows) ShowWindow(w, SW_HIDE); return windows.Count; } /// /// Fire-and-forget background watcher that polls every 250ms for up to /// 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). /// 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); /// /// 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 = EnumerateTopLevelTeamsWindows(); 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; } }