From 7ac56c266169e31103eca4c34a331a532b34b3c8 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 13 May 2026 21:31:04 -0400 Subject: [PATCH] feat(winui3): wire Teams orchestration into the in-call bar + rail buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-call control bar now drives the live Teams app via UIAutomation: * Mute button → TeamsControlBridge.ToggleMute() * Camera button → TeamsControlBridge.ToggleCamera() * Share button → TeamsControlBridge.OpenShareTray() * Leave button → TeamsControlBridge.LeaveCall() Each button reports the result through the status bar (Invoked / Teams-not-running / Control-not-visible / Invoke-failed). Rail buttons also wired: * Launch / surface Teams → TeamsLauncher.IsRunning()/TryLaunch()/ShowWindows() * Hide / show Teams windows → TeamsLauncher.HideWindows()/ShowWindows() with a _teamsHidden flag tracking the toggle state The Marker button was already command-bound to MainViewModel.DropRecording MarkerCommand (which fans out to IIsoController.AddRecordingMarker), so the only thing that wasn't covered before is the Teams-side stuff. Implementation notes: * Services/TeamsControlBridge.cs and Services/TeamsLauncher.cs are copied verbatim from src/TeamsISO.App/Services/ with only the namespace adjusted (TeamsISO.App.Services → TeamsISO.App.WinUI. Services). Neither file has WPF-specific dependencies — they use System.Windows.Automation (UIAutomationClient) which works identically across WPF and WinUI 3 builds. Duplication is acceptable migration debt; the long-term plan is to lift these into a shared TeamsISO.App.Shared library once both hosts stabilize. * DescribeBridgeResult maps the InvokeResult enum to operator-tone status text so a failing mute reads "Mute failed — control not visible (not in a call?)" instead of an opaque "ControlNotFound". The in-call bar now does what the WPF host's in-call bar does, minus the MUTED / CAM OFF state pills (those would need a 1Hz UIA poll of the Teams call state — wire-up to come). --- .../Services/TeamsControlBridge.cs | 398 +++++++++++ .../Services/TeamsLauncher.cs | 665 ++++++++++++++++++ src/TeamsISO.App.WinUI/Views/MainWindow.xaml | 12 +- .../Views/MainWindow.xaml.cs | 79 +++ 4 files changed, 1151 insertions(+), 3 deletions(-) create mode 100644 src/TeamsISO.App.WinUI/Services/TeamsControlBridge.cs create mode 100644 src/TeamsISO.App.WinUI/Services/TeamsLauncher.cs 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 @@ diff --git a/src/TeamsISO.App.WinUI/Views/MainWindow.xaml.cs b/src/TeamsISO.App.WinUI/Views/MainWindow.xaml.cs index b191d3c..d30c5a3 100644 --- a/src/TeamsISO.App.WinUI/Views/MainWindow.xaml.cs +++ b/src/TeamsISO.App.WinUI/Views/MainWindow.xaml.cs @@ -360,6 +360,85 @@ public sealed partial class MainWindow : Window SettingsDrawerHost.Visibility = Visibility.Collapsed; } + /// + /// Teams orchestration — mute. Drives the Teams app's in-call mute button + /// via UIAutomation (TeamsControlBridge does the localized-name search). + /// Surface failures via the status bar so the operator gets feedback + /// without a popup. + /// + private void OnMuteClick(object sender, RoutedEventArgs e) + { + var result = Services.TeamsControlBridge.ToggleMute(); + StatusBarText.Text = DescribeBridgeResult("Mute", result); + } + + private void OnCameraClick(object sender, RoutedEventArgs e) + { + var result = Services.TeamsControlBridge.ToggleCamera(); + StatusBarText.Text = DescribeBridgeResult("Camera", result); + } + + private void OnShareClick(object sender, RoutedEventArgs e) + { + var result = Services.TeamsControlBridge.OpenShareTray(); + StatusBarText.Text = DescribeBridgeResult("Share", result); + } + + private void OnLeaveClick(object sender, RoutedEventArgs e) + { + var result = Services.TeamsControlBridge.LeaveCall(); + StatusBarText.Text = DescribeBridgeResult("Leave", result); + } + + private static string DescribeBridgeResult(string action, Services.TeamsControlBridge.InvokeResult r) => + r switch + { + Services.TeamsControlBridge.InvokeResult.Invoked => $"{action} invoked", + Services.TeamsControlBridge.InvokeResult.TeamsNotRunning => $"{action} failed — Teams isn't running", + Services.TeamsControlBridge.InvokeResult.ControlNotFound => $"{action} failed — control not visible (not in a call?)", + Services.TeamsControlBridge.InvokeResult.InvokeFailed => $"{action} failed — Teams refused the invoke", + _ => $"{action} failed", + }; + + /// Launch Teams if not running, else show its windows. + private void OnLaunchTeamsClick(object sender, RoutedEventArgs e) + { + if (Services.TeamsLauncher.IsRunning()) + { + var shown = Services.TeamsLauncher.ShowWindows(); + StatusBarText.Text = $"Showed {shown} Teams window{(shown == 1 ? "" : "s")}"; + } + else + { + if (Services.TeamsLauncher.TryLaunch(out var err)) + { + StatusBarText.Text = "Teams launched"; + } + else + { + StatusBarText.Text = $"Teams launch failed: {err}"; + } + } + } + + /// Toggle Teams window visibility — invisible/visible flip. + private bool _teamsHidden; + private void OnToggleTeamsWindowsClick(object sender, RoutedEventArgs e) + { + if (_teamsHidden) + { + var n = Services.TeamsLauncher.ShowWindows(); + _teamsHidden = false; + StatusBarText.Text = $"Showed {n} Teams window{(n == 1 ? "" : "s")}"; + } + else + { + var n = Services.TeamsLauncher.HideWindows(); + _teamsHidden = true; + StatusBarText.Text = $"Hid {n} Teams window{(n == 1 ? "" : "s")}"; + } + } + private void ApplyResolvedTheme(ElementTheme theme) { if (Content is FrameworkElement root)