refactor(services): extract TeamsEmbedHost from TeamsLauncher

TeamsLauncher.cs was 665 lines / 30KB and mixed two unrelated lifecycles:
launch / hide / show / in-call orchestration (the bulk of the file), and
the Phase E.4 experimental SetParent-based embedding (~160 lines of
distinct Win32 surface area + its own state machine).

* Services/TeamsEmbedHost.cs (177L, new) — public static class owning
  EmbedTeamsInto / ResizeEmbedded / RestoreEmbed / IsEmbedded plus the
  Win32 p/invokes specific to embedding (SetParent, GetWindowLongPtr,
  SetWindowLongPtr, MoveWindow, SetWindowPos), the WS_* / SWP_* style
  + position constants, and the embed-state fields. The whole lifecycle
  (reparent → resize → restore) now lives in one place; the comment
  about WebView2 fragility moves with the code.
* Services/TeamsLauncher.cs (was 665L → now 510L) — keeps launch /
  stop / join / hide / show / window-title / shortcut concerns. The
  internal helper that enumerates Teams top-level windows is now
  named EnumerateTopLevelTeamsWindows (was FindTeamsTopLevelWindows)
  and marked `internal` so TeamsEmbedHost can call it without
  duplicating the EnumWindows traversal — both classes use the same
  process-name heuristic and the launcher's hide/show paths also
  consume it.
* TeamsEmbedWindow.xaml.cs — call sites moved from TeamsLauncher.* to
  TeamsEmbedHost.* (three references).

No behavior change. Build clean; 56 + 104 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-15 20:13:57 -04:00
parent 2640739bfc
commit 1f07992100
3 changed files with 191 additions and 169 deletions

View file

@ -0,0 +1,177 @@
using System.Runtime.InteropServices;
namespace TeamsISO.App.Services;
/// <summary>
/// Phase E.4 — Embedded Teams via SetParent.
///
/// Reparents Teams' main top-level window into a TeamsISO-owned host
/// (typically a Border element's HWND). Strips the captured window's
/// caption + thick frame so it integrates flush with the host, and
/// remembers enough about the original to restore it cleanly later.
///
/// 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 ensure the restore path always runs (the
/// caller wraps Embed in a finally block) so operators can fall back to
/// auto-hide mode if embedding misbehaves on their specific Teams build.
///
/// Lives in its own static class — separated from <see cref="TeamsLauncher"/>
/// because the embedding lifecycle (reparent → resize → restore) is its
/// own thing, and the Win32 surface it requires (SetParent / window-style
/// muck / SetWindowPos / MoveWindow) isn't reused by the launch / hide /
/// in-call control paths.
/// </summary>
public static class TeamsEmbedHost
{
[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)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int GetWindowTextLengthW(IntPtr hWnd);
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 = TeamsLauncher.EnumerateTopLevelTeamsWindows();
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;
}
}
}

View file

@ -271,168 +271,6 @@ public static class TeamsLauncher
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
@ -482,7 +320,14 @@ public static class TeamsLauncher
/// 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()
/// <summary>
/// Return the visible top-level windows owned by any Teams process.
/// Exposed internal so <see cref="TeamsEmbedHost"/> can pick the
/// "best" candidate to reparent without re-implementing the
/// enumeration. Keep this in TeamsLauncher because the launch /
/// hide / show paths use the same list.
/// </summary>
internal static List<IntPtr> EnumerateTopLevelTeamsWindows()
{
var teamsPids = new HashSet<uint>(
TeamsProcessNames
@ -509,7 +354,7 @@ public static class TeamsLauncher
/// </summary>
public static int HideWindows()
{
var windows = FindTeamsTopLevelWindows();
var windows = EnumerateTopLevelTeamsWindows();
foreach (var w in windows) ShowWindow(w, SW_HIDE);
return windows.Count;
}
@ -610,7 +455,7 @@ public static class TeamsLauncher
/// </summary>
public static bool SendShortcut(ShortcutModifiers modifiers, int virtualKey)
{
var windows = FindTeamsTopLevelWindows();
var windows = EnumerateTopLevelTeamsWindows();
if (windows.Count == 0) return false;
var hwnd = windows[^1];

View file

@ -16,7 +16,7 @@ namespace TeamsISO.App;
/// instead of leaving the host blank.
/// • Restore-on-close runs in a finally block so a crash mid-host
/// can't leave Teams orphaned with stripped window styles.
/// • TeamsLauncher.RestoreEmbed is idempotent — safe to call even if
/// • TeamsEmbedHost.RestoreEmbed is idempotent — safe to call even if
/// embedding never succeeded.
/// </summary>
public partial class TeamsEmbedWindow : Window
@ -43,7 +43,7 @@ public partial class TeamsEmbedWindow : Window
var w = (int)EmbedHost.ActualWidth;
var h = (int)EmbedHost.ActualHeight;
if (!TeamsLauncher.EmbedTeamsInto(src.Handle, w, h))
if (!TeamsEmbedHost.EmbedTeamsInto(src.Handle, w, h))
{
MessageBox.Show(
"Couldn't find a Microsoft Teams window to embed. " +
@ -57,14 +57,14 @@ public partial class TeamsEmbedWindow : Window
{
// Keep Teams sized to match the host as the embed window resizes.
// No-op when nothing is embedded.
TeamsLauncher.ResizeEmbedded((int)e.NewSize.Width, (int)e.NewSize.Height);
TeamsEmbedHost.ResizeEmbedded((int)e.NewSize.Width, (int)e.NewSize.Height);
}
private void OnWindowClosed(object? sender, EventArgs e)
{
// ALWAYS restore Teams to top-level state when this window closes,
// even if the embed never succeeded. Idempotent.
try { TeamsLauncher.RestoreEmbed(); }
try { TeamsEmbedHost.RestoreEmbed(); }
catch { /* defensive — restore is best-effort */ }
}