Phase E.4 experimental: SetParent-embed Teams window inside TeamsISO
Some checks failed
CI / build-and-test (push) Failing after 28s

Reparents Teams' main top-level window into a TeamsISO-owned host via Win32 SetParent + window-style stripping. Operator gets Teams visually INSIDE TeamsISO instead of as a separate window — completes the 'Teams runs within this app' direction the user asked for after auto-hide.

Strictly opt-in (DISPLAY tab → 'Embed Teams window (experimental)'). Modern Teams runs WebView2 in its main window; WebView2 is sensitive to parent changes and may render glitches or refuse focus. If so, operator unticks and falls back to auto-hide mode.

Implementation:

- TeamsLauncher.EmbedTeamsInto(hostHwnd, w, h): finds Teams' main window (longest-title heuristic — same as GetActiveWindowTitle), saves original parent + WS_STYLE, SetParents into host, strips WS_CAPTION + WS_THICKFRAME + WS_BORDER + WS_DLGFRAME + WS_POPUP, adds WS_CHILD, MoveWindow to fit.

- TeamsLauncher.RestoreEmbed(): SetParent back to desktop + restore saved window styles. Idempotent — safe to call on shutdown even if nothing was embedded.

- TeamsLauncher.ResizeEmbedded(w, h): MoveWindow to new dimensions; called from host SizeChanged event.

- New TeamsEmbedWindow chromeless host with an EXPERIMENTAL pill in the caption. Loaded → grab HwndSource from EmbedHost Border → call EmbedTeamsInto. SizeChanged → ResizeEmbedded. Closed → RestoreEmbed (in try/finally so a crash can't leave Teams orphaned). Friendly fallback messages if no Teams window exists or HWND grab fails.

- Settings → DISPLAY → checkbox + 'Open embed window' button (gated by the checkbox). Persisted via EmbedTeamsWindow on UIPreferences.
This commit is contained in:
Zac Gaetano 2026-05-10 21:14:42 -04:00
parent aa07ad9f08
commit cc29c503a9
7 changed files with 368 additions and 2 deletions

View file

@ -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."/>
<!-- Phase E.4 experimental — SetParent embed. WebView2 in modern
Teams can render weirdly after reparent; if so, untick and
fall back to auto-hide mode. -->
<Border Margin="0,12,0,8"
Height="1"
Background="{DynamicResource Wd.Border}"/>
<CheckBox Content="Embed Teams window inside TeamsISO (experimental)"
IsChecked="{Binding Settings.EmbedTeamsWindow}"
Margin="0,4,0,0"
ToolTip="EXPERIMENTAL: Reparent Teams' main window into a TeamsISO-owned host so Teams appears INSIDE our window. WebView2 in modern Teams may render glitches or refuse focus after reparent — if so, untick and use auto-hide mode instead. Click 'Open embed window' below after enabling."/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Open embed window"
Click="OnOpenEmbedWindowClick"
HorizontalAlignment="Stretch"
Margin="0,8,0,0"
Padding="0,8"
IsEnabled="{Binding Settings.EmbedTeamsWindow}"
ToolTip="Open the embed host window. Teams' main window will be reparented into it on load. Close the window to restore Teams to normal top-level state."/>
<Separator Margin="0,16,0,8"/>
<CheckBox Content="Record ISOs to disk"

View file

@ -174,6 +174,19 @@ public partial class MainWindow : Window
/// from the left-click so a normal click is "open / surface" rather than
/// the previous "open OR ambush you with a stop dialog".
/// </summary>
/// <summary>
/// Open the experimental Teams embed window. Operator enables the
/// preference first; this button materializes the host. See
/// <see cref="TeamsEmbedWindow"/> for the SetParent lifecycle.
/// </summary>
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;

View file

@ -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;
/// <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

View file

@ -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()
{

View file

@ -0,0 +1,76 @@
<Window x:Class="TeamsISO.App.TeamsEmbedWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Teams (embedded)"
Icon="/Assets/teamsiso.ico"
Width="1280" Height="720"
MinWidth="640" MinHeight="360"
Background="Black"
WindowStyle="None"
ResizeMode="CanResize"
UseLayoutRounding="True">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome
CaptionHeight="32"
ResizeBorderThickness="6"
CornerRadius="0"
GlassFrameThickness="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Caption with experimental warning. The X button restores
Teams' chrome before closing, never leaves Teams in a
reparented-orphan state. -->
<Grid Grid.Row="0" Background="{DynamicResource Wd.Surface}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" Margin="14,0,0,0">
<TextBlock Text="TEAMS (EMBEDDED)"
Style="{StaticResource Wd.Text.Caption}"
VerticalAlignment="Center"/>
<Border Style="{StaticResource Wd.Pill}"
VerticalAlignment="Center"
Margin="10,0,0,0"
Padding="8,2"
Background="{DynamicResource Wd.Accent.CoralBg}">
<TextBlock Text="experimental"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="10"
Foreground="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"/>
</Border>
</StackPanel>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.CaptionClose}"
Click="OnClose"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
ToolTip="Close embed window. Teams' chrome will be restored before this window closes.">
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"/>
</Button>
</Grid>
<!-- Embed host: the Teams window gets SetParent-reparented
into this Border's HWND on Loaded. SizeChanged drives
MoveWindow to keep Teams fitted to our bounds. -->
<Border x:Name="EmbedHost"
Grid.Row="1"
Background="Black"
SizeChanged="OnHostSizeChanged"/>
</Grid>
</Border>
</Window>

View file

@ -0,0 +1,72 @@
using System.Windows;
using System.Windows.Interop;
using TeamsISO.App.Services;
namespace TeamsISO.App;
/// <summary>
/// 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.
/// </summary>
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();
}

View file

@ -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));
/// <summary>
/// Auto-launch the Microsoft Teams desktop client when TeamsISO starts.
@ -307,6 +310,23 @@ public sealed class GlobalSettingsViewModel : ObservableObject
}
}
/// <summary>
/// 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.
/// </summary>
public bool EmbedTeamsWindow
{
get => _embedTeamsWindow;
set
{
if (SetField(ref _embedTeamsWindow, value)) PersistUiPrefs();
}
}
/// <summary>
/// Record each newly-enabled ISO's normalized output to disk under
/// <see cref="RecordingDirectory"/>. Already-running ISOs are not retroactively