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:
parent
538dd98f54
commit
7ac56c2661
4 changed files with 1151 additions and 3 deletions
398
src/TeamsISO.App.WinUI/Services/TeamsControlBridge.cs
Normal file
398
src/TeamsISO.App.WinUI/Services/TeamsControlBridge.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
665
src/TeamsISO.App.WinUI/Services/TeamsLauncher.cs
Normal file
665
src/TeamsISO.App.WinUI/Services/TeamsLauncher.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +91,7 @@
|
|||
<!-- Launch / surface Teams -->
|
||||
<Button Style="{StaticResource ButtonRailIcon}"
|
||||
Margin="8,0"
|
||||
Click="OnLaunchTeamsClick"
|
||||
ToolTipService.ToolTip="Launch Microsoft Teams (or surface its window)">
|
||||
<FontIcon Glyph=""
|
||||
FontSize="20"
|
||||
|
|
@ -100,6 +101,7 @@
|
|||
<!-- Hide / show Teams windows -->
|
||||
<Button Style="{StaticResource ButtonRailIcon}"
|
||||
Margin="8,0"
|
||||
Click="OnToggleTeamsWindowsClick"
|
||||
ToolTipService.ToolTip="Hide / show Microsoft Teams windows">
|
||||
<FontIcon Glyph=""
|
||||
FontSize="20"
|
||||
|
|
@ -345,20 +347,23 @@
|
|||
VerticalAlignment="Center"
|
||||
Margin="0,0,8,0"/>
|
||||
<Button Style="{StaticResource ButtonDestructive}"
|
||||
ToolTipService.ToolTip="Toggle microphone mute">
|
||||
Click="OnMuteClick"
|
||||
ToolTipService.ToolTip="Toggle microphone mute in Teams">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<FontIcon Glyph="" FontSize="14"/>
|
||||
<TextBlock Text="Muted"/>
|
||||
<TextBlock Text="Mute"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource ButtonSecondary}"
|
||||
ToolTipService.ToolTip="Toggle camera">
|
||||
Click="OnCameraClick"
|
||||
ToolTipService.ToolTip="Toggle camera in Teams">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<FontIcon Glyph="" FontSize="14"/>
|
||||
<TextBlock Text="Camera"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource ButtonSecondary}"
|
||||
Click="OnShareClick"
|
||||
ToolTipService.ToolTip="Open Teams share tray">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<FontIcon Glyph="" FontSize="14"/>
|
||||
|
|
@ -374,6 +379,7 @@
|
|||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource ButtonDestructive}"
|
||||
Click="OnLeaveClick"
|
||||
ToolTipService.ToolTip="Leave the Teams call">
|
||||
<TextBlock Text="Leave"/>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -360,6 +360,85 @@ public sealed partial class MainWindow : Window
|
|||
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)
|
||||
{
|
||||
if (Content is FrameworkElement root)
|
||||
|
|
|
|||
Loading…
Reference in a new issue