diff --git a/src/TeamsISO.App/MainWindow.xaml b/src/TeamsISO.App/MainWindow.xaml
index 5ab756a..d1fc1c0 100644
--- a/src/TeamsISO.App/MainWindow.xaml
+++ b/src/TeamsISO.App/MainWindow.xaml
@@ -1343,6 +1343,25 @@
Margin="0,8,0,0"
ToolTip="When checked, recording auto-starts the moment Teams transitions into a call (IN CALL pill goes cyan) and auto-stops when the call ends. Removes the manual Record toggle step from unattended-show workflows."/>
+
+
+
+
+
+ ///
+ /// Open the experimental Teams embed window. Operator enables the
+ /// preference first; this button materializes the host. See
+ /// for the SetParent lifecycle.
+ ///
+ private void OnOpenEmbedWindowClick(object sender, RoutedEventArgs e)
+ {
+ // Non-modal so the operator can keep using TeamsISO's controls.
+ // Owner = this so it minimizes / closes with TeamsISO.
+ var w = new TeamsEmbedWindow { Owner = this };
+ w.Show();
+ }
+
private void OnLaunchTeamsRightClick(object sender, MouseButtonEventArgs e)
{
if (!TeamsLauncher.IsRunning()) return;
diff --git a/src/TeamsISO.App/Services/TeamsLauncher.cs b/src/TeamsISO.App/Services/TeamsLauncher.cs
index 334faca..0d0c0f0 100644
--- a/src/TeamsISO.App/Services/TeamsLauncher.cs
+++ b/src/TeamsISO.App/Services/TeamsLauncher.cs
@@ -272,6 +272,167 @@ public static class TeamsLauncher
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
diff --git a/src/TeamsISO.App/Services/UIPreferences.cs b/src/TeamsISO.App/Services/UIPreferences.cs
index 4de17a8..b68e7a9 100644
--- a/src/TeamsISO.App/Services/UIPreferences.cs
+++ b/src/TeamsISO.App/Services/UIPreferences.cs
@@ -50,7 +50,12 @@ public static class UIPreferences
// (UIA Leave button appears), TeamsISO flips global recording on;
// when the call ends, recording stops. Pairs naturally with the
// headless workflow — operator never touches the recording toggle.
- bool AutoRecordOnCall = false);
+ bool AutoRecordOnCall = false,
+ // Experimental Phase E.4. SetParent-reparents Teams' main window
+ // into a TeamsISO-owned host. WebView2 in modern Teams can render
+ // weirdly after reparent; if so the operator unticks and falls
+ // back to auto-hide mode. Off by default.
+ bool EmbedTeamsWindow = false);
public static Prefs Load()
{
diff --git a/src/TeamsISO.App/TeamsEmbedWindow.xaml b/src/TeamsISO.App/TeamsEmbedWindow.xaml
new file mode 100644
index 0000000..ca7c9e9
--- /dev/null
+++ b/src/TeamsISO.App/TeamsEmbedWindow.xaml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/TeamsISO.App/TeamsEmbedWindow.xaml.cs b/src/TeamsISO.App/TeamsEmbedWindow.xaml.cs
new file mode 100644
index 0000000..4e0d0db
--- /dev/null
+++ b/src/TeamsISO.App/TeamsEmbedWindow.xaml.cs
@@ -0,0 +1,72 @@
+using System.Windows;
+using System.Windows.Interop;
+using TeamsISO.App.Services;
+
+namespace TeamsISO.App;
+
+///
+/// Phase E.4 experimental — hosts an embedded copy of the Teams main
+/// window via SetParent. Operator opens this from Settings → DISPLAY →
+/// 'Embed Teams window'. The host Border's HWND becomes Teams' parent on
+/// Loaded; SizeChanged keeps Teams fitted; Closing always restores Teams
+/// to a normal top-level window before we exit.
+///
+/// Failsafes:
+/// • If no Teams window is found at Loaded, show a friendly message
+/// 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
+/// embedding never succeeded.
+///
+public partial class TeamsEmbedWindow : Window
+{
+ public TeamsEmbedWindow()
+ {
+ InitializeComponent();
+ Loaded += OnWindowLoaded;
+ Closed += OnWindowClosed;
+ }
+
+ private void OnWindowLoaded(object sender, RoutedEventArgs e)
+ {
+ var src = PresentationSource.FromVisual(EmbedHost) as HwndSource;
+ if (src is null || src.Handle == IntPtr.Zero)
+ {
+ MessageBox.Show(
+ "Couldn't obtain a host HWND for the embed window. " +
+ "Try closing and re-opening the embed window.",
+ "TeamsISO — embed",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ var w = (int)EmbedHost.ActualWidth;
+ var h = (int)EmbedHost.ActualHeight;
+ if (!TeamsLauncher.EmbedTeamsInto(src.Handle, w, h))
+ {
+ MessageBox.Show(
+ "Couldn't find a Microsoft Teams window to embed. " +
+ "Launch Teams first (rail camera icon), then re-open this window.",
+ "TeamsISO — embed",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ }
+
+ private void OnHostSizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ // 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);
+ }
+
+ 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(); }
+ catch { /* defensive — restore is best-effort */ }
+ }
+
+ private void OnClose(object sender, RoutedEventArgs e) => Close();
+}
diff --git a/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs b/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs
index d621f2d..aaf98b3 100644
--- a/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs
+++ b/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs
@@ -37,6 +37,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
private bool _launchTeamsOnStartup;
private bool _autoHideTeamsWindows;
private bool _autoRecordOnCall;
+ private bool _embedTeamsWindow;
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
{
@@ -65,6 +66,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
_launchTeamsOnStartup = uiPrefs.LaunchTeamsOnStartup;
_autoHideTeamsWindows = uiPrefs.AutoHideTeamsWindows;
_autoRecordOnCall = uiPrefs.AutoRecordOnCall;
+ _embedTeamsWindow = uiPrefs.EmbedTeamsWindow;
// Bring the auto-apply flag in from the presets store so the checkbox
// reflects the user's prior choice when the settings panel opens.
@@ -258,7 +260,8 @@ public sealed class GlobalSettingsViewModel : ObservableObject
ControlSurfaceLanReachable: _controlSurfaceLanReachable,
LaunchTeamsOnStartup: _launchTeamsOnStartup,
AutoHideTeamsWindows: _autoHideTeamsWindows,
- AutoRecordOnCall: _autoRecordOnCall));
+ AutoRecordOnCall: _autoRecordOnCall,
+ EmbedTeamsWindow: _embedTeamsWindow));
///
/// Auto-launch the Microsoft Teams desktop client when TeamsISO starts.
@@ -307,6 +310,23 @@ public sealed class GlobalSettingsViewModel : ObservableObject
}
}
+ ///
+ /// EXPERIMENTAL: SetParent-reparents Teams' main window into a TeamsISO-
+ /// owned host so Teams visually appears inside our window. WebView2 in
+ /// modern Teams may render weirdly after reparent — if so, untick and
+ /// fall back to the auto-hide flow. Polling logic in MainWindow.xaml.cs
+ /// applies / restores the embed; this property is just the persisted
+ /// toggle.
+ ///
+ public bool EmbedTeamsWindow
+ {
+ get => _embedTeamsWindow;
+ set
+ {
+ if (SetField(ref _embedTeamsWindow, value)) PersistUiPrefs();
+ }
+ }
+
///
/// Record each newly-enabled ISO's normalized output to disk under
/// . Already-running ISOs are not retroactively