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 */ } }