diff --git a/src/TeamsISO.App.WinUI/Services/TeamsControlBridge.cs b/src/TeamsISO.App.WinUI/Services/TeamsControlBridge.cs
new file mode 100644
index 0000000..dffa072
--- /dev/null
+++ b/src/TeamsISO.App.WinUI/Services/TeamsControlBridge.cs
@@ -0,0 +1,398 @@
+using System.Diagnostics;
+using System.Windows.Automation;
+
+namespace TeamsISO.App.WinUI.Services;
+
+///
+/// Phase E.3 — UIAutomation bridge for the in-call controls (mute, camera,
+/// leave, share screen). Walks Teams' automation tree to locate the relevant
+/// buttons and invokes their or .
+///
+/// This is intentionally tolerant of Teams' UI volatility: we search by a
+/// chain of (AutomationId, Name, LocalizedControlType) candidates rather than
+/// pinning to a single identifier. When Teams ships a new build that renames a
+/// button, the operator gets a clear "control not found" toast rather than a
+/// crash, and we add the new identifier to the candidate list.
+///
+/// Limitations:
+/// - Requires Teams' main window to be present (not minimized to the system tray
+/// in a way that detaches its automation peers; minimized to taskbar is fine).
+/// - Some Teams builds host the call UI in a separate WebView2-backed top-level
+/// window; we enumerate every top-level window owned by every Teams process,
+/// so we'll find it wherever it lives.
+/// - Hidden windows (after ) are still
+/// traversable by UIAutomation — the buttons exist in the automation tree
+/// even when their HWND is SW_HIDDEN. This is what makes the "hide Teams,
+/// drive it from TeamsISO" workflow viable.
+///
+public static class TeamsControlBridge
+{
+ // ────────────────────────────────────────────────────────────────────
+ // Localized candidate-name lists.
+ //
+ // Teams localizes the AutomationElement.Name we match against. The lookup
+ // strategy is: ALL candidate strings across all locales are tried for each
+ // command, and the first match wins. This gives us a single binary that
+ // works regardless of the Teams UI language without needing to detect it
+ // — at the cost of a slightly broader match surface (a non-mute button
+ // with the German word "Stumm" in its name would false-positive). In
+ // practice Teams' button Names are highly distinctive and we haven't seen
+ // false positives during development.
+ //
+ // Adding a locale: append the localized strings to each command's array.
+ // Order doesn't matter for correctness; for performance we put the most
+ // common locales first since the array is iterated in order.
+ // ────────────────────────────────────────────────────────────────────
+
+ private static readonly string[] MuteCandidates =
+ {
+ // English (US/UK)
+ "Mute", "Unmute", "Microphone", "Toggle mute",
+ // German
+ "Stumm", "Stummschaltung", "Stummschalten", "Stummschaltung aufheben", "Mikrofon",
+ // Spanish
+ "Silenciar", "Activar audio", "Micrófono",
+ // French
+ "Désactiver le micro", "Activer le micro", "Micro", "Microphone",
+ // Portuguese
+ "Desativar áudio", "Ativar áudio", "Microfone",
+ // Japanese
+ "ミュート", "ミュート解除", "マイク",
+ };
+
+ private static readonly string[] CameraCandidates =
+ {
+ "Camera", "Turn camera on", "Turn camera off", "Video",
+ // German
+ "Kamera", "Kamera einschalten", "Kamera ausschalten", "Video",
+ // Spanish
+ "Cámara", "Activar cámara", "Desactivar cámara", "Vídeo",
+ // French
+ "Caméra", "Activer la caméra", "Désactiver la caméra", "Vidéo",
+ // Portuguese
+ "Câmera", "Ativar câmera", "Desativar câmera",
+ // Japanese
+ "カメラ", "ビデオ",
+ };
+
+ private static readonly string[] LeaveCandidates =
+ {
+ "Leave", "Hang up", "End call", "Leave call",
+ // German
+ "Verlassen", "Auflegen", "Anruf beenden",
+ // Spanish
+ "Salir", "Colgar", "Finalizar llamada",
+ // French
+ "Quitter", "Raccrocher", "Terminer l'appel",
+ // Portuguese
+ "Sair", "Desligar", "Encerrar chamada",
+ // Japanese
+ "退出", "通話を終了",
+ };
+
+ private static readonly string[] ShareCandidates =
+ {
+ "Share", "Share content", "Share screen", "Open share tray",
+ // German
+ "Teilen", "Inhalt teilen", "Bildschirm teilen",
+ // Spanish
+ "Compartir", "Compartir contenido", "Compartir pantalla",
+ // French
+ "Partager", "Partager du contenu", "Partager l'écran",
+ // Portuguese
+ "Compartilhar", "Compartilhar conteúdo", "Compartilhar tela",
+ // Japanese
+ "共有", "コンテンツの共有", "画面を共有",
+ };
+
+ private static readonly string[] RaiseHandCandidates =
+ {
+ "Raise", "Raise hand", "Lower hand",
+ // German
+ "Hand heben", "Hand senken",
+ // Spanish
+ "Levantar la mano", "Bajar la mano",
+ // French
+ "Lever la main", "Baisser la main",
+ // Portuguese
+ "Levantar a mão", "Abaixar a mão",
+ // Japanese
+ "手を挙げる", "手を下ろす",
+ };
+
+ private static readonly string[] ToggleChatCandidates =
+ {
+ "Show conversation", "Hide conversation", "Chat", "Show chat",
+ // German
+ "Unterhaltung anzeigen", "Unterhaltung ausblenden", "Chat",
+ // Spanish
+ "Mostrar conversación", "Ocultar conversación", "Chat",
+ // French
+ "Afficher la conversation", "Masquer la conversation", "Conversation",
+ // Portuguese
+ "Mostrar conversa", "Ocultar conversa", "Chat",
+ // Japanese
+ "会話を表示", "会話を非表示", "チャット",
+ };
+
+ private static readonly string[] BackgroundBlurCandidates =
+ {
+ "Background effects", "Apply background effects", "Background filters",
+ // German
+ "Hintergrundeffekte", "Hintergrundfilter",
+ // Spanish
+ "Efectos de fondo", "Filtros de fondo",
+ // French
+ "Effets d'arrière-plan", "Filtres d'arrière-plan",
+ // Portuguese
+ "Efeitos de plano de fundo", "Filtros de plano de fundo",
+ // Japanese
+ "背景効果", "背景フィルター",
+ };
+
+ /// Result of attempting one of the in-call commands.
+ public enum InvokeResult
+ {
+ /// The control was found and invoked successfully.
+ Invoked,
+ /// Teams isn't running, or its automation root couldn't be located.
+ TeamsNotRunning,
+ /// Teams is running but the matching button isn't currently exposed (maybe not in a call).
+ ControlNotFound,
+ /// The button was found but didn't expose a usable invoke / toggle pattern.
+ InvokeFailed,
+ }
+
+ public static InvokeResult ToggleMute() => InvokeFirstMatch(MuteCandidates);
+ public static InvokeResult ToggleCamera() => InvokeFirstMatch(CameraCandidates);
+ public static InvokeResult LeaveCall() => InvokeFirstMatch(LeaveCandidates);
+ public static InvokeResult OpenShareTray() => InvokeFirstMatch(ShareCandidates);
+ public static InvokeResult ToggleRaiseHand() => InvokeFirstMatch(RaiseHandCandidates);
+ public static InvokeResult ToggleChat() => InvokeFirstMatch(ToggleChatCandidates);
+ public static InvokeResult OpenBackgroundEffects() => InvokeFirstMatch(BackgroundBlurCandidates);
+
+ ///
+ /// Snapshot of the current call's local-user state. Read via a single
+ /// UIA traversal in ; null sub-fields when
+ /// the call isn't active or the button isn't in the tree.
+ ///
+ public sealed record CallStateSnapshot(bool IsInCall, bool? IsMuted, bool? IsCameraOff);
+
+ ///
+ /// One-shot UIA probe of Teams' in-call controls. The Mute and Camera
+ /// buttons toggle their Name between "Mute"/"Unmute" and "Turn camera
+ /// on"/"Turn camera off" depending on state, so reading the Name tells
+ /// us whether the operator is currently muted / camera-off.
+ ///
+ /// Returns IsInCall=false if Teams isn't running or no Leave button
+ /// exists. Returns IsMuted/IsCameraOff as null if those buttons aren't
+ /// found in this build (defensive — Teams sometimes uses different
+ /// candidate names across locales).
+ ///
+ public static CallStateSnapshot DetectCallState()
+ {
+ var roots = GetTeamsAutomationRoots();
+ if (roots.Count == 0) return new CallStateSnapshot(false, null, null);
+
+ var inCall = false;
+ bool? muted = null;
+ bool? camOff = null;
+
+ foreach (var root in roots)
+ {
+ AutomationElementCollection allButtons;
+ try
+ {
+ allButtons = root.FindAll(
+ TreeScope.Descendants,
+ new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
+ }
+ catch { continue; }
+
+ foreach (AutomationElement btn in allButtons)
+ {
+ var name = SafeGetName(btn);
+ if (string.IsNullOrEmpty(name)) continue;
+ var lower = name.ToLowerInvariant();
+
+ if (!inCall && LeaveCandidates.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
+ inCall = true;
+
+ // Mute button: name is "Mute" when active-can-mute, "Unmute"
+ // when currently muted. Detect by checking for "unmute" first
+ // (more specific) before falling to "mute" (more general).
+ if (muted is null)
+ {
+ if (lower.Contains("unmute") || lower.Contains("stummschaltung aufheben") ||
+ lower.Contains("activar audio") || lower.Contains("activer le micro") ||
+ lower.Contains("ativar áudio") || lower.Contains("ミュート解除"))
+ muted = true;
+ else if (lower.Contains("mute") || lower.Contains("stummschalten") ||
+ lower.Contains("silenciar") || lower.Contains("désactiver le micro") ||
+ lower.Contains("desativar áudio") || lower.Contains("ミュート"))
+ muted = false;
+ }
+
+ // Camera button: name is "Turn camera off" when on, "Turn
+ // camera on" when off.
+ if (camOff is null)
+ {
+ if (lower.Contains("turn camera on") || lower.Contains("kamera einschalten") ||
+ lower.Contains("activar cámara") || lower.Contains("activer la caméra") ||
+ lower.Contains("ativar câmera"))
+ camOff = true;
+ else if (lower.Contains("turn camera off") || lower.Contains("kamera ausschalten") ||
+ lower.Contains("desactivar cámara") || lower.Contains("désactiver la caméra") ||
+ lower.Contains("desativar câmera"))
+ camOff = false;
+ }
+ }
+ }
+ return new CallStateSnapshot(inCall, muted, camOff);
+ }
+
+ ///
+ /// True if Teams is currently in an active call. The Leave / Hang-up
+ /// button only exists in the automation tree when a call is in progress,
+ /// so its presence is a reliable in-call signal across Teams versions.
+ /// Returns false if Teams isn't running, isn't in a call, or the call
+ /// UI is in a state we don't recognize.
+ ///
+ /// This is the "tell me what Teams is doing without me having to look
+ /// at it" probe — operators using auto-hide Teams want a status pill
+ /// that says "In call · ready" without having to restore the Teams
+ /// window. Safe to call from any thread (UIA traversal is thread-safe);
+ /// not free (walks the descendant tree) so callers should poll at most
+ /// a few times per second.
+ ///
+ public static bool IsInCall()
+ {
+ var roots = GetTeamsAutomationRoots();
+ if (roots.Count == 0) return false;
+
+ foreach (var root in roots)
+ {
+ AutomationElementCollection allButtons;
+ try
+ {
+ allButtons = root.FindAll(
+ TreeScope.Descendants,
+ new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
+ }
+ catch
+ {
+ // Window died mid-traversal; try the next root.
+ continue;
+ }
+
+ foreach (AutomationElement btn in allButtons)
+ {
+ var name = SafeGetName(btn);
+ if (string.IsNullOrEmpty(name)) continue;
+ if (LeaveCandidates.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static InvokeResult InvokeFirstMatch(IReadOnlyList candidateNames)
+ {
+ var roots = GetTeamsAutomationRoots();
+ if (roots.Count == 0) return InvokeResult.TeamsNotRunning;
+
+ foreach (var root in roots)
+ {
+ // Search by Name first (most common case for Teams). Use a NameProperty
+ // contains-style match by collecting all Buttons in the subtree and then
+ // filtering manually — Condition only supports equality, and Teams'
+ // labels can include trailing state ("(unmuted)") that breaks equality.
+ var allButtons = root.FindAll(
+ TreeScope.Descendants,
+ new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
+
+ foreach (AutomationElement btn in allButtons)
+ {
+ var name = SafeGetName(btn);
+ if (string.IsNullOrEmpty(name)) continue;
+ if (!candidateNames.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
+ continue;
+
+ if (TryInvoke(btn)) return InvokeResult.Invoked;
+ }
+ }
+ return InvokeResult.ControlNotFound;
+ }
+
+ ///
+ /// Returns the AutomationElement root for every top-level window owned by
+ /// any running Teams process. Multiple roots is the normal case for new
+ /// MSTeams (which uses one window per call/chat).
+ ///
+ private static List GetTeamsAutomationRoots()
+ {
+ var teamsPids = new HashSet(
+ Process.GetProcessesByName("ms-teams")
+ .Concat(Process.GetProcessesByName("msteams"))
+ .Concat(Process.GetProcessesByName("Teams"))
+ .Select(p => { try { return p.Id; } finally { p.Dispose(); } }));
+
+ if (teamsPids.Count == 0) return new List();
+
+ // Filter the desktop's children to windows whose ProcessId matches.
+ var desktop = AutomationElement.RootElement;
+ var allWindows = desktop.FindAll(
+ TreeScope.Children,
+ new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Window));
+
+ var roots = new List();
+ foreach (AutomationElement w in allWindows)
+ {
+ try
+ {
+ var pid = (int)w.Current.ProcessId;
+ if (teamsPids.Contains(pid)) roots.Add(w);
+ }
+ catch
+ {
+ // Window died between enumeration and property read; skip.
+ }
+ }
+ return roots;
+ }
+
+ private static string SafeGetName(AutomationElement el)
+ {
+ try { return el.Current.Name ?? string.Empty; }
+ catch { return string.Empty; }
+ }
+
+ ///
+ /// Try Invoke first (most buttons), then Toggle (mute/camera are usually
+ /// toggle-pattern). Returns true if either succeeded.
+ ///
+ private static bool TryInvoke(AutomationElement el)
+ {
+ try
+ {
+ if (el.TryGetCurrentPattern(InvokePattern.Pattern, out var invoke))
+ {
+ ((InvokePattern)invoke).Invoke();
+ return true;
+ }
+ if (el.TryGetCurrentPattern(TogglePattern.Pattern, out var toggle))
+ {
+ ((TogglePattern)toggle).Toggle();
+ return true;
+ }
+ }
+ catch
+ {
+ // ElementNotEnabledException, ElementNotAvailableException — Teams
+ // disabled the button mid-traversal (e.g. mute is disabled before
+ // joining a call). Treat as "found but couldn't invoke" so the
+ // caller can surface a useful message.
+ }
+ return false;
+ }
+}
diff --git a/src/TeamsISO.App.WinUI/Services/TeamsLauncher.cs b/src/TeamsISO.App.WinUI/Services/TeamsLauncher.cs
new file mode 100644
index 0000000..9bc7142
--- /dev/null
+++ b/src/TeamsISO.App.WinUI/Services/TeamsLauncher.cs
@@ -0,0 +1,665 @@
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+
+namespace TeamsISO.App.WinUI.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; 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 TeamsISO'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 TeamsISO.
+ ///
+ /// 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 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);
+
+ [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);
+
+ // ────────────────────────────────────────────────────────────────────
+ // Phase E.4 — Embedded Teams via SetParent.
+ //
+ // Reparents Teams' main top-level window into a TeamsISO-owned host
+ // (typically a Border element's HWND). The Win32 behavior is well
+ // understood for classic Win32 apps but modern Teams runs WebView2 in
+ // its main window; WebView2's renderer is sensitive to parent changes
+ // and may flash white frames during reparent, drop input focus, or
+ // refuse to redraw until forced.
+ //
+ // We mark the feature experimental and provide a clean restore path
+ // (SetParent back to desktop + restore the original window styles)
+ // so operators can fall back to auto-hide mode if embedding misbehaves
+ // on their specific Teams build.
+ // ────────────────────────────────────────────────────────────────────
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern int GetWindowLongPtr(IntPtr hWnd, int nIndex);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern int SetWindowLongPtr(IntPtr hWnd, int nIndex, int dwNewLong);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, [MarshalAs(UnmanagedType.Bool)] bool bRepaint);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern IntPtr GetDesktopWindow();
+
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
+
+ private const int GWL_STYLE = -16;
+ private const long WS_CHILD = 0x40000000;
+ private const long WS_POPUP = unchecked((long)0x80000000);
+ private const long WS_CAPTION = 0x00C00000;
+ private const long WS_THICKFRAME = 0x00040000;
+ private const long WS_BORDER = 0x00800000;
+ private const long WS_DLGFRAME = 0x00400000;
+ private const uint SWP_FRAMECHANGED = 0x0020;
+ private const uint SWP_NOMOVE = 0x0002;
+ private const uint SWP_NOSIZE = 0x0001;
+ private const uint SWP_NOZORDER = 0x0004;
+ private const uint SWP_NOACTIVATE = 0x0010;
+
+ ///
+ /// Captures the original parent + window style so embedding can be
+ /// reversed cleanly. Tracked per-HWND so multiple consecutive
+ /// embed/unembed cycles don't lose the original chrome.
+ ///
+ private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState;
+ private static IntPtr _embeddedHwnd = IntPtr.Zero;
+
+ /// True when a Teams window is currently parented inside a TeamsISO host.
+ public static bool IsEmbedded => _embeddedHwnd != IntPtr.Zero;
+
+ ///
+ /// Reparents Teams' most-recently-used top-level window into
+ /// . Strips Teams' caption + thick frame so
+ /// it integrates flush with the host. Returns true on success, false
+ /// if no Teams window could be found.
+ ///
+ /// The host HWND is typically obtained via:
+ /// var src = (System.Windows.Interop.HwndSource)
+ /// PresentationSource.FromVisual(MyHostBorder);
+ /// src.Handle // → IntPtr suitable for hostHwnd
+ ///
+ public static bool EmbedTeamsInto(IntPtr hostHwnd, int width, int height)
+ {
+ if (hostHwnd == IntPtr.Zero) return false;
+ var teamsWindows = FindTeamsTopLevelWindows();
+ if (teamsWindows.Count == 0) return false;
+
+ // Pick the longest-title window as the "main" one — same heuristic
+ // GetActiveWindowTitle uses; matches the call/meeting window.
+ IntPtr best = IntPtr.Zero;
+ int bestLen = -1;
+ foreach (var w in teamsWindows)
+ {
+ var len = GetWindowTextLengthW(w);
+ if (len > bestLen) { bestLen = len; best = w; }
+ }
+ if (best == IntPtr.Zero) return false;
+
+ // Already embedded? Unembed first to clean state.
+ if (_embeddedHwnd != IntPtr.Zero) RestoreEmbed();
+
+ // Save original style + parent so we can fully reverse later.
+ var originalStyle = GetWindowLongPtr(best, GWL_STYLE);
+ var originalParent = SetParent(best, hostHwnd); // returns old parent
+
+ _embedSavedState = (originalParent, originalStyle);
+ _embeddedHwnd = best;
+
+ // Strip top-level decorations + add WS_CHILD so the OS treats it
+ // as a child window of the host.
+ var newStyle = originalStyle;
+ unchecked
+ {
+ newStyle &= ~(int)WS_CAPTION;
+ newStyle &= ~(int)WS_THICKFRAME;
+ newStyle &= ~(int)WS_BORDER;
+ newStyle &= ~(int)WS_DLGFRAME;
+ newStyle &= ~(int)WS_POPUP;
+ newStyle |= (int)WS_CHILD;
+ }
+ SetWindowLongPtr(best, GWL_STYLE, newStyle);
+
+ // Force a non-client recalculation so the style change takes effect.
+ SetWindowPos(best, IntPtr.Zero, 0, 0, 0, 0,
+ SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
+
+ // Place at top-left of host, full host size.
+ MoveWindow(best, 0, 0, width, height, true);
+ return true;
+ }
+
+ ///
+ /// Resize the currently-embedded Teams window to
+ /// × . Called when the host element resizes
+ /// (window resize, layout change, etc.). No-op if nothing is embedded.
+ ///
+ public static void ResizeEmbedded(int width, int height)
+ {
+ if (_embeddedHwnd == IntPtr.Zero || width <= 0 || height <= 0) return;
+ MoveWindow(_embeddedHwnd, 0, 0, width, height, true);
+ }
+
+ ///
+ /// Reverse an active embed: SetParent back to desktop + restore the
+ /// original window style so Teams looks/behaves like a normal top-level
+ /// window again. Safe to call when nothing is embedded — no-op.
+ ///
+ public static void RestoreEmbed()
+ {
+ if (_embeddedHwnd == IntPtr.Zero || _embedSavedState is null) return;
+ var (origParent, origStyle) = _embedSavedState.Value;
+ try
+ {
+ // Restore original style FIRST so when we reparent the window's
+ // top-level decorations come back correctly.
+ SetWindowLongPtr(_embeddedHwnd, GWL_STYLE, origStyle);
+ // SetParent(hwnd, Zero) returns to desktop. We could pass
+ // origParent verbatim but for Teams that's always the desktop
+ // anyway, and IntPtr.Zero is documented as "reparent to desktop".
+ SetParent(_embeddedHwnd, IntPtr.Zero);
+ SetWindowPos(_embeddedHwnd, IntPtr.Zero, 0, 0, 0, 0,
+ SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
+ }
+ catch { /* defensive — restore must never throw */ }
+ finally
+ {
+ _embedSavedState = null;
+ _embeddedHwnd = IntPtr.Zero;
+ }
+ }
+
+ ///
+ /// 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 TeamsISO'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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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 = 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;
+ }
+}
diff --git a/src/TeamsISO.App.WinUI/Views/MainWindow.xaml b/src/TeamsISO.App.WinUI/Views/MainWindow.xaml
index b5b22c1..30a3dee 100644
--- a/src/TeamsISO.App.WinUI/Views/MainWindow.xaml
+++ b/src/TeamsISO.App.WinUI/Views/MainWindow.xaml
@@ -91,6 +91,7 @@