feat(winui3): wire Teams orchestration into the in-call bar + rail buttons

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).
This commit is contained in:
Zac Gaetano 2026-05-13 21:31:04 -04:00
parent 538dd98f54
commit 7ac56c2661
4 changed files with 1151 additions and 3 deletions

View file

@ -0,0 +1,398 @@
using System.Diagnostics;
using System.Windows.Automation;
namespace TeamsISO.App.WinUI.Services;
/// <summary>
/// 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 <see cref="InvokePattern"/> or <see cref="TogglePattern"/>.
///
/// 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 <see cref="TeamsLauncher.HideWindows"/>) 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.
/// </summary>
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
"背景効果", "背景フィルター",
};
/// <summary>Result of attempting one of the in-call commands.</summary>
public enum InvokeResult
{
/// <summary>The control was found and invoked successfully.</summary>
Invoked,
/// <summary>Teams isn't running, or its automation root couldn't be located.</summary>
TeamsNotRunning,
/// <summary>Teams is running but the matching button isn't currently exposed (maybe not in a call).</summary>
ControlNotFound,
/// <summary>The button was found but didn't expose a usable invoke / toggle pattern.</summary>
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);
/// <summary>
/// Snapshot of the current call's local-user state. Read via a single
/// UIA traversal in <see cref="DetectCallState"/>; null sub-fields when
/// the call isn't active or the button isn't in the tree.
/// </summary>
public sealed record CallStateSnapshot(bool IsInCall, bool? IsMuted, bool? IsCameraOff);
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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<string> 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;
}
/// <summary>
/// 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).
/// </summary>
private static List<AutomationElement> GetTeamsAutomationRoots()
{
var teamsPids = new HashSet<int>(
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<AutomationElement>();
// 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<AutomationElement>();
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; }
}
/// <summary>
/// Try Invoke first (most buttons), then Toggle (mute/camera are usually
/// toggle-pattern). Returns true if either succeeded.
/// </summary>
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;
}
}

View file

