diff --git a/src/TeamsISO.App/Services/TeamsEmbedHost.cs b/src/TeamsISO.App/Services/TeamsEmbedHost.cs
new file mode 100644
index 0000000..b2af0bd
--- /dev/null
+++ b/src/TeamsISO.App/Services/TeamsEmbedHost.cs
@@ -0,0 +1,177 @@
+using System.Runtime.InteropServices;
+
+namespace TeamsISO.App.Services;
+
+///
+/// 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
+/// 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.
+///
+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;
+
+ ///
+ /// Captures the original parent + window style so embedding can be
+ /// reversed cleanly. Tracked per-HWND so multiple consecutive
+ /// embed / unembed cycles don't lose the original chrome.
+ ///
+ private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState;
+ private static IntPtr _embeddedHwnd = IntPtr.Zero;
+
+ /// True when a Teams window is currently parented inside a TeamsISO host.
+ public static bool IsEmbedded => _embeddedHwnd != IntPtr.Zero;
+
+ ///
+ /// Reparents Teams' most-recently-used top-level window into
+ /// . Strips Teams' caption + thick frame
+ /// so it integrates flush with the host. Returns true on success,
+ /// false if no Teams window could be found.
+ ///
+ /// The host HWND is typically obtained via:
+ /// var src = (System.Windows.Interop.HwndSource)
+ /// PresentationSource.FromVisual(MyHostBorder);
+ /// src.Handle // → IntPtr suitable for hostHwnd
+ ///
+ public static bool EmbedTeamsInto(IntPtr hostHwnd, int width, int height)
+ {
+ if (hostHwnd == IntPtr.Zero) return false;
+ var teamsWindows = 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;
+ }
+
+ ///
+ /// Resize the currently-embedded Teams window to
+ /// × . Called when the host element resizes
+ /// (window resize, layout change, etc.). No-op if nothing is embedded.
+ ///
+ public static void ResizeEmbedded(int width, int height)
+ {
+ if (_embeddedHwnd == IntPtr.Zero || width <= 0 || height <= 0) return;
+ MoveWindow(_embeddedHwnd, 0, 0, width, height, true);
+ }
+
+ ///
+ /// Reverse an active embed: SetParent back to desktop + restore the
+ /// original window style so Teams looks/behaves like a normal
+ /// top-level window again. Safe to call when nothing is embedded —
+ /// no-op.
+ ///
+ public static void RestoreEmbed()
+ {
+ if (_embeddedHwnd == IntPtr.Zero || _embedSavedState is null) return;
+ var (origParent, origStyle) = _embedSavedState.Value;
+ try
+ {
+ // Restore original style FIRST so when we reparent the
+ // window's top-level decorations come back correctly.
+ SetWindowLongPtr(_embeddedHwnd, GWL_STYLE, origStyle);
+ // SetParent(hwnd, Zero) returns to desktop. We could pass
+ // origParent verbatim but for Teams that's always the
+ // desktop anyway, and IntPtr.Zero is documented as
+ // "reparent to desktop".
+ SetParent(_embeddedHwnd, IntPtr.Zero);
+ SetWindowPos(_embeddedHwnd, IntPtr.Zero, 0, 0, 0, 0,
+ SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
+ }
+ catch { /* defensive — restore must never throw */ }
+ finally
+ {
+ _embedSavedState = null;
+ _embeddedHwnd = IntPtr.Zero;
+ }
+ }
+}
diff --git a/src/TeamsISO.App/Services/TeamsLauncher.cs b/src/TeamsISO.App/Services/TeamsLauncher.cs
index 0d0c0f0..b4ba4cd 100644
--- a/src/TeamsISO.App/Services/TeamsLauncher.cs
+++ b/src/TeamsISO.App/Services/TeamsLauncher.cs
@@ -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;
-
- ///
- /// Captures the original parent + window style so embedding can be
- /// reversed cleanly. Tracked per-HWND so multiple consecutive
- /// embed/unembed cycles don't lose the original chrome.
- ///
- private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState;
- private static IntPtr _embeddedHwnd = IntPtr.Zero;
-
- /// True when a Teams window is currently parented inside a TeamsISO host.
- public static bool IsEmbedded => _embeddedHwnd != IntPtr.Zero;
-
- ///
- /// Reparents Teams' most-recently-used top-level window into
- /// . Strips Teams' caption + thick frame so
- /// it integrates flush with the host. Returns true on success, false
- /// if no Teams window could be found.
- ///
- /// The host HWND is typically obtained via:
- /// var src = (System.Windows.Interop.HwndSource)
- /// PresentationSource.FromVisual(MyHostBorder);
- /// src.Handle // → IntPtr suitable for hostHwnd
- ///
- public static bool EmbedTeamsInto(IntPtr hostHwnd, int width, int height)
- {
- if (hostHwnd == IntPtr.Zero) return false;
- var teamsWindows = FindTeamsTopLevelWindows();
- if (teamsWindows.Count == 0) return false;
-
- // Pick the longest-title window as the "main" one — same heuristic
- // GetActiveWindowTitle uses; matches the call/meeting window.
- IntPtr best = IntPtr.Zero;
- int bestLen = -1;
- foreach (var w in teamsWindows)
- {
- var len = GetWindowTextLengthW(w);
- if (len > bestLen) { bestLen = len; best = w; }
- }
- if (best == IntPtr.Zero) return false;
-
- // Already embedded? Unembed first to clean state.
- if (_embeddedHwnd != IntPtr.Zero) RestoreEmbed();
-
- // Save original style + parent so we can fully reverse later.
- var originalStyle = GetWindowLongPtr(best, GWL_STYLE);
- var originalParent = SetParent(best, hostHwnd); // returns old parent
-
- _embedSavedState = (originalParent, originalStyle);
- _embeddedHwnd = best;
-
- // Strip top-level decorations + add WS_CHILD so the OS treats it
- // as a child window of the host.
- var newStyle = originalStyle;
- unchecked
- {
- newStyle &= ~(int)WS_CAPTION;
- newStyle &= ~(int)WS_THICKFRAME;
- newStyle &= ~(int)WS_BORDER;
- newStyle &= ~(int)WS_DLGFRAME;
- newStyle &= ~(int)WS_POPUP;
- newStyle |= (int)WS_CHILD;
- }
- SetWindowLongPtr(best, GWL_STYLE, newStyle);
-
- // Force a non-client recalculation so the style change takes effect.
- SetWindowPos(best, IntPtr.Zero, 0, 0, 0, 0,
- SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
-
- // Place at top-left of host, full host size.
- MoveWindow(best, 0, 0, width, height, true);
- return true;
- }
-
- ///
- /// Resize the currently-embedded Teams window to
- /// × . Called when the host element resizes
- /// (window resize, layout change, etc.). No-op if nothing is embedded.
- ///
- public static void ResizeEmbedded(int width, int height)
- {
- if (_embeddedHwnd == IntPtr.Zero || width <= 0 || height <= 0) return;
- MoveWindow(_embeddedHwnd, 0, 0, width, height, true);
- }
-
- ///
- /// Reverse an active embed: SetParent back to desktop + restore the
- /// original window style so Teams looks/behaves like a normal top-level
- /// window again. Safe to call when nothing is embedded — no-op.
- ///
- public static void RestoreEmbed()
- {
- if (_embeddedHwnd == IntPtr.Zero || _embedSavedState is null) return;
- var (origParent, origStyle) = _embedSavedState.Value;
- try
- {
- // Restore original style FIRST so when we reparent the window's
- // top-level decorations come back correctly.
- SetWindowLongPtr(_embeddedHwnd, GWL_STYLE, origStyle);
- // SetParent(hwnd, Zero) returns to desktop. We could pass
- // origParent verbatim but for Teams that's always the desktop
- // anyway, and IntPtr.Zero is documented as "reparent to desktop".
- SetParent(_embeddedHwnd, IntPtr.Zero);
- SetWindowPos(_embeddedHwnd, IntPtr.Zero, 0, 0, 0, 0,
- SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
- }
- catch { /* defensive — restore must never throw */ }
- finally
- {
- _embedSavedState = null;
- _embeddedHwnd = IntPtr.Zero;
- }
- }
-
///
/// Returns the title bar text of Teams' most-recently-used top-level
/// window, or empty string if Teams isn't running. Modern Teams puts
@@ -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.
///
- private static List FindTeamsTopLevelWindows()
+ ///
+ /// Return the visible top-level windows owned by any Teams process.
+ /// Exposed internal so 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.
+ ///
+ internal static List EnumerateTopLevelTeamsWindows()
{
var teamsPids = new HashSet(
TeamsProcessNames
@@ -509,7 +354,7 @@ public static class TeamsLauncher
///
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
///
public static bool SendShortcut(ShortcutModifiers modifiers, int virtualKey)
{
- var windows = FindTeamsTopLevelWindows();
+ var windows = EnumerateTopLevelTeamsWindows();
if (windows.Count == 0) return false;
var hwnd = windows[^1];
diff --git a/src/TeamsISO.App/TeamsEmbedWindow.xaml.cs b/src/TeamsISO.App/TeamsEmbedWindow.xaml.cs
index 4e0d0db..96c0928 100644
--- a/src/TeamsISO.App/TeamsEmbedWindow.xaml.cs
+++ b/src/TeamsISO.App/TeamsEmbedWindow.xaml.cs
@@ -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.
///
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 */ }
}