- Rename solution files: TeamsISO.sln/slnf -> Dragon-ISO.sln/slnf - Rename all src/TeamsISO.* directories and project files to src/Dragon-ISO.* equivalents - Update .gitignore to exclude build/test output logs - Update ci.yml, CHANGELOG.md, build-and-test.ps1, docs references Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
177 lines
7.6 KiB
C#
177 lines
7.6 KiB
C#
using System.Runtime.InteropServices;
|
|
|
|
namespace DragonISO.App.Services;
|
|
|
|
/// <summary>
|
|
/// Phase E.4 — Embedded Teams via SetParent.
|
|
///
|
|
/// Reparents Teams' main top-level window into a Dragon-ISO-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 Dragon-ISO 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;
|
|
}
|
|
}
|
|
}
|