@ -0,0 +1,665 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
namespace TeamsISO.App.WinUI.Services;
/// <summary>
/// Small Win32 wrapper that launches the Microsoft Teams desktop client as a
/// subprocess of TeamsISO. First step toward Phase E.1 of the embedded-Teams
/// roadmap (see docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md):
/// the operator can launch Teams from within TeamsISO so they don't have to
/// switch apps to start a meeting.
///
/// The launcher tries (in order):
/// 1. ms-teams: URI (works for both classic and new Teams)
/// 2. MSTeams.exe in %LOCALAPPDATA%\Microsoft\WindowsApps\
/// 3. Teams.exe in %LOCALAPPDATA%\Microsoft\Teams\Update.exe (legacy)
///
/// Group-routing automation (writing NDI Access Manager config so Teams
/// broadcasts on a private group) is deferred to a follow-up — for v1.0 we
/// document the manual steps in RELEASING.md and trust the operator to set
/// them once per machine.
/// </summary>
public static class TeamsLauncher
{
/// <summary>
/// Heuristic process-name candidates we'll consider as "the Teams process" when
/// the rail toggle wants to find a running instance. New MSTeams comes first.
/// </summary>
private static readonly string[] TeamsProcessNames =
{
"ms-teams", // new MSTeams binary basename
"msteams", // alternate basename observed on some installs
"Teams", // classic Teams desktop client
};
/// <summary>
/// True if any process matching the known Teams binary basenames is running.
/// Used by the rail to decide whether to show "Launch Teams" vs "Stop Teams".
/// </summary>
public static bool IsRunning() =>
TeamsProcessNames.Any(n => Process.GetProcessesByName(n).Length > 0);
/// <summary>
/// Launches Teams. Returns true if a launch was started successfully (the
/// process may take a few seconds to actually appear). False if every
/// fallback path failed; <paramref name="errorMessage"/> includes the
/// reasons each attempt was rejected so the operator can see why.
///
/// Path order matters:
/// 1. <c>ms-teams:</c> URI — new Teams (MSTeams AppX) registers this
/// handler at install. Activates through the AppX shell so the
/// stub <c>ms-teams.exe</c> in WindowsApps gets the right context.
/// 2. AppsFolder shell verb — direct AppX activation. Belt-and-braces
/// fallback if a misconfigured registry breaks the URI handler.
/// 3. Classic Teams Update.exe — pre-2024 Teams installations.
/// We deliberately DON'T try the bare <c>ms-teams.exe</c> WindowsApps
/// path: it's a 0-byte AppX placeholder that fails silently when invoked
/// without AppX activation context. Looked plausible, never worked.
/// </summary>
public static bool TryLaunch(out string? errorMessage)
{
errorMessage = null;
var attempts = new List<string>();
// Path 1: URI scheme. The shell handler picks the registered Teams
// (new MSTeams takes priority on modern Windows). UseShellExecute=true
// is required — Win32 Process creation can't open URIs directly.
if (TryStart("ms-teams:", useShell: true, out var err1)) return true;
attempts.Add($"ms-teams: URI → {err1}");
// Path 2: AppX activation via the explorer.exe shell. Modern Teams
// ships as MSTeams_8wekyb3d8bbwe; if other code on the box has
// clobbered the URI registration, this still works because it goes
// through the AppsFolder verb the OS itself uses for Start menu launches.
if (TryStart("explorer.exe", false, out var err2,
arguments: "shell:appsFolder\\MSTeams_8wekyb3d8bbwe!MSTeams"))
return true;
attempts.Add($"AppsFolder shell → {err2}");
// Path 3: classic Teams Update.exe with --processStart hands off to
// the actual Teams.exe via Squirrel.
var classicUpdater = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft", "Teams", "Update.exe");
if (File.Exists(classicUpdater))
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = classicUpdater,
Arguments = "--processStart \"Teams.exe\"",
UseShellExecute = false,
CreateNoWindow = true,
});
return true;
}
catch (Exception ex)
{
attempts.Add($"classic Update.exe → {ex.Message}");
}
}
else
{
attempts.Add($"classic Update.exe → not found at {classicUpdater}");
}
errorMessage = "No Microsoft Teams installation could be launched. " +
"Install Teams from https://www.microsoft.com/microsoft-teams and try again.\n\n" +
"Attempts:\n • " + string.Join("\n • ", attempts);
return false;
}
/// <summary>
/// Asks every running Teams process to close gracefully via WM_CLOSE
/// (CloseMainWindow). Returns the count of processes that exited cleanly within
/// <paramref name="gracePeriod"/>. Stragglers are NOT force-killed — Teams' own
/// "are you sure" prompt may legitimately keep a process alive briefly, and we
/// don't want to nuke the user's call mid-transition.
/// </summary>
public static int StopAll(TimeSpan? gracePeriod = null)
{
var grace = gracePeriod ?? TimeSpan.FromSeconds(3);
var deadline = DateTime.UtcNow + grace;
var asked = 0;
foreach (var name in TeamsProcessNames)
{
foreach (var p in Process.GetProcessesByName(name))
{
try
{
if (p.HasExited) { p.Dispose(); continue; }
if (p.MainWindowHandle != IntPtr.Zero)
{
p.CloseMainWindow();
asked++;
}
}
catch { /* defensive: process may have died between enumeration and signal */ }
finally { p.Dispose(); }
}
}
// Best-effort wait so the rail can flip its icon promptly.
while (DateTime.UtcNow < deadline && IsRunning())
Thread.Sleep(150);
return asked;
}
/// <summary>
/// Hand a meeting URL off to the Teams shell handler. Accepts both the
/// <c>https://teams.microsoft.com/l/meetup-join/...</c> web format and
/// the <c>msteams:/l/meetup-join/...</c> 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.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState;
private static IntPtr _embeddedHwnd = IntPtr.Zero;
/// <summary>True when a Teams window is currently parented inside a TeamsISO host.</summary>
public static bool IsEmbedded => _embeddedHwnd != IntPtr.Zero;
/// <summary>
/// Reparents Teams' most-recently-used top-level window into
/// <paramref name="hostHwnd"/>. 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
/// </summary>
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;
}
/// <summary>
/// Resize the currently-embedded Teams window to <paramref name="width"/>
/// × <paramref name="height"/>. Called when the host element resizes
/// (window resize, layout change, etc.). No-op if nothing is embedded.
/// </summary>
public static void ResizeEmbedded(int width, int height)
{
if (_embeddedHwnd == IntPtr.Zero || width <= 0 || height <= 0) return;
MoveWindow(_embeddedHwnd, 0, 0, width, height, true);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
public static string GetActiveWindowTitle()
{
try
{
var teamsPids = new HashSet<uint>(
TeamsProcessNames
.SelectMany(n => Process.GetProcessesByName(n))
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
if (teamsPids.Count == 0) return 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; }
}
/// <summary>
/// Enumerate every visible top-level window owned by any running Teams
/// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is
/// not a tooltip or popup of another). Used by Hide/Show.
/// </summary>
private static List<IntPtr> FindTeamsTopLevelWindows()
{
var teamsPids = new HashSet<uint>(
TeamsProcessNames
.SelectMany(n => Process.GetProcessesByName(n))
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
if (teamsPids.Count == 0) return new List<IntPtr>();
var windows = new List<IntPtr>();
EnumWindows((hWnd, _) =>
{
if (!IsWindowVisible(hWnd)) return true;
if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true; // not top-level
GetWindowThreadProcessId(hWnd, out var pid);
if (teamsPids.Contains(pid)) windows.Add(hWnd);
return true;
}, IntPtr.Zero);
return windows;
}
/// <summary>
/// Hides every visible top-level Teams window. Returns the count hidden;
/// 0 means Teams isn't running or has no visible windows yet (it can take
/// a couple seconds after launch for the splash to materialize).
/// </summary>
public static int HideWindows()
{
var windows = FindTeamsTopLevelWindows();
foreach (var w in windows) ShowWindow(w, SW_HIDE);
return windows.Count;
}
/// <summary>
/// Fire-and-forget background watcher that polls every 250ms for up to
/// <paramref name="timeout"/> and hides any visible top-level Teams
/// windows it finds. Used after launch so the operator never sees the
/// Teams UI flash on screen — Teams takes 2-5s to splash + render its
/// main window, and the splash arrives separately from the main window
/// (so we keep polling past the first hide to catch follow-up windows).
///
/// Returns the Task so callers can await completion if they want, but
/// production code should fire-and-forget. Exceptions are swallowed —
/// failure to hide is harmless (user just sees Teams briefly).
/// </summary>
public static Task AutoHideAfterLaunchAsync(TimeSpan? timeout = null, CancellationToken ct = default)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(15));
return Task.Run(async () =>
{
try
{
var hiddenAny = false;
while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline)
{
// Poll for visible windows. Each iteration may catch new
// ones — Teams sometimes opens a small splash, then a
// larger main window 1-2s later, then a "What's new"
// banner. Keep hiding until we've gone a full second
// with nothing new appearing.
var hidden = HideWindows();
if (hidden > 0)
{
hiddenAny = true;
// Settling delay: after we hide windows, wait a beat
// before polling again so we don't busy-loop while
// Teams' window manager catches up.
await Task.Delay(750, ct).ConfigureAwait(false);
}
else if (hiddenAny)
{
// We hid at least once; if the next poll finds
// nothing, Teams has settled. Bail early.
return;
}
else
{
// Teams hasn't materialized yet; keep waiting.
await Task.Delay(250, ct).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException) { /* expected on cancel */ }
catch { /* defensive — auto-hide is best-effort, never breaks the app */ }
}, ct);
}
// ────────────────────────────────────────────────────────────────────
// Keyboard-shortcut forwarding (PostMessage path).
//
// UIAutomation (TeamsControlBridge) is our preferred way to drive Teams
// because it works regardless of foreground/visibility state. PostMessage
// is a fallback for shortcuts that don't have a stable UIA-discoverable
// button — chat scroll, custom keymap actions, etc. Note: WebView2-hosted
// Teams (the modern client) frequently ignores PostMessage(WM_KEYDOWN) at
// its app-shortcut layer because shortcut routing happens after focus
// changes, not on raw key messages. Treat this as best-effort.
// ────────────────────────────────────────────────────────────────────
private const uint WM_KEYDOWN = 0x0100;
private const uint WM_KEYUP = 0x0101;
private const uint WM_CHAR = 0x0102;
private const uint WM_SYSKEYDOWN = 0x0104;
private const uint WM_SYSKEYUP = 0x0105;
[Flags]
public enum ShortcutModifiers
{
None = 0,
Ctrl = 1,
Shift = 2,
Alt = 4,
}
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
/// <summary>
/// Sends a synthesized key press (modifier-down, key-down, key-up,
/// modifier-up) to the most recently used top-level Teams window via
/// PostMessage. Returns true if a window was found to send to. Note that
/// returning true doesn't guarantee Teams reacted — modern WebView2-based
/// Teams sometimes ignores synthesized key messages at the app-shortcut
/// layer. Prefer UIA (<see cref="TeamsControlBridge"/>) when an equivalent
/// button exists.
/// </summary>
public static bool SendShortcut(ShortcutModifiers modifiers, int virtualKey)
{
var windows = FindTeamsTopLevelWindows();
if (windows.Count == 0) return false;
var hwnd = windows[^1];
// Modifier key downs
if ((modifiers & ShortcutModifiers.Ctrl) != 0)
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)0x11, IntPtr.Zero); // VK_CONTROL
if ((modifiers & ShortcutModifiers.Shift) != 0)
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)0x10, IntPtr.Zero); // VK_SHIFT
if ((modifiers & ShortcutModifiers.Alt) != 0)
PostMessage(hwnd, WM_SYSKEYDOWN, (IntPtr)0x12, IntPtr.Zero); // VK_MENU
// Main key down + up
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)virtualKey, IntPtr.Zero);
PostMessage(hwnd, WM_KEYUP, (IntPtr)virtualKey, IntPtr.Zero);
// Modifier key ups (reverse order)
if ((modifiers & ShortcutModifiers.Alt) != 0)
PostMessage(hwnd, WM_SYSKEYUP, (IntPtr)0x12, IntPtr.Zero);
if ((modifiers & ShortcutModifiers.Shift) != 0)
PostMessage(hwnd, WM_KEYUP, (IntPtr)0x10, IntPtr.Zero);
if ((modifiers & ShortcutModifiers.Ctrl) != 0)
PostMessage(hwnd, WM_KEYUP, (IntPtr)0x11, IntPtr.Zero);
return true;
}
/// <summary>
/// Restores every Teams top-level window from hidden state and brings the
/// most recently used one to the foreground. Returns the count shown.
/// </summary>
public static int ShowWindows()
{
// To find hidden windows too we still enumerate, but our IsWindowVisible
// filter would skip them. Re-implement here with the visible check off.
var teamsPids = new HashSet<uint>(
TeamsProcessNames
.SelectMany(n => Process.GetProcessesByName(n))
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
var windows = new List<IntPtr>();
EnumWindows((hWnd, _) =>
{
if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true;
GetWindowThreadProcessId(hWnd, out var pid);
if (teamsPids.Contains(pid)) windows.Add(hWnd);
return true;
}, IntPtr.Zero);
foreach (var w in windows) ShowWindow(w, SW_SHOW);
if (windows.Count > 0) SetForegroundWindow(windows[^1]);
return windows.Count;
}
}

