TeamsLauncher.cs was 665 lines / 30KB and mixed two unrelated lifecycles:
launch / hide / show / in-call orchestration (the bulk of the file), and
the Phase E.4 experimental SetParent-based embedding (~160 lines of
distinct Win32 surface area + its own state machine).
* Services/TeamsEmbedHost.cs (177L, new) — public static class owning
EmbedTeamsInto / ResizeEmbedded / RestoreEmbed / IsEmbedded plus the
Win32 p/invokes specific to embedding (SetParent, GetWindowLongPtr,
SetWindowLongPtr, MoveWindow, SetWindowPos), the WS_* / SWP_* style
+ position constants, and the embed-state fields. The whole lifecycle
(reparent → resize → restore) now lives in one place; the comment
about WebView2 fragility moves with the code.
* Services/TeamsLauncher.cs (was 665L → now 510L) — keeps launch /
stop / join / hide / show / window-title / shortcut concerns. The
internal helper that enumerates Teams top-level windows is now
named EnumerateTopLevelTeamsWindows (was FindTeamsTopLevelWindows)
and marked `internal` so TeamsEmbedHost can call it without
duplicating the EnumWindows traversal — both classes use the same
process-name heuristic and the launcher's hide/show paths also
consume it.
* TeamsEmbedWindow.xaml.cs — call sites moved from TeamsLauncher.* to
TeamsEmbedHost.* (three references).
No behavior change. Build clean; 56 + 104 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
The IN-CALL pill now reads 'IN CALL · Weekly Standup' (or 'IN CALL' if Teams' window doesn't expose a meeting title), so operators using auto-hide know WHICH meeting they're in without restoring the Teams window.
Implementation: TeamsLauncher.GetActiveWindowTitle uses EnumWindows + GetWindowTextW to read every Teams top-level window title (hidden windows too — title bar text is accessible even with SW_HIDE), picks the longest as a heuristic for 'most informative' (Teams creates several windows per process; the call window has the meaningful title). MainViewModel.ExtractMeetingTitle strips the ' | Microsoft Teams' / ' - Microsoft Teams' suffix variations and clamps overly long titles to 50 chars with an ellipsis.
10 new unit tests for ExtractMeetingTitle covering: standard formats with both separators, bare 'Microsoft Teams' (returns empty so the pill stays at 'IN CALL'), long-title truncation, outer-whitespace trimming, unrecognized formats passing through.
169/169 tests passing.
Adds a small URL input + Join button to the IN-CALL bar. Operators paste a https://teams.microsoft.com/l/meetup-join/... or msteams:/l/meetup-join/... link, click Join, and Teams launches into the meeting in one shot. Eliminates the open-Teams → Calendar → find meeting → click join dance — operators get meeting links from email/Outlook and can now join straight from TeamsISO.
TeamsLauncher.TryJoinMeeting validates the URL targets Teams (only http(s) URLs containing teams.microsoft.com / teams.live.com, or msteams: deep-links — won't shell-exec arbitrary clipboard contents). On success, integrates with AutoHideTeamsWindows so the Teams meeting window briefly appears then vanishes; operator is in the call, driving routing from TeamsISO.
VM-side: MainViewModel.JoinMeetingCommand + JoinMeetingUrl two-way bound. Field clears on success; warn-toast on failure with the specific reason (empty / not-a-teams-url / launch-failed).
Two new persisted preferences in DISPLAY settings, paired to give operators the 'launch TeamsISO, never see Teams' experience the user asked for:
- LaunchTeamsOnStartup: TeamsISO auto-starts Teams in the background each launch (fire-and-forget background task in App.OnStartup, after the main window has materialized so a slow Teams launch doesn't delay the UI).
- AutoHideTeamsWindows: as soon as Teams' windows materialize after launch, hide them. New TeamsLauncher.AutoHideAfterLaunchAsync runs a polling loop (250ms / up to 15s) that catches the splash, main window, and any follow-up panels Teams opens. Teams takes 2-5s to render its main window and the splash arrives separately, so a one-shot hide right after launch wouldn't be enough.
When TeamsISO starts and Teams is already running (from a prior session), the auto-hide path still fires so the 'I only see TeamsISO' rule applies even when Teams was launched externally.
Operator drives everything through the IN-CALL bar (mute / camera / share / leave / marker) + participants DataGrid (ISO routing). Eye-toggle in the rail still restores Teams windows on demand.
Both toggles default to off — opt-in. Persisted via UIPreferences so they survive process restart.
Two user-reported bugs:
1) CheckBox content was clipping in the 380px settings panel ('Control surface (Stream Deck / Companion / w...' / 'LAN-reachable (allow other machines on yo...'). The Wd.CheckBox template used a horizontal StackPanel which doesn't bound child width, so long Content strings ran off the column without wrapping. Replaced StackPanel with a Grid (Auto + *) and injected a TextBlock style with TextWrapping=Wrap into the ContentPresenter resources — when WPF auto-wraps a string Content in a TextBlock, the resource lookup gives it Wrap.
2) The rail Launch Teams button ambushed operators: clicking with Teams already running (which is common when the eye-toggle has hidden Teams' windows) opened a 'Close all Teams windows now?' dialog. Operators expect Launch to mean 'show me Teams', not 'stop Teams'. Split the actions:
- Left-click: Teams not running → launch; Teams hidden → restore + foreground; Teams visible → bring to front. Always idempotent-progressive.
- Right-click: ask to stop Teams (preserves the kill path for those who want it).
TeamsLauncher.TryLaunch now collects per-attempt errors instead of swallowing them — a real failure surfaces 'ms-teams: URI → <reason>' / 'AppsFolder shell → <reason>' / 'classic Update.exe → not found at <path>' so 'No Teams found' isn't a black box.
Also added a 2nd path: explorer.exe shell:appsFolder\\\\MSTeams_8wekyb3d8bbwe!MSTeams (AppX activation via the OS's own Start-menu verb) as a fallback if the URI handler is misconfigured. Removed the broken bare-stub call to %LOCALAPPDATA%\\\\Microsoft\\\\WindowsApps\\\\ms-teams.exe — that's a 0-byte AppX placeholder that never worked outside an AppX context.
Six related polish items, all building on tonight's groundwork.
1. App icon: teamsiso.ico generated from dragon-mark.png at 7 sizes (16-256), wired as ApplicationIcon in the WPF csproj, MainWindow.Icon, AboutWindow.Icon, and ARPPRODUCTICON in the WiX MSI. Taskbar / window / Add-Remove-Programs all show the dragon mark now.
2. Running incoming FPS: ring buffer of last 30 frame timestamps in IsoPipeline; ComputeFps() returns moving-average rate. Surfaced on IsoHealthStats.IncomingFps and shown in the Source column of the participants DataGrid as 'WxH · 59.94 fps'. Resets cleanly on every supervisor restart.
3. Drops counter: FrameProcessor.Stats already aggregated FramesDropped (closest-frame strategy when the receiver outpaces the processor) and FramesDuplicated; just plumbed _liveProcessor through IsoPipeline so GetStats() can read them. Exposed in the Live column under the in/out counters as a coral-tinted 'drop N'.
4. Console --version flag: prints engine version (with embedded git SHA), .NET version, OS, NDI runtime banner, expected prefix, exit-code legend, plus a wilddragon.net link. Useful for support tickets.
5. About dialog: chromeless modal with the dragon mark + version / .NET / OS / NDI runtime fields and a link to wilddragon.net. Triggered by clicking the rail logo.
6. Teams launcher Stop toggle: TeamsLauncher gains IsRunning() and StopAll(). The rail's Teams button now toggles — if Teams is up, ask to close all Teams windows via WM_CLOSE; otherwise launch as before. Confirms before stopping so we don't kill the user's call mid-transition.
Tests: 74/74 unit + 9/9 NDI integration green throughout. MSI builds clean and now embeds the dragon icon for ARP.
First step of Phase E.1 from the new spec at docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md: a third icon in the left rail launches the Microsoft Teams desktop client as a subprocess of TeamsISO so the operator doesn't have to leave the app to start a meeting.
Services/TeamsLauncher tries the ms-teams: URI first, falls back to %LOCALAPPDATA%\\Microsoft\\WindowsApps\\ms-teams.exe (new Teams), then the classic Update.exe handoff. On failure surfaces a friendly MessageBox with the install link.
The spec doc lays out the full three-phase roadmap (launcher -> window orchestration -> in-app meeting controls via Graph API or UIAutomation) and explicitly calls out what's out of scope (replacing Teams' media stack).
_NEXT.md updated to mark Phase D done and queue Phase E + remaining polish items (code-signing, Inter/JetBrains Mono font bundling, real Wild Dragon dragon-mark, drops counter, running-fps display).