View file

@ -91,6 +91,7 @@
<!-- Launch / surface Teams --> <!-- Launch / surface Teams -->
<Button Style="{StaticResource ButtonRailIcon}" <Button Style="{StaticResource ButtonRailIcon}"
Margin="8,0" Margin="8,0"
Click="OnLaunchTeamsClick"
ToolTipService.ToolTip="Launch Microsoft Teams (or surface its window)"> ToolTipService.ToolTip="Launch Microsoft Teams (or surface its window)">
<FontIcon Glyph="&#xE714;" <FontIcon Glyph="&#xE714;"
FontSize="20" FontSize="20"
@ -100,6 +101,7 @@
<!-- Hide / show Teams windows --> <!-- Hide / show Teams windows -->
<Button Style="{StaticResource ButtonRailIcon}" <Button Style="{StaticResource ButtonRailIcon}"
Margin="8,0" Margin="8,0"
Click="OnToggleTeamsWindowsClick"
ToolTipService.ToolTip="Hide / show Microsoft Teams windows"> ToolTipService.ToolTip="Hide / show Microsoft Teams windows">
<FontIcon Glyph="&#xE7B3;" <FontIcon Glyph="&#xE7B3;"
FontSize="20" FontSize="20"
@ -345,20 +347,23 @@
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="0,0,8,0"/> Margin="0,0,8,0"/>
<Button Style="{StaticResource ButtonDestructive}" <Button Style="{StaticResource ButtonDestructive}"
ToolTipService.ToolTip="Toggle microphone mute"> Click="OnMuteClick"
ToolTipService.ToolTip="Toggle microphone mute in Teams">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon Glyph="&#xE74F;" FontSize="14"/> <FontIcon Glyph="&#xE74F;" FontSize="14"/>
<TextBlock Text="Muted"/> <TextBlock Text="Mute"/>
</StackPanel> </StackPanel>
</Button> </Button>
<Button Style="{StaticResource ButtonSecondary}" <Button Style="{StaticResource ButtonSecondary}"
ToolTipService.ToolTip="Toggle camera"> Click="OnCameraClick"
ToolTipService.ToolTip="Toggle camera in Teams">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon Glyph="&#xE714;" FontSize="14"/> <FontIcon Glyph="&#xE714;" FontSize="14"/>
<TextBlock Text="Camera"/> <TextBlock Text="Camera"/>
</StackPanel> </StackPanel>
</Button> </Button>
<Button Style="{StaticResource ButtonSecondary}" <Button Style="{StaticResource ButtonSecondary}"
Click="OnShareClick"
ToolTipService.ToolTip="Open Teams share tray"> ToolTipService.ToolTip="Open Teams share tray">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon Glyph="&#xE72D;" FontSize="14"/> <FontIcon Glyph="&#xE72D;" FontSize="14"/>
@ -374,6 +379,7 @@
</StackPanel> </StackPanel>
</Button> </Button>
<Button Style="{StaticResource ButtonDestructive}" <Button Style="{StaticResource ButtonDestructive}"
Click="OnLeaveClick"
ToolTipService.ToolTip="Leave the Teams call"> ToolTipService.ToolTip="Leave the Teams call">
<TextBlock Text="Leave"/> <TextBlock Text="Leave"/>
</Button> </Button>

View file

@ -360,6 +360,85 @@ public sealed partial class MainWindow : Window
SettingsDrawerHost.Visibility = Visibility.Collapsed; SettingsDrawerHost.Visibility = Visibility.Collapsed;
} }
/// <summary>
/// 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.
/// </summary>
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",
};
/// <summary>Launch Teams if not running, else show its windows.</summary>
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}";
}
}
}
/// <summary>Toggle Teams window visibility — invisible/visible flip.</summary>
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) private void ApplyResolvedTheme(ElementTheme theme)
{ {
if (Content is FrameworkElement root) if (Content is FrameworkElement root)