Compare commits

..

5 commits

Author SHA1 Message Date
150dd3f029 docs(next-steps): refresh — v2 shell + table + palette already shipped
Some checks failed
CI / build-and-test (push) Failing after 26s
The prior NEXT_STEPS.md was written the night the Studio Terminal shell
landed and listed tasks 39 + 40 (table redesign, command palette) as
queued — but they shipped in d282e1b, and several rounds of polish have
landed since (per-ISO overrides, thumbnails, web /ui redesign, gear
glyph swap, ISO toggle corner-radius + column-width fixes).

Rewrite the doc to reflect actual state: what's on main, what's running,
verified-this-session, candidate paper-cuts still to consider, and what
the next real feature pass would look like.
2026-05-16 00:18:35 -04:00
f3e1f50ece docs: refresh participants-table column summary + ISO toggle comment
Some checks failed
CI / build-and-test (push) Failing after 27s
The block comment above the DataGrid still said 'Five columns' and described
the ISO column as a 'pill' at 110px. Both got out-of-date as commits landed:
the table actually has seven columns (preview + CFG were added), the ISO
geometry is a rounded-rect, and the column is 124px now. Bring the comments
in line with the code so future-me reads the right thing first.

No XAML logic changes; comments only.
2026-05-16 00:17:37 -04:00
a98ab811a9 ISO toggle: widen column 110->124, tighten padding so 'Enable' fits
Some checks failed
CI / build-and-test (push) Failing after 31s
After dropping IsoToggle from a full pill to a Radius.M rounded-rect, the
'Enable' label (and the active-state '* LIVE') started clipping at the
right edge of the 110px cell. The pill geometry had visually masked the
tight fit by softening the edges; the squared corners made it obvious.

Widen the ISO column from 110 to 124 (+14px) and tighten the inline button
padding from 14,6 to 10,6. The MinWidth=84 from the IsoToggle style still
covers the OFF state; the column bump gives the active 'LIVE' state room
to breathe without changing the overall row rhythm.
2026-05-16 00:15:26 -04:00
25229bdf1b ISO toggle: square corners to match the rest of the button family
Some checks failed
CI / build-and-test (push) Failing after 27s
Wd.Button.IsoToggle was the only button in the GUI using CornerRadius=999
(full pill). It read as a different control type from the toolbar buttons
around it (Enable all, Refresh, Presets, Stop all, Mute, Cam, Leave —
all Radius.M). The pill shape was meant to make the LIVE state visually
distinct, but the status-coded fill (cyan/coral/amber) already carries
that signal — the geometry was double-duty.

Swap the IsoToggle's CornerRadius from 999 to Radius.M so every button
in the app shares the same shape language. Status read remains via the
fill color.
2026-05-15 16:19:19 -04:00
29f7e56eb9 gear icon: swap Path glyph for U+2699 + bump column to 56px
The custom Path gear with Stroke=Wd.Text.Secondary + StrokeThickness=1.4
rendered as a near-invisible thin grey shape against the dark row
background — users couldn't tell the column was clickable.

Replace with TextBlock rendering U+2699 GEAR from Segoe UI Symbol
at 16px and Wd.Text.Primary foreground. Universally recognized as
'settings', renders crisply at any DPI, and stands out against the
row. Header bumped from empty to 'CFG' so the affordance is
discoverable, column widened from 32px to 56px so 'CFG' fits cleanly.
2026-05-15 16:11:53 -04:00
61 changed files with 2174 additions and 3802 deletions

3
.gitignore vendored
View file

@ -28,6 +28,3 @@ publish/
# OS
.DS_Store
Thumbs.db
# Local Claude session metadata
.claude/

View file

@ -6,46 +6,55 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added — v2 "Studio Terminal" GUI (2026-05-13)
### Added — Ground-up GUI redesign (started 2026-05-12)
The May 2026 ground-up redesign — explicit anti-reference to "the v1
GUI screamed AI made it" — landed on the WPF host
(`src/TeamsISO.App/`). The shape brief lives at
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`. An earlier WinUI
3 replatform was scoped on 2026-05-12 and abandoned in favour of doing
the redesign in WPF (activation blockers + redundant work given the
shared view-model surface). The abandoned migration plan + bootstrap
probe are archived under `docs/archive/`.
After greenlighting a from-the-scratch redesign and an explicit WinUI 3
replatform target, the May 2026 batch is followed by a major
restructuring of the host UI. Highlights:
- **PRODUCT.md** + **DESIGN.md**: impeccable context for the redesign.
Solo broadcast operator at 1:50am is the canonical persona; "vibe-coded
GUI" is the explicit anti-reference. Tokens cover dark + light palettes
with context-aware accent split (cyan surface fill stays bright in
both modes; cyan-as-text darkens to `#0E7C82` on light for AA contrast).
- **Theme system** (`Themes/Theme.Dark.xaml`, `Theme.Light.xaml`,
`WildDragonTheme.xaml`) + `Services/ThemeManager.cs` singleton that
swaps the merged dictionary at runtime, reads
`HKCU\…\AppsUseLightTheme` for System mode, subscribes to
`SystemEvents.UserPreferenceChanged`, persists via
`UIPreferences.Theme`. `Ctrl+T` toggles dark ↔ light.
- **v2 main window shell**: default system title bar; 32px header (Wild
Dragon mark + wordmark left, ⌘K / theme / settings icons right); 40px
transport strip (`● 02:14:32 PART 4 · LIVE 2 CTRL :9755`); body with
alert banner + update banner + action toolbar + participants
DataGrid; conditional `IN CALL` meeting bar at bottom; slide-over
settings drawer (420px from right) with OUTPUT / NETWORK / APP tabs.
The v1 72px rail, the 380px permanent settings panel, and the
six-column footer are gone.
- **Task 39 — participants table v2**: five columns (24px state LED,
name + codec caption, 110px audio meter, 130px mono output name, 100px
ISO pill), 52px rows, full-row active-speaker tint (replaces the v1
left-edge stripe).
- **Task 40 — Ctrl+K command palette**: `Views/CommandPaletteWindow.xaml`
+ `ViewModels/CommandPaletteViewModel.cs`. Centered 560×360 floating
window with fuzzy search across Quick / Teams / Presets / Output /
Network / App categories. ↑/↓ navigates, Enter invokes, Esc closes.
- **Interactive HTML preview** at `docs/preview/redesigned-mainwindow.html`
for stakeholders to see the v2 shell.
both modes; cyan-as-text darkens to #0E7C82 on light for AA contrast).
- **WinUI 3 host scaffold** as `src/TeamsISO.App.WinUI/` coexisting with
the existing WPF host. WindowsAppSDK 1.6 LTS, unpackaged mode, win-x64
pinned RID, custom Bootstrap-aware `Program.Main`, post-build runtimeconfig
patch to drop the .NET-SDK-implicit `Microsoft.WindowsDesktop.App`
framework reference (WinUI 3 doesn't use it).
- **Redesigned MainWindow**: 64px rail with brand mark + nav +
engine-status puck; 44px custom title bar absorbing live pills
(session timer · REC count · disk free) + theme toggle; section header
with single Primary CTA + Secondary actions; participants list
(ItemsRepeater stub pending DataGrid migration) at 64px row height
with cyan-left-border active-speaker treatment; conditional slim
in-call control bar; 32px status bar.
- **ThemeManager service** holds the user theme preference
(System / Dark / Light), resolves via UISettings.GetColorValue when
System, broadcasts changes so the AppWindow title-bar buttons stay
in sync with the visual tree.
- **Settings drawer** that slides in from the right (220ms ease-out-quart)
with five tabs (Appearance / Routing / Display / Control / Advanced).
Appearance tab includes the theme tri-state picker + an accent palette
peek. Replaces the WPF host's 380px permanent settings panel.
- **Help / About / Onboarding** as ContentDialog-based surfaces. Help
is the keyboard shortcut cheat sheet; About has Wild Dragon mark +
version + quick-access folder shortcuts; Onboarding is three numbered
steps for first launch (Install NDI Runtime / Enable Teams NDI / Pick
transcoder topology) with "Don't show again" defaulted on.
- **Interactive HTML preview** at `docs/preview/redesigned-mainwindow.html`
faithful single-file render of the WinUI 3 XAML with theme toggle and
drawer interaction, so stakeholders can see the redesign before the
WinUI 3 build is feature-complete.
- **Migration plan** at `docs/superpowers/plans/2026-05-12-winui3-migration.md`
with nine phases (scaffold / shell / activation / VM wiring / DataGrid
/ secondary windows / hardening / tests / retire-WPF) and a risk
register flagging fallback paths.
The WPF host (`src/TeamsISO.App/`) is unchanged and remains the shipping
build until the WinUI 3 build passes a real-meeting smoke test. The
view-model layer is unchanged so the WinUI 3 host will reuse it via
ProjectReference once view-model wiring lands (Phase 4 of the plan).
### Added — May 2026 feature batch
@ -243,6 +252,14 @@ For operators who want to launch TeamsISO and never look at the Teams UI:
- **Recording badge in footer shows elapsed duration** alongside the count
(`REC 3 · 12:45`). Separate timer from the session timer because
recording can start AFTER the meeting begins.
- **Quick-join Teams meeting from URL** in the IN-CALL bar — paste a
`teams.microsoft.com/l/meetup-join/...` or `msteams:/l/meetup-join/...`
link, click Join, Teams launches into the meeting in one shot.
- **IN-CALL bar surfaces Teams meeting state**`IN CALL · <meeting title>`
/ `READY` / empty. UIA probe at 1Hz for the Leave button, meeting title
extracted from Teams' window title with brand suffix stripped.
- **Auto-launch Teams + auto-hide windows** preferences for the headless
"I only see TeamsISO" workflow.
- **MUTED / CAM OFF pills** in the IN-CALL bar — UIA detects whether the
local user is muted or has their camera off, surfaces as coral pills.
Operator with auto-hide knows the local state without restoring Teams.

View file

@ -219,10 +219,9 @@ the same job with less visual noise.
**Single icon system, one stroke width, one optical size.** The previous GUI
inlined ~12 bespoke `<Path Data="...">` icons with stroke widths varying
between 1.2 and 1.6. The redesign uses **Segoe Fluent Icons font** (shipped
with Windows 11; falls back to Segoe MDL2 Assets on Windows 10) as the
baseline, with a custom subset added only where a broadcast concept isn't
covered (e.g. NDI signal lock, ISO routing state).
between 1.2 and 1.6. The redesign uses **WinUI 3's bundled Segoe Fluent Icons
font** as the baseline, with a custom subset added only where a broadcast
concept isn't covered (e.g. NDI signal lock, ISO routing state).
Sizes: 16 (inline), 20 (button), 24 (rail / hero).
Stroke: inherited from font; no hand-stroked paths.
@ -234,8 +233,8 @@ Stroke: inherited from font; no hand-stroked paths.
- Durations: 120ms for affordance feedback, 200ms for panel transitions,
280ms hero (rarely used).
- No bounce. No elastic. No spring overshoots.
- **Never animate** layout properties. Animate `RenderTransform` and
`Opacity` (WPF's composition layer handles these GPU-cheaply).
- **Never animate** layout properties. Animate `Translation` and `Opacity`
(WinUI 3's composition layer handles these GPU-cheaply).
## Component decisions
@ -330,7 +329,7 @@ no side-stripe borders, no glassmorphism). It does have:
## Migration boundary
The view-model surface in `src/TeamsISO.App/ViewModels/` is the contract.
The redesign rewrites `MainWindow.xaml` and `Themes/*` but leaves view-model
The redesign rewrites everything in `Views/` (WinUI 3) but leaves view-model
properties and commands untouched. Any place where the redesign needs a new
piece of view-model state, the contract widens via additive properties —
existing bindings keep working until the new view stops needing the old shape.

View file

@ -1,81 +1,107 @@
# Where we left off — v2 "Studio Terminal" shell complete (2026-05-15)
# Where we left off — Studio Terminal shell shipped, paper-cuts being chipped (2026-05-16 morning)
## What's done on main
## State of main right now
**v2 shape locked.** Approved brief at
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`. Aesthetic
register: "broadcast-engineering instrument" — Linear's keyboard-first
density × Avid console legibility. Goes hard against the "screams AI"
failure mode.
Origin tip: `f3e1f50` — *docs: refresh participants-table column summary + ISO toggle comment*
**WinUI 3 replatform: abandoned.** The early-May scoping concluded that
the redesign is purely view-layer (XAML + theme tokens + view-models);
doing it in WPF is strictly less work than fighting WinUI 3 activation +
DataGrid replacement. The migration plan + bootstrap probe are archived
under `docs/archive/` for the record.
Recent commits on `origin/main`, newest first:
**Shell:**
- Default Windows title bar (no custom chromeless caption buttons).
- 32px header — Wild Dragon mark + "TeamsISO" wordmark left; three icon
buttons right (⌘K command palette, theme toggle, settings gear).
- 40px transport strip — single mono line:
`● 02:14:32 PART 4 · LIVE 2 CTRL :9755`. Cyan dot + timer only when
at least one ISO live.
- Body — alert banner + update banner + action toolbar + participants
DataGrid + (conditional) meeting bar at the bottom.
- Settings — slide-over drawer (420px from right) with OUTPUT / NETWORK /
APP tabs. Scrim click or Esc dismisses.
- v1 leftovers (72px rail, 380px permanent settings panel, six-column
footer) are gone.
**Theme system:**
- `Themes/Theme.Dark.xaml` + `Themes/Theme.Light.xaml` — color brushes
only.
- `Themes/WildDragonTheme.xaml` — styles + control templates (no color
brushes; every brush ref is `DynamicResource`).
- `Services/ThemeManager.cs` — swaps the merged dictionary at runtime;
reads `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme`
for System mode; subscribes to `SystemEvents.UserPreferenceChanged`;
persists via `UIPreferences.Theme`.
**Task 39 — participants table v2 (LANDED).**
Five columns: 24px state LED, name + codec caption, 110px audio meter,
130px mono output name, 100px ISO pill. 52px rows. Full-row
active-speaker tint (replaces the v1 left-stripe).
**Task 40 — Ctrl+K command palette (LANDED).**
`Views/CommandPaletteWindow.xaml` + `ViewModels/CommandPaletteViewModel.cs`
ship a centered 560×360 floating window with fuzzy search across Quick /
Teams / Presets / Output / Network / App categories. ↑/↓ navigates,
Enter invokes, Esc closes. The header ⌘K button and Ctrl+K (also Ctrl+P)
keyboard binding both open it.
**Hotkeys:**
- `F1` — help / cheat sheet
- `Ctrl+K` (also `Ctrl+P`) — command palette
- `Ctrl+T` — toggle theme (dark ↔ light)
- `Ctrl+M` — drop marker into every active recording
- `Ctrl+R` — refresh NDI discovery
- `Ctrl+Shift+S` — panic-stop every ISO
- `1``9` / `NumPad 1``9` — toggle the Nth visible participant's ISO
## What's queued
Pre-1.0 cut is gated on:
1. Code-signing the MSI (`SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD`
Forgejo Secrets wired in `release.yml`).
2. A real-meeting smoke pass on a host with a live NDI runtime.
## Build + run
```powershell
dotnet build TeamsISO.Windows.slnf -c Release
.\src\TeamsISO.App\bin\Release\net8.0-windows\TeamsISO.exe
```
f3e1f50 docs: refresh participants-table column summary + ISO toggle comment
a98ab81 ISO toggle: widen column 110->124, tighten padding so 'Enable' fits
25229bd ISO toggle: square corners to match the rest of the button family
29f7e56 gear icon: swap Path glyph for U+2699 + bump column to 56px
5a43c9c feat: per-ISO framerate/resolution/aspect/audio overrides + thumbnail BMP
647deec feat(web): topology + thumbnail endpoints, redesigned /ui control panel
4944de5 feat(wpf): v2 - restore live thumbnail preview column in participants table
209b643 fix(wpf): MainViewModel subscription via direct Subscribe + Dispatcher marshal
d282e1b feat(wpf): v2 task 39+40 - studio table redesign + Ctrl+K command palette
c271303 feat(wpf): v2 'Studio Terminal' shell - theme system, header, transport strip, drawer
1d1ce6a feat(wpf): rollback to WPF host, axe recording, fix settings pane
```
The shipped helpers `build-and-test.ps1` and `commit-and-push.ps1`
wrap the build + test + push flow.
The v2 "Studio Terminal" shell, the v2 participants table redesign (task 39),
the Ctrl+K command palette (task 40), per-ISO overrides + thumbnails, and the
redesigned `/ui` control panel are **all shipped**. The previous NEXT_STEPS.md
listed 39 + 40 as queued — that's stale.
If something regresses, `1d1ce6a` is the rollback point for the WPF v1
shell (recording was axed at that commit), and `c271303` is the v2
shell-without-table-redesign rollback point.
## What's running
A fresh Release build (PID changes each session — current one is `7928`,
started 2026-05-16 00:17). Source: `src/TeamsISO.App/bin/Release/net8.0-windows/TeamsISO.exe`,
last write 23:50ish after the comment-refresh rebuild.
## Verified this session
- `dotnet test src/tests/TeamsISO.Engine.Tests/`**104 passed / 0 failed**.
- `dotnet build src/TeamsISO.App/TeamsISO.App.csproj -c Release` → 0 warnings, 0 errors.
- Pushed three new commits to forgejo: `5a43c9c..f3e1f50`.
## Candidate paper-cuts I noticed but didn't touch
These are the same shape of fix as the ones in `25229bd` / `29f7e56` /
`a98ab81` — small, single-file, low-risk — but I want you to sign off
before they ship:
1. **Header gear button still uses the thin-Path glyph.** `MainWindow.xaml:149`.
The exact same near-invisible 1.4px stroke that drove the per-row swap to
`U+2699` in `29f7e56` is still on the header settings gear. Same fix
probably applies — swap Path → `TextBlock` with `U+2699` from Segoe UI
Symbol, drop the size hack.
2. **Header theme-toggle button.** `MainWindow.xaml:137`. Hand-rolled
crescent-moon Path with 1.4px stroke. Reads as a vague grey blob in dark
mode. Candidates: `U+263D` (☽), `U+263E` (☾), or `U+2600` / `U+263C`
(☀ for light), with a `DataTrigger` swapping by `ThemeManager.Current.Mode`.
3. **Launch-Teams / Hide-Teams icon buttons in the toolbar.**
`MainWindow.xaml:388` and `:400`. Custom Path glyphs at 1.4 / 1.5 stroke
thickness — same legibility complaint, just less load-bearing because
the buttons also have tooltips.
4. **Per-row gear column** (`CFG`, 56px wide). Was widened in `29f7e56`
from 32 → 56 specifically to fit the "CFG" header. The gear glyph
inside is centered and only ~16px wide, so 56px is generous; could
probably go to 48px and reclaim 8px for the flex `*` column without
losing anything.
## What's queued for an actual feature pass
Nothing committed-against. Conversation has danced around:
- **Real command palette content.** The `Ctrl+K` window exists (commit
`d282e1b`) — actions feed from `CommandPaletteCommands.cs`. Worth a pass
to make sure every toolbar action and settings tab is mirrored there,
and that the keyboard shortcut hints render right.
- **Settings drawer copy.** The OUTPUT / NETWORK / APP tabs were rewritten
during the v2 shell pass. Some of the help text is still v1-era and
refers to controls that aren't there anymore. Worth a sweep through
`MainWindow.xaml` around the drawer body.
- **Onboarding window.** `OnboardingWindow.xaml` exists. Last touched
before the v2 shell. Probably out of date visually.
## Build, push, demo cheatsheet
```powershell
cd "C:\Users\zacga\Documents\Claude\Projects\Teams ISO"
# Clear a stale index lock if you hit one
Remove-Item .git\index.lock -Force -ErrorAction SilentlyContinue
# Stop any running instance (release the DLL locks)
Stop-Process -Name TeamsISO -Force -ErrorAction SilentlyContinue
# Build + run
dotnet build src\TeamsISO.App\TeamsISO.App.csproj -c Release
Start-Process .\src\TeamsISO.App\bin\Release\net8.0-windows\TeamsISO.exe
# Push when ready
git add -A
git commit -m "your message"
git push origin main # forge.wilddragon.net
```
If anything regresses the v2 shell, the v1 entry point is preserved in git
history at `1d1ce6a` — rollback with `git reset --hard 1d1ce6a` then publish.

View file

@ -44,21 +44,22 @@ Pre-1.0. The May 2026 batch is feature-complete; v1.0 cut is gated on
code-signing the MSI and a smoke pass against a real Teams meeting.
See `CHANGELOG.md` for the [Unreleased] entry.
The May 2026 ground-up redesign — the v2 "Studio Terminal" shell — has
landed on the WPF host (`src/TeamsISO.App/`). A WinUI 3 replatform was
explored in early May 2026 and abandoned (activation blockers + redundant
work given the redesign is purely XAML / view-layer); the brief lives at
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`, and the
abandoned migration plan + bootstrap probe are archived under
`docs/archive/`.
A ground-up GUI redesign is in flight on `main` (see
`docs/superpowers/plans/2026-05-12-winui3-migration.md`). The WPF host
(`src/TeamsISO.App/`) remains the shipping build; a parallel WinUI 3 host
(`src/TeamsISO.App.WinUI/`) is scaffolded with the redesigned MainWindow,
theme system (dark + light), and secondary surfaces. Activation of the
unpackaged WinUI 3 .exe is the current blocker — diagnostics in the
migration plan's Phase 3.
## Build
Requires .NET 8 SDK on Windows. WPF is the only host:
Requires .NET 8 SDK on Windows. The repo has two hosts:
- `src/TeamsISO.App` — WPF, `net8.0-windows`, the shipping build
- `src/TeamsISO.App` — WPF, `net8.0-windows`, current shipping build
- `src/TeamsISO.App.WinUI` — WinUI 3, `net8.0-windows10.0.19041.0`, in-flight
Build from the solution filter:
Both build together from the solution filter:
dotnet restore TeamsISO.Windows.slnf
dotnet build TeamsISO.Windows.slnf -c Release
@ -79,21 +80,21 @@ The shipped helper scripts in the repo root automate this:
- [Embedded Teams orchestration spec](docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md)
— Phase E roadmap.
- [Redesign brief](PRODUCT.md) + [design system](DESIGN.md) — token-level
spec for the v2 "Studio Terminal" redesign.
- [v2 shape brief](docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md) —
approved aesthetic + IA for the May 2026 WPF rebuild.
spec for the in-flight WinUI 3 redesign.
- [WinUI 3 migration plan](docs/superpowers/plans/2026-05-12-winui3-migration.md)
— nine-phase plan covering scaffold through retiring the WPF host.
- [Interactive redesign preview](docs/preview/redesigned-mainwindow.html) —
open in any browser to see and toggle the redesigned MainWindow before
the WinUI 3 binary lands.
## Keyboard shortcuts
| Key | Action |
| --- | --- |
| `F1` | Open help / cheat sheet |
| `Ctrl + K` | Open the command palette (also `Ctrl + P`) |
| `Ctrl + T` | Toggle theme (dark ↔ light) |
| `Ctrl + M` | Drop a timestamped marker into every active recording |
| `Ctrl + Shift + S` | Stop every running ISO (emergency) |
| `Ctrl + R` | Refresh NDI discovery (rebuild finder) |
| `1``9` / `NumPad 1``9` | Toggle the Nth visible participant's ISO |
## File locations

View file

@ -6,6 +6,7 @@
"src\\TeamsISO.Engine.NdiInterop\\TeamsISO.Engine.NdiInterop.csproj",
"src\\TeamsISO.Console\\TeamsISO.Console.csproj",
"src\\TeamsISO.App\\TeamsISO.App.csproj",
"src\\TeamsISO.App.WinUI\\TeamsISO.App.WinUI.csproj",
"src\\tests\\TeamsISO.Engine.Tests\\TeamsISO.Engine.Tests.csproj",
"src\\tests\\TeamsISO.Engine.IntegrationTests\\TeamsISO.Engine.IntegrationTests.csproj",
"src\\tests\\TeamsISO.App.Tests\\TeamsISO.App.Tests.csproj"

View file

@ -1,45 +1,443 @@
# Build + test verification, then push the current branch to origin.
# Commit + push the May 2026 polish batch to forge.wilddragon.net.
#
# Run from the repo root:
# pwsh -ExecutionPolicy Bypass -File .\commit-and-push.ps1
#
# This is the operator's "I'm done with this branch, ship it" helper. It
# runs build-and-test.ps1 first (Release build with TreatWarningsAsErrors,
# then the test suite minus the requires=ndi tier), and only pushes if
# both pass.
#
# History note: the prior incarnation of this script (May 2026) was a
# one-shot batch-commit script that staged 25 themed commits in sequence
# to land the May 2026 polish batch on origin/main. That work has long
# since been committed, so the staging logic is dead weight; the script
# now reflects the actual day-to-day workflow.
# Splits into 8 atomic commits (#59, #61, #64-#69), then pushes origin/main.
# Stops on first error so you can resolve and re-run.
$ErrorActionPreference = 'Stop'
# Ensure we're at repo root.
if (-not (Test-Path '.git') -or -not (Test-Path 'TeamsISO.sln')) {
throw "Run from the TeamsISO repo root."
}
# Step 1 — build + tests must be green before anything ships.
Write-Host "──── Build + test ────" -ForegroundColor Cyan
pwsh -NoProfile -ExecutionPolicy Bypass -File '.\build-and-test.ps1'
if ($LASTEXITCODE -ne 0) { throw "build-and-test.ps1 failed; aborting." }
# Tidy up the diagnostic artifact I left while probing the sandbox.
if (Test-Path '.claude-bash-test.txt') {
Remove-Item '.claude-bash-test.txt' -Force
Write-Host "Removed sandbox diagnostic file." -ForegroundColor DarkGray
}
# Step 2 — what are we pushing? Surface the branch + commit summary so
# the operator sees the exact thing about to land on the remote.
$branch = (git rev-parse --abbrev-ref HEAD).Trim()
if ($branch -eq 'HEAD') { throw "Detached HEAD; check out a branch before running this script." }
# ─── helper ─────────────────────────────────────────────────────────────
function Stage-AndCommit($message, [string[]]$paths) {
Write-Host ""
Write-Host "──── $message ────" -ForegroundColor Cyan
foreach ($p in $paths) {
if (Test-Path $p) {
git add -- $p
if ($LASTEXITCODE -ne 0) { throw "git add failed for $p" }
} else {
Write-Warning "Path not found, skipping: $p"
}
}
# Anything actually staged?
git diff --cached --quiet
if ($LASTEXITCODE -eq 0) {
Write-Host " (no changes to commit; skipping)" -ForegroundColor DarkGray
return
}
git commit -m $message
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $message" }
}
# ─── #59 Auto-disable on participant departure ─────────────────────────
# View-model gained AutoDisableOnDeparture; MainViewModel hooks departure;
# DISPLAY settings shows the toggle.
# (These three files also carry later changes — staging them here means the
# first commit captures only the auto-disable additions IF you've checked
# the diff is clean. If `git diff --cached` after the add looks bigger than
# the auto-disable feature alone, abort, edit the message, and let the
# combined commit cover #59 as part of the broader UI batch.)
Stage-AndCommit `
"feat(ui): auto-disable ISOs when participants leave the meeting" `
@(
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml"
)
# ─── #61 Operator presets ──────────────────────────────────────────────
# Only the new files; the wiring into MainWindow header / MainViewModel
# was already staged above as part of #59 (because all three commits touch
# MainWindow.xaml / MainViewModel.cs, the cleanest atomic split would
# require git add -p; for batch-push we accept that the boundary is
# approximate and the headline message reflects the dominant change).
Stage-AndCommit `
"feat(ui): operator presets — save/load named ISO assignment snapshots" `
@(
"src/TeamsISO.App/Services/OperatorPresetStore.cs",
"src/TeamsISO.App/PresetsDialog.xaml",
"src/TeamsISO.App/PresetsDialog.xaml.cs"
)
# ─── #64 Optional MSI / exe code-signing in release.yml ────────────────
Stage-AndCommit `
"ci: optional MSI + exe code-signing in release.yml" `
@(
".forgejo/workflows/release.yml",
"docs/RELEASING.md"
)
# ─── #65 Refresh discovery affordance ──────────────────────────────────
# Includes engine-side RefreshDiscovery + idempotent re-Add + regression test.
Stage-AndCommit `
"feat(engine): refresh discovery affordance + idempotent re-Add handling" `
@(
"src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs",
"src/TeamsISO.Engine/Discovery/ParticipantTracker.cs",
"src/TeamsISO.Engine/Controller/IIsoController.cs",
"src/TeamsISO.Engine/Controller/IsoController.cs",
"src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs"
)
# ─── #66 / #67 / #68 / #69 UI batch ────────────────────────────────────
# These four features all touch MainViewModel.cs / MainWindow.xaml / theme
# files together, so a per-feature split is impractical without git add -p.
# We commit as one batch with a descriptive message.
Stage-AndCommit `
"feat(ui): May 2026 batch — auto-apply preset, settings tabs, Phase E.2/E.3" `
@(
"src/TeamsISO.App/Services/TeamsLauncher.cs",
"src/TeamsISO.App/Services/TeamsControlBridge.cs",
"src/TeamsISO.App/MainWindow.xaml.cs",
"src/TeamsISO.App/Themes/WildDragonTheme.xaml"
)
# ─── #70 / #71 / #73 Hardening + onboarding ────────────────────────────
# Crash diagnostics, first-launch welcome dialog, Reset-to-defaults button.
# Touches App.xaml.cs, AboutWindow (re-open onboarding link), and adds the
# new OnboardingWindow files.
Stage-AndCommit `
"feat(ui): crash diagnostics, first-launch welcome, reset-to-defaults" `
@(
"src/TeamsISO.App/App.xaml.cs",
"src/TeamsISO.App/OnboardingWindow.xaml",
"src/TeamsISO.App/OnboardingWindow.xaml.cs",
"src/TeamsISO.App/AboutWindow.xaml",
"src/TeamsISO.App/AboutWindow.xaml.cs"
)
# ─── #77 Per-output recording ──────────────────────────────────────────
# IRecorderSink + RawBgraRecorderSink + IsoPipelineConfig.Recorder wiring +
# IsoController.SetRecording + UI checkbox in DISPLAY tab.
Stage-AndCommit `
"feat: per-output recording — raw BGRA stream + ffmpeg convert.cmd" `
@(
"src/TeamsISO.Engine/Pipeline/IRecorderSink.cs",
"src/TeamsISO.Engine/Pipeline/RawBgraRecorderSink.cs",
"src/TeamsISO.Engine/Pipeline/IsoPipelineConfig.cs",
"src/TeamsISO.Engine/Pipeline/IsoPipeline.cs",
"src/TeamsISO.Engine/Controller/IIsoController.cs",
"src/TeamsISO.Engine/Controller/IsoController.cs"
)
# ─── #78 / #79 REST control surface + preset apply lift ───────────────
# ControlSurfaceServer + PresetApplier (lifted from PresetsDialog) +
# REST endpoints + DISPLAY tab toggle + CONTROL-SURFACE.md docs.
# PresetsDialog and MainViewModel.TryAutoApplyPendingPreset both delegate
# to PresetApplier so apply has a single implementation across the dialog,
# auto-apply-on-launch, and the REST surface.
Stage-AndCommit `
"feat: REST control surface + lift preset-apply into PresetApplier" `
@(
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
"src/TeamsISO.App/Services/PresetApplier.cs",
"src/TeamsISO.App/PresetsDialog.xaml.cs",
"docs/CONTROL-SURFACE.md"
)
# ─── #80 In-app preview thumbnails ─────────────────────────────────────
# Engine: IsoPipeline.LatestProcessedFrame + IsoController.GetLatestProcessedFrame.
# UI: ParticipantViewModel.Thumbnail (WriteableBitmap, BGRA, 160x90 nearest-neighbor),
# DataGrid Preview column, .csproj AllowUnsafeBlocks.
Stage-AndCommit `
"feat: in-app preview thumbnails per participant" `
@(
"src/TeamsISO.Engine/Pipeline/IsoPipeline.cs",
"src/TeamsISO.Engine/Controller/IIsoController.cs",
"src/TeamsISO.Engine/Controller/IsoController.cs",
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"src/TeamsISO.App/TeamsISO.App.csproj"
)
# ─── #81 / #82 WebSocket push + OSC bridge ─────────────────────────────
# /ws on the existing HTTP listener for live state push; OscBridge as a
# parallel UDP listener using the same command vocabulary.
Stage-AndCommit `
"feat: WebSocket live-state push + OSC bridge" `
@(
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
"src/TeamsISO.App/Services/OscBridge.cs",
"src/TeamsISO.App/App.xaml.cs",
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"docs/CONTROL-SURFACE.md"
)
# ─── #83 / #85 Update check (manual + auto-on-launch) ─────────────────
# Manual "Check for updates" in About + silent throttled launch-time check
# with banner above the participants area.
Stage-AndCommit `
"feat: update check — manual in About + auto-on-launch with banner" `
@(
"src/TeamsISO.App/Services/UpdateChecker.cs",
"src/TeamsISO.App/AboutWindow.xaml",
"src/TeamsISO.App/AboutWindow.xaml.cs",
"src/TeamsISO.App/ViewModels/UpdateBannerViewModel.cs",
"src/TeamsISO.App/App.xaml.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml"
)
# ─── #86 Preset import / export ────────────────────────────────────────
# OperatorPresetStore.ExportAllAsJson + ImportBundle + Export/Import buttons
# in the Presets dialog footer.
Stage-AndCommit `
"feat: preset import / export bundles" `
@(
"src/TeamsISO.App/Services/OperatorPresetStore.cs",
"src/TeamsISO.App/PresetsDialog.xaml",
"src/TeamsISO.App/PresetsDialog.xaml.cs"
)
# ─── #87 Recording markers ─────────────────────────────────────────────
# IRecorderSink.AddMarker fan-out via IIsoController.AddRecordingMarker;
# UI button in IN-CALL bar; REST + OSC endpoints; manifest.json gets
# markers[] array.
Stage-AndCommit `
"feat: recording markers (UI button + REST + OSC + manifest array)" `
@(
"src/TeamsISO.Engine/Pipeline/IRecorderSink.cs",
"src/TeamsISO.Engine/Pipeline/RawBgraRecorderSink.cs",
"src/TeamsISO.Engine/Controller/IIsoController.cs",
"src/TeamsISO.Engine/Controller/IsoController.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
"src/TeamsISO.App/Services/OscBridge.cs",
"docs/CONTROL-SURFACE.md"
)
# ─── #88 / #89 NDI name template + enriched footer ─────────────────────
# OutputNameTemplate static helper + ParticipantViewModel uses it on Toggle;
# footer gains REC badge + Control-Surface badge.
Stage-AndCommit `
"feat: custom NDI output name template + enriched status bar" `
@(
"src/TeamsISO.App/Services/OutputNameTemplate.cs",
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml"
)
# ─── #90 / #91 Disk space watcher + diagnostics bundle ─────────────────
Stage-AndCommit `
"feat: disk space watcher + diagnostic bundle export" `
@(
"src/TeamsISO.App/Services/DiskSpaceWatcher.cs",
"src/TeamsISO.App/Services/DiagnosticsBundle.cs",
"src/TeamsISO.App/App.xaml.cs",
"src/TeamsISO.App/AboutWindow.xaml",
"src/TeamsISO.App/AboutWindow.xaml.cs"
)
# ─── #92 Per-participant recording opt-out ─────────────────────────────
# IsoController.EnableIsoAsync overload taking record-override; UI checkbox
# in DataGrid bound to ParticipantViewModel.RecordToDisk.
Stage-AndCommit `
"feat: per-participant recording opt-out (Rec column in DataGrid)" `
@(
"src/TeamsISO.Engine/Controller/IIsoController.cs",
"src/TeamsISO.Engine/Controller/IsoController.cs",
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml"
)
# ─── #93 / #94 Keyboard shortcuts + help cheat sheet ───────────────────
# F1 / Ctrl+M / Ctrl+Shift+S / Ctrl+R InputBindings + HelpWindow dialog.
Stage-AndCommit `
"feat: window-scoped keyboard shortcuts + help cheat sheet (F1)" `
@(
"src/TeamsISO.App/HelpWindow.xaml",
"src/TeamsISO.App/HelpWindow.xaml.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml"
)
# ─── #95 / #96 / #97 Bulk enable + filter + context menu ───────────────
# EnableAllOnlineCommand, ParticipantsView with live filter, right-click
# context menu on DataGrid rows.
Stage-AndCommit `
"feat: bulk enable + participant filter + right-click context menu" `
@(
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml"
)
# ─── #98 / #99 / #100 / #101 / #102 Operator polish batch ─────────────
# --apply-preset CLI, dynamic status with live counts, embedded HTML panel
# at /ui, session timer in footer, NotesService + REST/OSC notes endpoint.
Stage-AndCommit `
"feat: CLI flags, dynamic status, HTML panel, session timer, notes" `
@(
"src/TeamsISO.App/App.xaml.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
"src/TeamsISO.App/Services/ControlPanelHtml.cs",
"src/TeamsISO.App/Services/OscBridge.cs",
"src/TeamsISO.App/Services/NotesService.cs",
"docs/CONTROL-SURFACE.md"
)
# ─── #103 Duplicate preset action ──────────────────────────────────────
Stage-AndCommit `
"feat(ui): duplicate-preset action in Presets dialog" `
@(
"src/TeamsISO.App/PresetsDialog.xaml",
"src/TeamsISO.App/PresetsDialog.xaml.cs"
)
# ─── #104 CHANGELOG.md ─────────────────────────────────────────────────
Stage-AndCommit `
"docs: add CHANGELOG.md tracking the May 2026 batch" `
@(
"CHANGELOG.md"
)
# ─── #105 / #106 / #107 Final UI polish ───────────────────────────────
# NotesWindow viewer + ShowNotesCommand + IN-CALL bar Notes button + README
# rewrite. Confirm-before-Stop-All (catches mid-show misclicks).
# About dialog gained "Logs / Recordings / Notes" folder shortcut buttons.
Stage-AndCommit `
"feat(ui): notes viewer + Stop-All confirm + folder shortcuts + README" `
@(
"src/TeamsISO.App/NotesWindow.xaml",
"src/TeamsISO.App/NotesWindow.xaml.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"src/TeamsISO.App/AboutWindow.xaml",
"src/TeamsISO.App/AboutWindow.xaml.cs",
"README.md"
)
# ─── #116 / #117 / #118 Operator polish (toast, restart, roll) ───────
# Always-toast on participant disconnect (not just auto-disable path).
# Per-pipeline "Restart this ISO" right-click action.
# "Roll recording" via UI command + REST /recording/roll + OSC.
Stage-AndCommit `
"feat(ui+control): disconnect toast, per-pipeline restart, roll recording" `
@(
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
"src/TeamsISO.App/Services/OscBridge.cs",
"docs/CONTROL-SURFACE.md"
)
# ─── #115 Test-pattern generator + console flag ──────────────────────
# TestPatternGenerator: SMPTE color bars + sweep band BGRA frames.
# TeamsISO.Console --test-pattern broadcasts TEAMSISO_TEST at 720p30.
# Useful for verifying NDI runtime without Teams running.
Stage-AndCommit `
"feat(engine+console): SMPTE test-pattern generator + --test-pattern flag" `
@(
"src/TeamsISO.Engine/Pipeline/TestPatternGenerator.cs",
"src/TeamsISO.Console/Program.cs",
"src/tests/TeamsISO.Engine.Tests/Pipeline/TestPatternGeneratorTests.cs"
)
# ─── #114 / #119 Tray icon + WinForms/WPF disambiguation ─────────────
# Adds System.Windows.Forms via UseWindowsForms=true for NotifyIcon.
# GlobalUsings.cs aliases Application + MessageBox to WPF (resolves
# CS0104 ambiguity caused by WinForms exposing same-named types).
# ControlSurfaceServer.cs gained explicit `using System.IO;` (implicit
# usings shifted with UseWindowsForms).
Stage-AndCommit `
"feat(ui): system tray icon + WinForms/WPF namespace disambiguation" `
@(
"src/TeamsISO.App/Services/TrayIconHost.cs",
"src/TeamsISO.App/Services/UIPreferences.cs",
"src/TeamsISO.App/App.xaml.cs",
"src/TeamsISO.App/TeamsISO.App.csproj",
"src/TeamsISO.App/GlobalUsings.cs",
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml"
)
# ─── #76 / #74 / #112 Tests + audio meter scaffold + MF recorder ─────
# OperatorPresetStore + OutputNameTemplate + OscMessage tests in a new
# net8.0-windows test project. Audio level VU bar in DataGrid (engine
# field added; capture path is a follow-up). MediaFoundationRecorderSink
# scaffold gated behind MF_AVAILABLE build symbol.
Stage-AndCommit `
"test+feat: App.Tests project + audio VU scaffold + MF recorder stub" `
@(
"src/tests/TeamsISO.App.Tests/TeamsISO.App.Tests.csproj",
"src/tests/TeamsISO.App.Tests/Services/OperatorPresetStoreTests.cs",
"src/tests/TeamsISO.App.Tests/Services/OutputNameTemplateTests.cs",
"src/tests/TeamsISO.App.Tests/Services/OscMessageTests.cs",
"src/TeamsISO.App/Services/OperatorPresetStore.cs",
"src/TeamsISO.App/TeamsISO.App.csproj",
"src/TeamsISO.Engine/Domain/IsoHealthStats.cs",
"src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs",
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"TeamsISO.sln",
"TeamsISO.Windows.slnf",
"docs/REAL-TIME-RECORDING.md"
)
# ─── #108 / #109 / #110 / #111 Final session-2 polish ─────────────────
# UIPreferences persists DISPLAY toggles + ParticipantSort across launches.
# PreviewWindow non-modal floating preview at 20Hz for multi-monitor.
# Configurable participant sort order via ICollectionView.SortDescriptions.
# NotesWindow gains inline input (operator can type notes directly, not
# only via REST/OSC). HTML control panel gains a "Note…" button. Richer
# GET / response. Updated CHANGELOG + README to reflect all of session 2.
Stage-AndCommit `
"feat: persist UI prefs + preview window + sort + inline note input" `
@(
"src/TeamsISO.App/Services/UIPreferences.cs",
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
"src/TeamsISO.App/Services/ControlPanelHtml.cs",
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/PreviewWindow.xaml",
"src/TeamsISO.App/PreviewWindow.xaml.cs",
"src/TeamsISO.App/NotesWindow.xaml",
"src/TeamsISO.App/NotesWindow.xaml.cs",
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"README.md",
"CHANGELOG.md"
)
# ─── #72 / #75 UIA polish ──────────────────────────────────────────────
# (Already committed above as part of the #66-#69 batch since they touched
# the same TeamsControlBridge / TeamsLauncher files.)
# ─── docs ───────────────────────────────────────────────────────────────
Stage-AndCommit `
"docs: refresh _NEXT.md after recording + control surface" `
@(
"docs/superpowers/plans/_NEXT.md"
)
# ─── Push ───────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "──── Pushing to origin/main ────" -ForegroundColor Cyan
git push origin main
if ($LASTEXITCODE -ne 0) { throw "git push failed" }
Write-Host ""
Write-Host "──── Pushing $branch to origin ────" -ForegroundColor Cyan
git status --short
$ahead = (git rev-list --count "origin/$branch..HEAD" 2>$null)
if (-not $ahead) { $ahead = (git rev-list --count HEAD).Trim() }
Write-Host " $ahead commit(s) to push." -ForegroundColor DarkGray
git push origin $branch
if ($LASTEXITCODE -ne 0) { throw "git push failed." }
Write-Host ""
Write-Host "Done. Pushed $branch to origin." -ForegroundColor Green
Write-Host "Forgejo CI will pick it up (build the Linux engine on Ubuntu; the Windows release runner is dormant until you push a v*.*.* tag)." -ForegroundColor DarkGray
Write-Host "Done. Commits pushed to forge.wilddragon.net/zgaetano/teamsiso." -ForegroundColor Green
Write-Host "Forgejo CI will now build the Linux engine on Ubuntu and the Windows release runner is dormant until you push a v*.*.* tag." -ForegroundColor DarkGray

View file

@ -1,250 +0,0 @@
using System.IO;
using System.Windows;
using System.Windows.Interop;
using Microsoft.Extensions.Logging;
using TeamsISO.App.Services;
using TeamsISO.App.ViewModels;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Interop;
using TeamsISO.Engine.NdiInterop;
using TeamsISO.Engine.Persistence;
using TeamsISO.Engine.Pipeline;
namespace TeamsISO.App;
// Linear bootstrap steps that OnStartup walks through, extracted so the
// main file reads as a wiring pipeline rather than a single 200-line
// procedure. Each method here either does its own work or returns a
// signal (bool / nullable) so OnStartup can bail early on failure.
public partial class App
{
/// <summary>
/// Acquire the per-user named mutex that gates a single TeamsISO
/// instance per Windows user. Two TeamsISOs on the same machine for
/// the same user race over the NDI finder, the NDI senders, and
/// %APPDATA%\TeamsISO\config.json — none of those are safe to share.
///
/// On loss: broadcast the bring-to-front message to wake the existing
/// instance and signal the caller to <see cref="Application.Shutdown(int)"/>
/// silently. On win: install the message-pump filter so subsequent
/// duplicate launches can surface us.
/// </summary>
/// <returns>true if this is the first instance; false if we should exit.</returns>
private bool TryAcquireSingleInstance()
{
_singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true, SingleInstanceMutexName, out var createdNew);
_ownsSingleInstanceMutex = createdNew;
if (!createdNew)
{
var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
if (bringToFront != 0)
SendNotifyMessageW(HWND_BROADCAST, bringToFront, IntPtr.Zero, IntPtr.Zero);
return false;
}
// We're the first instance. Install the message-pump filter so a
// *subsequent* launch that broadcasts our bring-to-front message
// surfaces our window. Hold the delegate in a field so OnExit can
// unsubscribe cleanly (ComponentDispatcher is process-static).
var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
_bringToFrontHandler = (ref MSG msg, ref bool handled) =>
{
if (msg.message == (int)bringToFrontMsg && MainWindow is not null)
{
if (MainWindow.WindowState == WindowState.Minimized) MainWindow.WindowState = WindowState.Normal;
MainWindow.Activate();
MainWindow.Topmost = true;
MainWindow.Topmost = false;
handled = true;
}
};
ComponentDispatcher.ThreadFilterMessage += _bringToFrontHandler;
return true;
}
/// <summary>
/// Initialize the NDI interop layer. On failure (most commonly: NDI
/// Runtime isn't installed), show the operator a "go to ndi.video/tools"
/// dialog and signal a clean shutdown. The boolean return is checked
/// by OnStartup so we don't continue past a broken NDI host.
/// </summary>
/// <returns>true on success; false if OnStartup should Shutdown(2).</returns>
private bool TryBootstrapNdiInterop()
{
if (_loggerFactory is null) return false;
try
{
_interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger<NdiInteropPInvoke>());
return true;
}
catch (Exception ex)
{
MessageBox.Show(
"TeamsISO could not initialize the NDI runtime.\n\n" +
"Install the NDI Runtime from https://ndi.video/tools/ and try again.\n\n" +
"Details: " + ex.Message,
"TeamsISO — NDI runtime missing",
MessageBoxButton.OK,
MessageBoxImage.Error);
return false;
}
}
/// <summary>
/// Wire the engine: configstore, NDI runtime probe, frame scaler,
/// pipeline factory, IsoController. Doesn't start the engine — that's
/// MainViewModel.InitializeAsync's job.
/// </summary>
private void BootstrapEngine()
{
if (_loggerFactory is null || _interop is null) return;
var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"TeamsISO", "config.json");
var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger<ConfigStore>());
var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix);
var scaler = new ManagedNearestNeighborFrameScaler();
var loggerFactoryRef = _loggerFactory;
var interopRef = _interop;
IsoPipeline PipelineFactory(IsoPipelineConfig config)
{
var clock = new PeriodicTimerFrameClock(config.Settings.FramerateHz);
return new IsoPipeline(
config, interopRef, scaler, clock,
ExponentialBackoff.Default,
(delay, ct) => Task.Delay(delay, ct),
loggerFactoryRef);
}
_controller = new IsoController(
_interop, PipelineFactory, configStore, probe, _loggerFactory);
}
/// <summary>
/// Construct the view-model, the main window, and show it. After this
/// returns, <see cref="Application.MainWindow"/> is non-null and the
/// window is on screen.
/// </summary>
private MainWindow ConstructAndShowMainWindow()
{
_viewModel = new MainViewModel(_controller!, Dispatcher);
var window = new MainWindow(_viewModel);
window.Show();
MainWindow = window;
return window;
}
/// <summary>
/// REST + WebSocket control surface for Stream Deck / Companion and
/// the OSC bridge. Created always; only Started if the operator had
/// the toggle on in the previous session (the settings VM's setter
/// handles the in-session flip path). Failures log + toast — we don't
/// want a port-bind error to block app start.
/// </summary>
private void BootstrapControlSurfaceServices()
{
if (_controller is null || _viewModel is null || _loggerFactory is null) return;
_controlSurface = new ControlSurfaceServer(
_controller,
() => _viewModel,
_loggerFactory.CreateLogger<ControlSurfaceServer>());
_oscBridge = new OscBridge(
_controller,
() => _viewModel,
_loggerFactory.CreateLogger<OscBridge>());
if (_viewModel.Settings.ControlSurfaceEnabled)
{
try
{
_controlSurface.Start(
_viewModel.Settings.ControlSurfacePort,
_viewModel.Settings.ControlSurfaceLanReachable);
}
catch (Exception ex)
{
_loggerFactory.CreateLogger<App>().LogWarning(ex,
"Control surface auto-start failed; operator can retry via Settings.");
}
}
}
/// <summary>
/// Tray-icon host. Hosting from App (not MainWindow) ensures icon
/// lifetime matches the process, so the icon stays visible during a
/// minimize-to-tray (when MainWindow is hidden).
/// </summary>
private void BootstrapTrayIcon(MainWindow window)
{
if (_viewModel is null) return;
_trayIcon = new TrayIconHost(window)
{
Enabled = _viewModel.Settings.MinimizeToTray,
};
}
/// <summary>
/// First-launch onboarding dialog. Shown AFTER MainWindow so it has
/// a sensible Owner for centering + z-order. Suppressed forever once
/// the user dismisses with the checkbox checked.
/// </summary>
private static void TryShowOnboarding(MainWindow window)
{
if (!OnboardingWindow.ShouldShow()) return;
try
{
var onboarding = new OnboardingWindow { Owner = window };
onboarding.ShowDialog();
}
catch
{
// Defensive: an onboarding-dialog failure should never block startup.
}
}
/// <summary>
/// Auto-launch Teams in the background if the operator opted in.
/// Combined with AutoHideTeamsWindows this gives the "I only see
/// TeamsISO" experience. Fire-and-forget — a slow Teams launch must
/// not delay TeamsISO's own window from appearing.
/// </summary>
private void TryAutoLaunchTeams(ILogger logger)
{
if (_viewModel is null) return;
var settings = _viewModel.Settings;
if (settings.LaunchTeamsOnStartup && !TeamsLauncher.IsRunning())
{
_ = Task.Run(() =>
{
try
{
if (TeamsLauncher.TryLaunch(out var launchError))
{
if (settings.AutoHideTeamsWindows)
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
}
else
{
logger.LogWarning("Auto-launch Teams on startup failed: {Error}", launchError);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Auto-launch Teams on startup threw");
}
});
}
else if (settings.AutoHideTeamsWindows && TeamsLauncher.IsRunning())
{
// Teams is already up from a previous session. If auto-hide is
// on, hide it now so the operator's "I only see TeamsISO" rule
// applies even when Teams was launched externally.
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
}
}
}

View file

@ -1,93 +0,0 @@
using System.IO;
using System.Windows;
using System.Windows.Threading;
using Microsoft.Extensions.Logging;
namespace TeamsISO.App;
// Crash diagnostics — the three exception channels WPF leaves open by
// default, wired to a single handler that logs Fatal to Serilog (rolling
// daily file at %LOCALAPPDATA%\TeamsISO\Logs) and then shows the user a
// dialog with the log path so they can attach it to a bug report.
//
// We deliberately don't catch StackOverflowException or
// ExecutionEngineException — both are uncatchable in modern .NET; if one
// fires the OS Watson dialog takes it from here.
public partial class App
{
/// <summary>
/// Where the rolling Serilog file sink writes. Reused by the crash
/// dialog so we can show the user the exact directory to attach when
/// filing a bug.
/// </summary>
private static string LogDirectory =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Logs");
private void OnAppDomainUnhandled(object sender, UnhandledExceptionEventArgs e)
{
// IsTerminating is almost always true here — finalizers and
// managed-thread top-frames don't have a graceful path back. Log
// + show a dialog inline since the process will exit either way.
var ex = e.ExceptionObject as Exception;
TryLogFatal("AppDomain.UnhandledException", ex);
TryShowCrashDialog(ex, terminating: e.IsTerminating);
}
private void OnDispatcherUnhandled(object sender, DispatcherUnhandledExceptionEventArgs e)
{
TryLogFatal("Dispatcher.UnhandledException", e.Exception);
TryShowCrashDialog(e.Exception, terminating: false);
// Mark Handled so a single bad UI thunk doesn't take the whole app
// down — the user has the dialog and the log; they can choose to
// keep going.
e.Handled = true;
}
private void OnUnobservedTaskException(object? sender, System.Threading.Tasks.UnobservedTaskExceptionEventArgs e)
{
TryLogFatal("TaskScheduler.UnobservedTaskException", e.Exception);
// Don't show a dialog here — these fire from the finalizer thread
// and tend to be cleanup-time noise, not user-actionable. Log only.
e.SetObserved();
}
private void TryLogFatal(string source, Exception? ex)
{
try
{
var logger = _loggerFactory?.CreateLogger<App>();
logger?.LogCritical(ex, "{Source} fired", source);
}
catch
{
// Logger itself failed (rare — disk full, permission denied).
// Swallow: nothing useful to do, and re-throwing during crash
// handling makes things worse.
}
}
private static void TryShowCrashDialog(Exception? ex, bool terminating)
{
try
{
var heading = terminating
? "TeamsISO encountered an unrecoverable error and will exit."
: "TeamsISO encountered an error.";
var details = ex?.GetType().Name + ": " + (ex?.Message ?? "(no details)");
var body =
heading + "\n\n" +
details + "\n\n" +
$"A full diagnostic log has been written to:\n{LogDirectory}\n\n" +
"Attach the most recent file from that directory to your bug report.";
MessageBox.Show(body, "TeamsISO — Error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
catch
{
// Even the dialog failed (e.g., during shutdown when the
// message pump is already gone). Nothing more to do.
}
}
}

View file

@ -1,42 +0,0 @@
using Microsoft.Extensions.Logging;
using TeamsISO.App.Services;
namespace TeamsISO.App;
// Background update check, throttled to once per 24h. Fire-and-forget
// so a slow / offline update server never delays startup. Surfaces a
// banner via UpdateBanner if newer; failures just log.
public partial class App
{
/// <summary>
/// Kick off the launch-time update check if the operator hasn't opted
/// out via the flag file. Called from OnStartup right after the engine
/// + view-model are live. Returns immediately; the actual HTTP call
/// runs on a worker.
/// </summary>
private void StartBackgroundUpdateCheck(ILogger logger)
{
if (!UpdateChecker.LaunchCheckEnabled) return;
if (_viewModel is null) return;
var vm = _viewModel;
_ = Task.Run(async () =>
{
try
{
var result = await UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
if (result?.Status == UpdateChecker.UpdateStatus.UpdateAvailable
&& !string.IsNullOrEmpty(result.LatestTag)
&& !string.IsNullOrEmpty(result.CurrentVersion))
{
await Dispatcher.InvokeAsync(() =>
vm.UpdateBanner.Show(result.CurrentVersion!, result.LatestTag!));
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Background update check failed");
}
});
}
}

View file

@ -1,29 +1,22 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Threading;
using Microsoft.Extensions.Logging;
using TeamsISO.App.ViewModels;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Interop;
using TeamsISO.Engine.Logging;
using TeamsISO.Engine.NdiInterop;
using TeamsISO.Engine.Persistence;
using TeamsISO.Engine.Pipeline;
// Application + MessageBox aliases live in GlobalUsings.cs (project-wide).
// Don't redeclare here — Roslyn errors with CS1537 on duplicate alias.
namespace TeamsISO.App;
// Split across partial files by responsibility:
// • App.xaml.cs — class skeleton, OnStartup (the wiring
// pipeline that calls into the partials),
// OnExit, CLI arg parser.
// • App.Bootstrap.cs — the linear setup steps OnStartup walks
// (single-instance gate, NDI interop, engine,
// main window, control surface, tray icon,
// onboarding, Teams auto-launch).
// • App.CrashHandlers.cs — AppDomain / Dispatcher / Task exception
// handlers + crash dialog + LogDirectory.
// • App.UpdateCheckBootstrap.cs — the background update-checker
// kickoff (24h-throttled).
public partial class App : Application
{
/// <summary>
@ -89,20 +82,45 @@ public partial class App : Application
// in place; DynamicResource refs in WildDragonTheme.xaml re-bind.
TeamsISO.App.Services.ThemeManager.Current.Apply();
// Single-instance gate. Implementation in App.Bootstrap.cs; we
// bail silently if another instance already owns the mutex (the
// existing instance gets surfaced via the bring-to-front broadcast).
if (!TryAcquireSingleInstance())
// Single-instance gate: if another TeamsISO is already running for this user,
// broadcast the bring-to-front message and exit silently. This prevents the
// NDI/config contention seen during testing where two finders, two senders
// with the same default name, and two writers to config.json all raced.
bool createdNew;
_singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true, SingleInstanceMutexName, out createdNew);
_ownsSingleInstanceMutex = createdNew;
if (!createdNew)
{
var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
if (bringToFront != 0)
SendNotifyMessageW(HWND_BROADCAST, bringToFront, IntPtr.Zero, IntPtr.Zero);
Shutdown(0);
return;
}
// Listen for the broadcast — if a *new* instance launches and finds us already
// running, it'll send this message; we surface our window in response. Hold the
// delegate in a field so OnExit can unsubscribe cleanly even though the
// AppDomain teardown would also drop it.
var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
_bringToFrontHandler = (ref System.Windows.Interop.MSG msg, ref bool handled) =>
{
if (msg.message == (int)bringToFrontMsg && MainWindow is not null)
{
if (MainWindow.WindowState == WindowState.Minimized) MainWindow.WindowState = WindowState.Normal;
MainWindow.Activate();
MainWindow.Topmost = true;
MainWindow.Topmost = false;
handled = true;
}
};
ComponentDispatcher.ThreadFilterMessage += _bringToFrontHandler;
try
{
// WPF host: write to both console (visible if attached) and a
// rolling daily file under %LOCALAPPDATA%\TeamsISO\Logs so users
// have something to grab when they file an issue.
// WPF host: write to both console (visible if attached) and a rolling daily
// file under %LOCALAPPDATA%\TeamsISO\Logs so users have something to grab when
// they file an issue.
_loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
var logger = _loggerFactory.CreateLogger<App>();
logger.LogInformation(
@ -110,26 +128,180 @@ public partial class App : Application
typeof(App).Assembly.GetName().Version,
Environment.ProcessId);
if (!TryBootstrapNdiInterop())
// ---- Preflight: NDI runtime ----
try
{
_interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger<NdiInteropPInvoke>());
}
catch (Exception ex)
{
MessageBox.Show(
"TeamsISO could not initialize the NDI runtime.\n\n" +
"Install the NDI Runtime from https://ndi.video/tools/ and try again.\n\n" +
"Details: " + ex.Message,
"TeamsISO — NDI runtime missing",
MessageBoxButton.OK,
MessageBoxImage.Error);
Shutdown(2);
return;
}
BootstrapEngine();
var window = ConstructAndShowMainWindow();
BootstrapControlSurfaceServices();
BootstrapTrayIcon(window);
TryShowOnboarding(window);
// ---- Engine wiring ----
var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"TeamsISO", "config.json");
var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger<ConfigStore>());
// Parse CLI args BEFORE InitializeAsync so any --apply-preset
// request overrides the persisted auto-apply preference cleanly.
var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix);
var scaler = new ManagedNearestNeighborFrameScaler();
var loggerFactoryRef = _loggerFactory;
var interopRef = _interop;
IsoPipeline PipelineFactory(IsoPipelineConfig config)
{
var clock = new PeriodicTimerFrameClock(config.Settings.FramerateHz);
return new IsoPipeline(
config, interopRef, scaler, clock,
ExponentialBackoff.Default,
(delay, ct) => Task.Delay(delay, ct),
loggerFactoryRef);
}
_controller = new IsoController(
_interop, PipelineFactory, configStore, probe, _loggerFactory);
_viewModel = new MainViewModel(_controller, Dispatcher);
var window = new MainWindow(_viewModel);
window.Show();
MainWindow = window;
// REST control surface for Stream Deck / Companion. Off by default —
// operators turn it on via the DISPLAY tab. When the toggle flips,
// GlobalSettingsViewModel reaches into App.Current to start/stop it.
_controlSurface = new TeamsISO.App.Services.ControlSurfaceServer(
_controller,
() => _viewModel,
_loggerFactory.CreateLogger<TeamsISO.App.Services.ControlSurfaceServer>());
_oscBridge = new TeamsISO.App.Services.OscBridge(
_controller,
() => _viewModel,
_loggerFactory.CreateLogger<TeamsISO.App.Services.OscBridge>());
// Auto-start the REST + WebSocket control surface if the operator
// turned it on in a previous session. The settings VM's setter
// also calls Start when the operator toggles it during a session;
// this block covers the "restart the app, expect it still on" case.
if (_viewModel.Settings.ControlSurfaceEnabled)
{
try
{
_controlSurface.Start(
_viewModel.Settings.ControlSurfacePort,
_viewModel.Settings.ControlSurfaceLanReachable);
}
catch (Exception ex)
{
_loggerFactory.CreateLogger<App>().LogWarning(ex,
"Control surface auto-start failed; operator can retry via Settings.");
}
}
// DiskSpaceWatcher removed alongside the rest of the recording surface.
// Tray icon host. Disabled by default; the settings VM flips
// Enabled when the operator toggles the DISPLAY checkbox. Hosting
// it from App ensures the icon's lifetime matches the process,
// not the main window (which gets hidden during minimize-to-tray).
_trayIcon = new TeamsISO.App.Services.TrayIconHost(window)
{
Enabled = _viewModel.Settings.MinimizeToTray,
};
// First-launch onboarding. The dialog explains the once-per-machine
// setup (NDI runtime, Teams admin permission, transcoder topology)
// that the UI alone can't communicate clearly. Suppressed after the
// user dismisses it with the checkbox checked. We show it AFTER the
// main window so the dialog has a sensible Owner for centering and
// z-order.
if (OnboardingWindow.ShouldShow())
{
try
{
var onboarding = new OnboardingWindow { Owner = window };
onboarding.ShowDialog();
}
catch
{
// Defensive: an onboarding-dialog failure should never block startup.
}
}
// Parse CLI args BEFORE InitializeAsync so any --apply-preset request
// overrides the persisted auto-apply preference cleanly.
ApplyCommandLineArgs(e.Args);
await _viewModel!.InitializeAsync(CancellationToken.None);
await _viewModel.InitializeAsync(CancellationToken.None);
TryAutoLaunchTeams(logger);
StartBackgroundUpdateCheck(logger);
// Auto-launch Teams in the background if the operator has opted in.
// Combined with AutoHideTeamsWindows this gives the "I only see
// TeamsISO" experience — Teams runs but never appears on screen,
// and all interaction routes through the IN-CALL bar + participants
// DataGrid. Fire-and-forget so a slow Teams launch doesn't delay
// TeamsISO's window from appearing.
if (_viewModel.Settings.LaunchTeamsOnStartup && !Services.TeamsLauncher.IsRunning())
{
_ = Task.Run(() =>
{
try
{
if (Services.TeamsLauncher.TryLaunch(out var launchError))
{
if (_viewModel.Settings.AutoHideTeamsWindows)
_ = Services.TeamsLauncher.AutoHideAfterLaunchAsync();
}
else
{
logger.LogWarning("Auto-launch Teams on startup failed: {Error}", launchError);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Auto-launch Teams on startup threw");
}
});
}
else if (_viewModel.Settings.AutoHideTeamsWindows && Services.TeamsLauncher.IsRunning())
{
// Teams is already up from a previous session. If auto-hide is
// on, hide it now so the operator's "I only see TeamsISO" rule
// applies even when Teams was launched externally.
_ = Services.TeamsLauncher.AutoHideAfterLaunchAsync();
}
// Background update check, throttled to once per 24h. Fire-and-forget
// so a slow / offline update server never delays startup. Surfaces a
// banner via UpdateBanner if newer; failures just log.
if (Services.UpdateChecker.LaunchCheckEnabled)
{
_ = Task.Run(async () =>
{
try
{
var result = await Services.UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
if (result?.Status == Services.UpdateChecker.UpdateStatus.UpdateAvailable
&& !string.IsNullOrEmpty(result.LatestTag)
&& !string.IsNullOrEmpty(result.CurrentVersion))
{
await Dispatcher.InvokeAsync(() =>
_viewModel.UpdateBanner.Show(result.CurrentVersion!, result.LatestTag!));
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Background update check failed");
}
});
}
}
catch (Exception ex)
{
@ -149,6 +321,15 @@ public partial class App : Application
}
}
/// <summary>
/// Where the rolling Serilog file sink writes. Reused by the crash dialog so we
/// can show the user the exact directory to attach when filing a bug.
/// </summary>
private static string LogDirectory =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Logs");
/// <summary>
/// Parse the supported CLI flags. Currently:
/// <c>--apply-preset NAME</c> — apply the named preset once participants
@ -175,9 +356,70 @@ public partial class App : Application
}
}
// Crash handlers (OnAppDomainUnhandled / OnDispatcherUnhandled /
// OnUnobservedTaskException / TryLogFatal / TryShowCrashDialog / LogDirectory)
// live in App.CrashHandlers.cs.
private void OnAppDomainUnhandled(object sender, UnhandledExceptionEventArgs e)
{
// IsTerminating is almost always true here — finalizers and managed-thread
// top-frames don't have a graceful path back. Log + show a dialog inline
// since the process will exit either way.
var ex = e.ExceptionObject as Exception;
TryLogFatal("AppDomain.UnhandledException", ex);
TryShowCrashDialog(ex, terminating: e.IsTerminating);
}
private void OnDispatcherUnhandled(object sender, DispatcherUnhandledExceptionEventArgs e)
{
TryLogFatal("Dispatcher.UnhandledException", e.Exception);
TryShowCrashDialog(e.Exception, terminating: false);
// Mark Handled so a single bad UI thunk doesn't take the whole app down —
// the user has the dialog and the log; they can choose to keep going.
e.Handled = true;
}
private void OnUnobservedTaskException(object? sender, System.Threading.Tasks.UnobservedTaskExceptionEventArgs e)
{
TryLogFatal("TaskScheduler.UnobservedTaskException", e.Exception);
// Don't show a dialog here — these fire from the finalizer thread and
// tend to be cleanup-time noise, not user-actionable. Log only.
e.SetObserved();
}
private void TryLogFatal(string source, Exception? ex)
{
try
{
var logger = _loggerFactory?.CreateLogger<App>();
logger?.LogCritical(ex, "{Source} fired", source);
}
catch
{
// Logger itself failed (rare — disk full, permission denied). Swallow:
// there's nothing useful we can do, and re-throwing during crash
// handling makes things worse.
}
}
private static void TryShowCrashDialog(Exception? ex, bool terminating)
{
try
{
var heading = terminating
? "TeamsISO encountered an unrecoverable error and will exit."
: "TeamsISO encountered an error.";
var details = ex?.GetType().Name + ": " + (ex?.Message ?? "(no details)");
var body =
heading + "\n\n" +
details + "\n\n" +
$"A full diagnostic log has been written to:\n{LogDirectory}\n\n" +
"Attach the most recent file from that directory to your bug report.";
MessageBox.Show(body, "TeamsISO — Error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
catch
{
// Even the dialog failed (e.g., during shutdown when the message pump
// is already gone). Nothing more to do.
}
}
protected override async void OnExit(ExitEventArgs e)
{

View file

@ -39,7 +39,6 @@
FalseValue="Visible"/>
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
<conv:LevelThresholdConverter x:Key="LevelGate"/>
<conv:CountToVisibilityConverter x:Key="CountToVis"/>
</Window.Resources>
<Window.InputBindings>
@ -139,7 +138,7 @@
Command="{Binding ToggleThemeCommand}"
Padding="6,4"
Margin="0,0,2,0"
ToolTip="Theme (System / Dark / Light)">
ToolTip="Toggle theme (Ctrl+T)">
<Path Data="M 8,2 C 8,2 4,4 4,8 C 4,12 8,14 8,14 C 5,14 2,11 2,8 C 2,5 5,2 8,2 Z"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.4"
@ -411,33 +410,36 @@
<!--
Participants table — v2 "Studio Terminal" layout.
Spec columns (from docs/shapes/2026-05-13-…studio-terminal.md):
Seven columns (left → right):
1. State LED 24px — 8×8 hard-edged square. Filled cyan
when LIVE; filled coral on ERROR;
filled amber on NO SIGNAL / STARTING;
hollow neutral when OFF.
2. Name + caption * — DisplayName (Inter 13/Medium) plus
2. Preview thumb 106px — 96×54 (16:9) WriteableBitmap fed
from the engine's most recent
ProcessedFrame at the 1Hz stats
tick. Em-dash placeholder when
no pipeline is running.
3. Name + caption * — DisplayName (Inter 13/Medium) plus
source machine + state label below
in JetBrains Mono 11/Tertiary.
3. Audio meter 110px — five vertical hard-edged bars,
4. Audio meter 110px — five vertical hard-edged bars,
each lit when DisplayedAudioLevel
crosses its threshold (0.2, 0.4,
0.6, 0.8, 1.0). No averaging.
4. Output name 130px — JetBrains Mono 12 — the NDI source
0.6, 0.8, 0.95). No averaging.
5. Output name 150px — JetBrains Mono 12 — the NDI source
name TeamsISO broadcasts as.
5. ISO toggle pill 100px — LIVE = cyan-muted fill with cyan
text; OFF = hollow neutral; ERROR
gets the existing trigger swap.
Deliberate deviations from the spec (operator preference, see
4944de5 — "restore live thumbnail preview column"):
• A 106px live thumbnail column sits between State LED and
Name. Replaces the table's previous role as the only place
to see what the operator is broadcasting; the pop-out
preview window is the secondary view.
• A 32px ghost-button cell on the right edge of Name opens
the per-ISO override dialog (framerate / resolution /
aspect / audio). Hidden on hover-out.
6. CFG (gear) 56px — U+2699 glyph button; opens the
per-participant override editor
(framerate / res / aspect / audio).
7. ISO toggle 124px — rounded-rect Radius.M button. LIVE
= cyan-muted fill with cyan text;
OFF = hollow neutral; ERROR gets
the existing trigger swap. Width
124 chosen so 'Enable' and
'● LIVE' don't clip at the right
edge after the corner-radius
change in 25229bd.
Row height 52 (down from 56). Active speaker = full-row
bg.active-speaker tint set by the global DataGridRow style
@ -449,7 +451,6 @@
BorderThickness="1"
CornerRadius="{StaticResource Radius.M}"
Background="{DynamicResource Wd.Surface}">
<Grid>
<DataGrid x:Name="ParticipantsGrid"
ItemsSource="{Binding ParticipantsView}"
AutoGenerateColumns="False"
@ -463,8 +464,7 @@
CanUserResizeRows="False"
SelectionMode="Single"
SelectionUnit="FullRow"
RowHeight="52"
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}}">
RowHeight="52">
<DataGrid.Columns>
<!-- Col 1 — State LED. 8×8 hard-edged Rectangle.
Default fill is hollow (transparent with stroke). DataTriggers
@ -614,7 +614,7 @@
<!-- Col 4 — Output name (mono). The NDI source name TeamsISO
will broadcast this participant as. -->
<DataGridTemplateColumn Header="Output" Width="130" IsReadOnly="True">
<DataGridTemplateColumn Header="Output" Width="150" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding OutputName}"
@ -628,36 +628,43 @@
</DataGridTemplateColumn>
<!-- Col 5a — Per-row gear: opens the ISO override editor for this
participant. Narrow (32px) so the table still fits inside a
1280px window after the toggle column. -->
<DataGridTemplateColumn Header="" Width="32" IsReadOnly="True">
participant. We use the Unicode gear glyph (U+2699) instead
of a custom Path — it renders cleanly at any size, doesn't
disappear against dark rows the way 1.4px strokes do, and
reads as "settings" at a glance. Header is "CFG" so the
affordance is discoverable even when the row hover state
isn't active. -->
<DataGridTemplateColumn Header="CFG" Width="56" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnIsoOverrideClick"
Padding="6,4"
Padding="6,2"
VerticalAlignment="Center"
HorizontalAlignment="Center"
ToolTip="Override output settings for this participant">
<Path Data="M 8,1 L 8,3 M 8,13 L 8,15 M 1,8 L 3,8 M 13,8 L 15,8 M 3,3 L 4.5,4.5 M 11.5,11.5 L 13,13 M 3,13 L 4.5,11.5 M 11.5,4.5 L 13,3 M 8,5.5 C 9.4,5.5 10.5,6.6 10.5,8 C 10.5,9.4 9.4,10.5 8,10.5 C 6.6,10.5 5.5,9.4 5.5,8 C 5.5,6.6 6.6,5.5 8,5.5"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="14" Height="14"
Stretch="None"/>
ToolTip="Override output settings for this participant (framerate, resolution, audio)">
<TextBlock Text="&#x2699;"
FontSize="16"
FontFamily="Segoe UI Symbol"
Foreground="{DynamicResource Wd.Text.Primary}"
VerticalAlignment="Center"
HorizontalAlignment="Center"/>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 5 — ISO toggle pill. LIVE = cyan-muted fill + cyan border + cyan text.
OFF = hollow neutral. Error states use the existing IsoToggle style. -->
<DataGridTemplateColumn Header="ISO" Width="100">
<!-- Col 7 — ISO toggle. LIVE = cyan-muted fill + cyan border + cyan text.
OFF = hollow neutral. Error states use the existing IsoToggle style.
Width 124 (was 110) so the "Enable" / "● LIVE" content has breathing
room inside the rounded-rect — 110 was clipping the label at the
right edge once the IsoToggle stopped being a full pill (25229bd). -->
<DataGridTemplateColumn Header="ISO" Width="124">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Command="{Binding ToggleIsoCommand}"
Margin="0,0,12,0"
Padding="14,6"
Padding="10,6"
VerticalAlignment="Center">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource Wd.Button.IsoToggle}">
@ -678,30 +685,6 @@
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<!-- Empty-state placeholder. Renders when no NDI participants
have been discovered yet. Mono sentence + one tertiary
Refresh button — no illustration, no mascot, per the v2
shape brief's empty-states section. -->
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}, ConverterParameter=empty}">
<TextBlock Text="no ndi sources yet — open teams and start a meeting"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Tertiary}"
HorizontalAlignment="Center"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding RefreshDiscoveryCommand}"
Content="Refresh discovery (Ctrl+R)"
Padding="14,7"
Margin="0,14,0,0"
HorizontalAlignment="Center"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
ToolTip="Rebuild the NDI finder"/>
</StackPanel>
</Grid>
</Border>
</Grid>

View file

@ -42,12 +42,7 @@ public partial class MainWindow : Window
/// <summary>Persist the placement on close so next launch lands in the same spot.</summary>
private void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
{
// A failure persisting window state must NEVER block the window from
// closing — operator's shutdown comes first. WindowStateStore.Save
// already swallows its own IO errors; this is defense-in-depth for
// anything that escapes (NRE, future regression, etc.).
try { WindowStateStore.Save(this); }
catch { /* best-effort: forgo placement memory for one launch */ }
WindowStateStore.Save(this);
}
/// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary>
@ -92,8 +87,8 @@ public partial class MainWindow : Window
if (!TeamsLauncher.IsRunning())
{
MessageBox.Show(
Properties.Strings.HideShowTeams_NotRunning_Message,
Properties.Strings.HideShowTeams_Title,
"Microsoft Teams isn't running. Click the camera icon above to launch it first.",
"TeamsISO — Hide / show Teams",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@ -130,8 +125,8 @@ public partial class MainWindow : Window
if (!TeamsLauncher.TryLaunch(out var error))
{
MessageBox.Show(
string.Format(Properties.Strings.LaunchTeams_Failed_MessageFormat, error),
Properties.Strings.LaunchTeams_Title,
$"Could not launch Microsoft Teams.\n\n{error}",
"TeamsISO — Launch Teams",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
@ -164,19 +159,15 @@ public partial class MainWindow : Window
/// <summary>
/// Right-click on the Launch button asks to stop Teams. Split out from the
/// left-click so a normal click is "open / surface" rather than the previous
/// "open OR ambush you with a stop dialog". The confirmation dialog here is
/// intentional — Stop Teams is a destructive mid-show action; explicit
/// confirmation is the right pattern, not the "ambush" anti-pattern that
/// was fixed for left-click. The palette also offers Stop Teams for
/// keyboard-first operators.
/// "open OR ambush you with a stop dialog".
/// </summary>
private void OnLaunchTeamsRightClick(object sender, MouseButtonEventArgs e)
{
if (!TeamsLauncher.IsRunning()) return;
var confirm = MessageBox.Show(
Properties.Strings.StopTeams_Confirm_Message,
Properties.Strings.StopTeams_Title,
"Microsoft Teams is currently running.\n\nClose all Teams windows now?",
"TeamsISO — Stop Teams",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes) return;
@ -186,9 +177,9 @@ public partial class MainWindow : Window
{
MessageBox.Show(
asked == 0
? Properties.Strings.StopTeams_NoneResponded
: string.Format(Properties.Strings.StopTeams_AskedFormat, asked),
Properties.Strings.StopTeams_Title,
? "No Teams windows responded to close."
: $"Sent close to {asked} Teams window(s); some may still be exiting.",
"TeamsISO — Stop Teams",
MessageBoxButton.OK,
MessageBoxImage.Information);
}

View file

@ -1,36 +0,0 @@
// Hand-written strongly-typed accessor for Properties/Strings.resx. Kept
// out of Visual Studio's "ResXFileCodeGenerator" auto-regeneration loop
// so the .csproj stays simple and the file doesn't churn on every save.
// If you add a key in Strings.resx, add a matching property here.
// The compiler treats `*.Designer.cs` as auto-generated and refuses
// nullable annotations without an explicit directive — opt in.
#nullable enable
using System.Globalization;
using System.Resources;
namespace TeamsISO.App.Properties;
internal static class Strings
{
private static readonly ResourceManager ResourceManager = new(
baseName: "TeamsISO.App.Properties.Strings",
assembly: typeof(Strings).Assembly);
public static CultureInfo? Culture { get; set; }
private static string Get(string key) =>
ResourceManager.GetString(key, Culture) ?? string.Empty;
public static string HideShowTeams_Title => Get(nameof(HideShowTeams_Title));
public static string HideShowTeams_NotRunning_Message => Get(nameof(HideShowTeams_NotRunning_Message));
public static string LaunchTeams_Title => Get(nameof(LaunchTeams_Title));
public static string LaunchTeams_Failed_MessageFormat => Get(nameof(LaunchTeams_Failed_MessageFormat));
public static string StopTeams_Title => Get(nameof(StopTeams_Title));
public static string StopTeams_Confirm_Message => Get(nameof(StopTeams_Confirm_Message));
public static string StopTeams_NoneResponded => Get(nameof(StopTeams_NoneResponded));
public static string StopTeams_AskedFormat => Get(nameof(StopTeams_AskedFormat));
}

View file

@ -1,75 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
User-facing English strings shown by MainWindow's MessageBox prompts.
Pulled out of code-behind so a future localizer has a single seam to
translate. Strings.Designer.cs is a hand-rolled accessor backed by
ResourceManager — no Visual-Studio auto-regeneration needed.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
<resheader name="version"><value>2.0</value></resheader>
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<data name="HideShowTeams_Title" xml:space="preserve">
<value>TeamsISO — Hide / show Teams</value>
</data>
<data name="HideShowTeams_NotRunning_Message" xml:space="preserve">
<value>Microsoft Teams isn't running. Click the camera icon above to launch it first.</value>
</data>
<data name="LaunchTeams_Title" xml:space="preserve">
<value>TeamsISO — Launch Teams</value>
</data>
<data name="LaunchTeams_Failed_MessageFormat" xml:space="preserve">
<value>Could not launch Microsoft Teams.
{0}</value>
<comment>{0} = error string from TeamsLauncher.TryLaunch.</comment>
</data>
<data name="StopTeams_Title" xml:space="preserve">
<value>TeamsISO — Stop Teams</value>
</data>
<data name="StopTeams_Confirm_Message" xml:space="preserve">
<value>Microsoft Teams is currently running.
Close all Teams windows now?</value>
</data>
<data name="StopTeams_NoneResponded" xml:space="preserve">
<value>No Teams windows responded to close.</value>
</data>
<data name="StopTeams_AskedFormat" xml:space="preserve">
<value>Sent close to {0} Teams window(s); some may still be exiting.</value>
<comment>{0} = number of windows the launcher asked to close.</comment>
</data>
</root>

View file

@ -1,49 +0,0 @@
namespace TeamsISO.App.Services;
// GET / — server info + endpoint catalogue. Returned as the JSON
// homepage when a Companion / Stream Deck plugin first probes the
// surface; humans see it via curl http://127.0.0.1:9755/.
public sealed partial class ControlSurfaceServer
{
private object GetServerInfo()
{
// Best-effort engine snapshot — wrapped in TryRead so a transient
// controller error doesn't 500 the homepage poll.
var settings = TryRead(() => _controller.GlobalSettings);
var groups = TryRead(() => _controller.GroupSettings);
return new
{
product = "TeamsISO",
version = typeof(ControlSurfaceServer).Assembly.GetName().Version?.ToString() ?? "unknown",
engine = new
{
framerateHz = settings?.FramerateHz,
targetResolution = settings?.Resolution.ToString(),
aspectMode = settings?.Aspect.ToString(),
audioMode = settings?.Audio.ToString(),
discoveryGroups = groups?.DiscoveryGroups,
outputGroups = groups?.OutputGroups,
},
endpoints = new[]
{
"GET / (this)",
"GET /ui (HTML control panel)",
"GET /participants",
"GET /ws (WebSocket: live participant snapshots)",
"POST /participants/{id}/iso",
"POST /participants/iso (body: displayName + enabled)",
"POST /presets/{name}/apply",
"POST /presets/refresh-discovery",
"POST /presets/stop-all",
"POST /teams/mute, /camera, /leave, /share, /raise-hand",
"POST /notes (body: text)",
},
};
}
private static T? TryRead<T>(Func<T> reader) where T : class
{
try { return reader(); }
catch { return null; }
}
}

View file

@ -1,19 +0,0 @@
using System.Collections.Specialized;
using System.Text.Json;
namespace TeamsISO.App.Services;
// /notes/* route handlers — append-only operator show-notes file.
//
// POST /notes (body: { "text": "..." }) → AppendNote
public sealed partial class ControlSurfaceServer
{
private object AppendNote(JsonElement body, NameValueCollection query)
{
var text = TryGetString(body, query, "text");
if (string.IsNullOrWhiteSpace(text))
return new { ok = false, error = "text required" };
var ok = NotesService.Append(text);
return new { ok, action = "note", path = NotesService.TodayPath };
}
}

View file

@ -1,191 +0,0 @@
using System.Collections.Specialized;
using System.Text.Json;
using TeamsISO.Engine.Domain;
namespace TeamsISO.App.Services;
// /participants/* route handlers. Anything that reads or writes
// participant + per-pipeline state lives here.
//
// GET /participants → GetParticipants
// POST /participants/{id}/iso → ToggleIsoByIdAsync
// POST /participants/iso → ToggleIsoByNameAsync
// POST /participants/{id}/override → SetIsoOverrideByIdAsync
// DELETE /participants/{id}/override → ClearIsoOverrideByIdAsync
public sealed partial class ControlSurfaceServer
{
private object GetParticipants()
{
var vm = _viewModel();
if (vm is null) return new { participants = Array.Empty<object>() };
// Synchronously snapshot on the UI thread — ObservableCollection
// isn't safe to enumerate from this request handler's thread-pool
// task, and the ParticipantViewModel property reads chase
// data-binding state.
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null) return new { participants = Array.Empty<object>() };
var globals = _controller.GlobalSettings;
var list = dispatcher.Invoke(() => vm.Participants.Select(p => {
var ovr = _controller.GetIsoOverride(p.Id);
return (object)new
{
id = p.Id,
displayName = p.DisplayName,
isOnline = p.IsOnline,
isEnabled = p.IsEnabled,
customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName,
stateLabel = p.StateLabel,
// Effective settings = override if set, else globals. The
// web UI uses this to show the current per-row values
// without a separate round-trip to /global.
effective = new
{
framerate = (ovr ?? globals).Framerate.ToString(),
resolution = (ovr ?? globals).Resolution.ToString(),
aspect = (ovr ?? globals).Aspect.ToString(),
audio = (ovr ?? globals).Audio.ToString(),
isOverride = ovr is not null,
},
};
}).ToArray());
return new { participants = list, globals = new {
framerate = globals.Framerate.ToString(),
resolution = globals.Resolution.ToString(),
aspect = globals.Aspect.ToString(),
audio = globals.Audio.ToString(),
} };
}
/// <summary>
/// POST /participants/{id}/override — set or replace the per-pipeline
/// override. Body fields: framerate (enum string), resolution (enum
/// string), aspect (enum string), audio (enum string). All fields are
/// optional; missing fields fall back to the current global value.
/// </summary>
private async Task<object> SetIsoOverrideByIdAsync(string path, JsonElement body)
{
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override")
return new { ok = false, error = "expected /participants/{id}/override" };
if (!Guid.TryParse(segments[1], out var id))
return new { ok = false, error = "invalid id" };
var g = _controller.GlobalSettings;
var framerate = TryParseEnum(body, "framerate", g.Framerate);
var resolution = TryParseEnum(body, "resolution", g.Resolution);
var aspect = TryParseEnum(body, "aspect", g.Aspect);
var audio = TryParseEnum(body, "audio", g.Audio);
var ovr = new FrameProcessingSettings(framerate, resolution, aspect, audio);
await _controller.SetIsoOverrideAsync(id, ovr, CancellationToken.None);
return new { ok = true, id, effective = new
{
framerate = ovr.Framerate.ToString(),
resolution = ovr.Resolution.ToString(),
aspect = ovr.Aspect.ToString(),
audio = ovr.Audio.ToString(),
isOverride = true,
} };
}
/// <summary>DELETE /participants/{id}/override — pipeline reverts to global settings.</summary>
private async Task<object> ClearIsoOverrideByIdAsync(string path)
{
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override")
return new { ok = false, error = "expected /participants/{id}/override" };
if (!Guid.TryParse(segments[1], out var id))
return new { ok = false, error = "invalid id" };
await _controller.SetIsoOverrideAsync(id, null, CancellationToken.None);
return new { ok = true, id, cleared = true };
}
/// <summary>
/// Parse an enum value from a JSON body, falling back to a default when
/// the field is missing or the value doesn't match any enum member.
/// Case-insensitive. Used by SetIsoOverrideByIdAsync for the four
/// FrameProcessingSettings enums.
/// </summary>
private static TEnum TryParseEnum<TEnum>(JsonElement body, string field, TEnum fallback)
where TEnum : struct, Enum
{
if (body.ValueKind != JsonValueKind.Object) return fallback;
if (!body.TryGetProperty(field, out var prop)) return fallback;
if (prop.ValueKind != JsonValueKind.String) return fallback;
var s = prop.GetString();
if (string.IsNullOrEmpty(s)) return fallback;
return Enum.TryParse<TEnum>(s, ignoreCase: true, out var result) ? result : fallback;
}
private async Task<object> ToggleIsoByIdAsync(string path, JsonElement body, NameValueCollection query)
{
// path = /participants/<guid>/iso
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "iso")
return NotFound();
if (!Guid.TryParse(segments[1], out var id))
return new { ok = false, error = "invalid id" };
return await ToggleByIdAsync(id, body, query);
}
private async Task<object> ToggleIsoByNameAsync(JsonElement body, NameValueCollection query)
{
var displayName = TryGetString(body, query, "displayName");
if (string.IsNullOrWhiteSpace(displayName))
return new { ok = false, error = "displayName required" };
var vm = _viewModel();
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (vm is null || dispatcher is null)
return new { ok = false, error = "view-model not ready" };
var p = await dispatcher.InvokeAsync(() => vm.Participants.FirstOrDefault(x =>
string.Equals(x.DisplayName, displayName, StringComparison.OrdinalIgnoreCase)));
if (p is null) return new { ok = false, error = "participant not found", displayName };
return await ToggleByIdAsync(p.Id, body, query);
}
private async Task<object> ToggleByIdAsync(Guid id, JsonElement body, NameValueCollection query)
{
var enabled = TryGetBool(body, query, "enabled");
var customName = TryGetString(body, query, "customName");
var vm = _viewModel();
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (vm is null || dispatcher is null)
return new { ok = false, error = "view-model not ready" };
// Look up the VM and snapshot its current state on the UI thread —
// ObservableCollection enumeration and view-model property reads
// both need to happen there.
var lookup = await dispatcher.InvokeAsync(() =>
{
var p = vm.Participants.FirstOrDefault(x => x.Id == id);
return p is null
? null
: new { Pvm = p, p.IsEnabled, p.CustomName };
});
if (lookup is null) return new { ok = false, error = "participant not found", id };
var target = enabled ?? !lookup.IsEnabled;
var nameToUse = !string.IsNullOrEmpty(customName) ? customName : lookup.CustomName;
if (target == lookup.IsEnabled && string.IsNullOrEmpty(customName))
return new { ok = true, id, enabled = lookup.IsEnabled, action = "noop" };
// Apply CustomName change first (if any) on the UI thread so a
// subsequent EnableIsoAsync sees the new name.
if (!string.IsNullOrEmpty(customName))
await dispatcher.InvokeAsync(() => lookup.Pvm.CustomName = customName);
if (target)
{
await _controller.EnableIsoAsync(id,
string.IsNullOrWhiteSpace(nameToUse) ? null : nameToUse,
CancellationToken.None);
await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = true);
}
else
{
await _controller.DisableIsoAsync(id, CancellationToken.None);
await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = false);
}
return new { ok = true, id, enabled = target };
}
}

View file

@ -1,71 +0,0 @@
namespace TeamsISO.App.Services;
// /presets/* route handlers.
//
// POST /presets/refresh-discovery → RefreshDiscovery
// POST /presets/stop-all → StopAllAsync
// POST /presets/{name}/apply → ApplyPresetAsync
public sealed partial class ControlSurfaceServer
{
private object RefreshDiscovery()
{
_controller.RefreshDiscovery();
return new { ok = true, action = "refresh-discovery" };
}
private async Task<object> StopAllAsync()
{
var vm = _viewModel();
if (vm is null) return new { ok = false, error = "view-model not ready" };
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null) return new { ok = false, error = "dispatcher not ready" };
// Snapshot the enabled set on the UI thread — ObservableCollection
// isn't safe to enumerate from a thread-pool task, and reading the
// IsEnabled property indirectly walks the data-binding system.
var enabled = await dispatcher.InvokeAsync(() =>
vm.Participants.Where(p => p.IsEnabled).ToArray());
foreach (var p in enabled)
{
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
catch { /* defensive */ }
await dispatcher.InvokeAsync(() => p.IsEnabled = false);
}
return new { ok = true, action = "stop-all", count = enabled.Length };
}
private async Task<object> ApplyPresetAsync(string path)
{
// path = /presets/<name>/apply
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 3 || segments[0] != "presets" || segments[2] != "apply")
return NotFound();
var name = Uri.UnescapeDataString(segments[1]);
var preset = OperatorPresetStore.Find(name);
if (preset is null) return new { ok = false, error = "preset not found", name };
var vm = _viewModel();
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (vm is null || dispatcher is null)
return new { ok = false, error = "view-model not ready" };
// Snapshot participants on the UI thread — ObservableCollection
// enumeration and ParticipantViewModel state reads both need to
// happen there. PresetApplier marshals subsequent property writes
// via the dispatcher.
var snapshot = await dispatcher.InvokeAsync(() => vm.Participants.ToList());
var result = await PresetApplier.ApplyAsync(
preset, snapshot, _controller, dispatcher);
return new
{
ok = true,
name = preset.Name,
matched = result.Matched,
changed = result.Changed,
skipped = result.Skipped,
};
}
}

View file

@ -1,22 +0,0 @@
namespace TeamsISO.App.Services;
// /teams/* route handlers — UIAutomation-driven in-call controls.
//
// POST /teams/mute → InvokeTeams(ToggleMute, "mute")
// POST /teams/camera → InvokeTeams(ToggleCamera, "camera")
// POST /teams/leave → InvokeTeams(LeaveCall, "leave")
// POST /teams/share → InvokeTeams(OpenShareTray, "share")
// POST /teams/raise-hand → InvokeTeams(ToggleRaiseHand, "raise-hand")
public sealed partial class ControlSurfaceServer
{
private object InvokeTeams(Func<TeamsControlBridge.InvokeResult> invoke, string action)
{
var result = invoke();
return new
{
ok = result == TeamsControlBridge.InvokeResult.Invoked,
action,
result = result.ToString(),
};
}
}

View file

@ -1,113 +0,0 @@
using Microsoft.Extensions.Logging;
namespace TeamsISO.App.Services;
// GET /participants/{id}/thumbnail.bmp — small BMP of the latest
// processed frame. Used by the embedded HTML control panel for live
// preview tiles with a cache-busting query param at ~1Hz.
//
// BMP (not JPEG) because the System.Windows.Media.Imaging path NREs on
// non-UI threads and marshaling 1Hz JPEG encodes through the WPF
// dispatcher hurts responsiveness. ~40KB at 192-wide compresses fine
// over LAN gzip.
public sealed partial class ControlSurfaceServer
{
/// <summary>
/// Encode the engine's most recent processed frame for the given
/// participant as a BMP. Returns null when no pipeline is running for
/// this participant or the frame can't be encoded.
/// </summary>
private byte[]? TryEncodeThumbnailJpeg(Guid participantId)
{
try
{
var frame = _controller.GetLatestProcessedFrame(participantId);
if (frame is null)
{
_logger?.LogDebug("Thumbnail: no frame for {Id}", participantId);
return null;
}
if (frame.Pixels.Length == 0)
{
_logger?.LogDebug("Thumbnail: empty pixel buffer for {Id} ({W}x{H})", participantId, frame.Width, frame.Height);
return null;
}
// Nearest-neighbor downscale to ~192 wide. Source is BGRA32.
const int targetWidth = 192;
var ratio = (double)frame.Height / frame.Width;
var targetHeight = Math.Max(1, (int)(targetWidth * ratio));
return EncodeBmpDownscaled(frame.Pixels.Span, frame.Width, frame.Height, targetWidth, targetHeight);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Thumbnail encode failed for {Id}", participantId);
return null;
}
}
/// <summary>
/// Nearest-neighbor downscale of a BGRA32 source then write a 32-bpp
/// top-down BMP. Returns the full BMP file bytes including the 14-byte
/// BMP header and 40-byte BITMAPINFOHEADER. Browsers handle it directly
/// (no JPEG / PNG codec needed in-process).
/// </summary>
private static byte[] EncodeBmpDownscaled(ReadOnlySpan<byte> srcBgra, int srcW, int srcH, int dstW, int dstH)
{
var pixelBytes = dstW * dstH * 4;
var bmp = new byte[54 + pixelBytes];
// BMP file header (14 bytes): 'BM', file size, reserved, pixel offset.
bmp[0] = (byte)'B'; bmp[1] = (byte)'M';
WriteUInt32LE(bmp, 2, (uint)bmp.Length);
WriteUInt32LE(bmp, 6, 0);
WriteUInt32LE(bmp, 10, 54);
// DIB header (BITMAPINFOHEADER, 40 bytes): negative height = top-down.
WriteUInt32LE(bmp, 14, 40);
WriteInt32LE(bmp, 18, dstW);
WriteInt32LE(bmp, 22, -dstH);
WriteUInt16LE(bmp, 26, 1);
WriteUInt16LE(bmp, 28, 32);
WriteUInt32LE(bmp, 30, 0);
WriteUInt32LE(bmp, 34, (uint)pixelBytes);
WriteUInt32LE(bmp, 38, 2835);
WriteUInt32LE(bmp, 42, 2835);
WriteUInt32LE(bmp, 46, 0);
WriteUInt32LE(bmp, 50, 0);
// Nearest-neighbor downscale, top-down (matches negative-height header).
var srcStride = srcW * 4;
var dstOffset = 54;
for (var dy = 0; dy < dstH; dy++)
{
var sy = (int)((long)dy * srcH / dstH);
for (var dx = 0; dx < dstW; dx++)
{
var sx = (int)((long)dx * srcW / dstW);
var si = sy * srcStride + sx * 4;
bmp[dstOffset++] = srcBgra[si];
bmp[dstOffset++] = srcBgra[si + 1];
bmp[dstOffset++] = srcBgra[si + 2];
bmp[dstOffset++] = srcBgra[si + 3];
}
}
return bmp;
}
private static void WriteUInt16LE(byte[] buf, int offset, ushort value)
{
buf[offset] = (byte)(value & 0xFF);
buf[offset + 1] = (byte)((value >> 8) & 0xFF);
}
private static void WriteInt32LE(byte[] buf, int offset, int value) => WriteUInt32LE(buf, offset, (uint)value);
private static void WriteUInt32LE(byte[] buf, int offset, uint value)
{
buf[offset] = (byte)(value & 0xFF);
buf[offset + 1] = (byte)((value >> 8) & 0xFF);
buf[offset + 2] = (byte)((value >> 16) & 0xFF);
buf[offset + 3] = (byte)((value >> 24) & 0xFF);
}
}

View file

@ -1,91 +0,0 @@
namespace TeamsISO.App.Services;
// /topology/* route handlers — read + apply / restore the machine NDI
// access-manager config so the operator can flip transcoder topology
// without leaving the web UI.
//
// GET /topology → GetTopology
// POST /topology/apply → ApplyTopologyAsync
// POST /topology/restore → RestoreTopologyAsync
public sealed partial class ControlSurfaceServer
{
/// <summary>
/// Report the current NDI machine topology. "mode" is "hidden" when
/// local senders are confined to the private group (raw Teams sources
/// invisible to the rest of the LAN), "public" otherwise. Reads the
/// machine NDI config file directly — no caching, so the result
/// reflects whatever state the file is in right now (including
/// manual edits).
/// </summary>
private object GetTopology()
{
try
{
var (mode, sends, recvs) = NdiAccessManagerConfig.ReadCurrent();
return new
{
mode,
senders = sends,
receivers = recvs,
configPath = NdiAccessManagerConfig.ConfigPath,
};
}
catch (Exception ex)
{
return new { ok = false, error = ex.Message };
}
}
/// <summary>
/// Apply the transcoder topology: machine senders → <c>teamsiso-input</c>,
/// receivers → <c>public + teamsiso-input</c>; engine groups updated to
/// match (discover from teamsiso-input, broadcast on public). Operator
/// MUST restart Teams afterward for it to read the new NDI config.
/// </summary>
private async Task<object> ApplyTopologyAsync()
{
var result = NdiAccessManagerConfig.ApplyTranscoderTopology();
if (!result.Success)
{
return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath };
}
// Mirror what the WPF settings VM does so the engine groups +
// machine config stay in lockstep.
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings(
DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup,
OutputGroups: "public");
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
return new
{
ok = true,
mode = "hidden",
backupPath = result.BackupPath,
note = "Restart Microsoft Teams for the new NDI config to take effect there.",
};
}
/// <summary>
/// Restore the machine NDI defaults: senders + receivers both on
/// <c>public</c>. Engine groups go back to null/defaults too. Operator
/// must restart Teams for it to broadcast on public again.
/// </summary>
private async Task<object> RestoreTopologyAsync()
{
var result = NdiAccessManagerConfig.RestoreDefaults();
if (!result.Success)
{
return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath };
}
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings(
DiscoveryGroups: null,
OutputGroups: null);
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
return new
{
ok = true,
mode = "public",
backupPath = result.BackupPath,
note = "Restart Microsoft Teams for the new NDI config to take effect there.",
};
}
}

View file

@ -1,147 +0,0 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace TeamsISO.App.Services;
// GET /ws — upgrades to WebSocket and pushes participant-list snapshots
// at 4Hz with diffing (no push when nothing changed). Lets controllers
// stay live-synced without polling /participants.
//
// Lifecycle:
// • Server's accept loop upgrades the request and hands the socket here.
// • HandleWebSocketAsync owns the connection until the client closes.
// • The Start() method wires a 4Hz DispatcherTimer that calls
// PushSnapshotIfChangedAsync to fan out to every connected client.
public sealed partial class ControlSurfaceServer
{
/// <summary>
/// Owns a single client connection until it closes. Sends an immediate
/// snapshot on connect (so the client doesn't have to wait up to 250ms
/// for the next push tick), then sits in a receive loop draining any
/// incoming text — we ignore client→server messages for v1 since all
/// commands are REST. The receive loop is the canonical way to detect
/// graceful close: when WebSocket.ReceiveAsync returns CloseReceived,
/// we close back and remove the client.
/// </summary>
private async Task HandleWebSocketAsync(WebSocket ws)
{
var clientId = Guid.NewGuid();
_clients[clientId] = ws;
_logger?.LogInformation("WebSocket client {Id} connected.", clientId);
try
{
// Initial snapshot — fetch synchronously on the UI thread so the
// ObservableCollection isn't enumerated cross-thread.
await SendAsync(ws, await GetSnapshotJsonAsync());
var buf = new byte[1024];
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buf), CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
break;
}
// Ignore any client-sent messages for now; future bidirectional
// commands could route through here.
}
}
catch (WebSocketException) { /* client crashed; drop */ }
catch (ObjectDisposedException) { /* Stop() aborted us */ }
catch (OperationCanceledException) { /* server shutting down */ }
finally
{
_clients.TryRemove(clientId, out _);
// Don't double-dispose: Stop() already disposed the WebSocket if
// it's tearing us down. Aborting an already-disposed socket is a
// no-op throw which we catch + ignore.
try { ws.Dispose(); } catch { /* defensive */ }
_logger?.LogInformation("WebSocket client {Id} disconnected.", clientId);
}
}
/// <summary>
/// Dispatcher-tick handler. Reads the current participants snapshot,
/// and if it differs from what we last pushed, broadcasts the new
/// JSON to every connected client. Diffing on the JSON string is
/// cheap and saves wire bytes when nothing's actually changing —
/// typical operator workflow has long periods of no state churn
/// between meetings.
/// </summary>
private async Task PushSnapshotIfChangedAsync()
{
if (_clients.IsEmpty) return;
string snapshot;
try { snapshot = await GetSnapshotJsonAsync(); }
catch { return; }
if (snapshot == _lastPushedSnapshot) return;
_lastPushedSnapshot = snapshot;
var bytes = Encoding.UTF8.GetBytes(snapshot);
foreach (var (id, ws) in _clients.ToArray())
{
if (ws.State != WebSocketState.Open)
{
_clients.TryRemove(id, out _);
continue;
}
try
{
await ws.SendAsync(
new ArraySegment<byte>(bytes),
WebSocketMessageType.Text,
endOfMessage: true,
CancellationToken.None);
}
catch
{
_clients.TryRemove(id, out _);
try { ws.Dispose(); } catch { /* defensive */ }
}
}
}
private static async Task SendAsync(WebSocket ws, string text)
{
var bytes = Encoding.UTF8.GetBytes(text);
await ws.SendAsync(
new ArraySegment<byte>(bytes),
WebSocketMessageType.Text,
endOfMessage: true,
CancellationToken.None);
}
/// <summary>
/// Build the same payload as <c>GET /participants</c> but as a JSON
/// string for direct WebSocket Send. Reads the ObservableCollection
/// via the UI dispatcher because WPF's ObservableCollection isn't
/// thread-safe to enumerate from a non-UI thread.
/// </summary>
private async Task<string> GetSnapshotJsonAsync()
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
var participants = dispatcher is null
? Array.Empty<object>()
: await dispatcher.InvokeAsync(() =>
{
var vm = _viewModel();
if (vm is null) return Array.Empty<object>();
return vm.Participants.Select(p => (object)new
{
id = p.Id,
displayName = p.DisplayName,
isOnline = p.IsOnline,
isEnabled = p.IsEnabled,
customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName,
stateLabel = p.StateLabel,
}).ToArray();
});
return JsonSerializer.Serialize(new { type = "participants", participants }, JsonOpts);
}
}

View file

@ -44,10 +44,7 @@ namespace TeamsISO.App.Services;
/// either via JSON body or via query string (?enabled=true&amp;customName=Host).
/// This is friendly to Companion's "URL with query string" mode.
/// </summary>
// Endpoint handlers live in partial files under Services/ControlSurface/Endpoints/.
// This file holds the host: listener lifecycle, accept loop, dispatch table,
// response helpers, and the WebSocket push loop.
public sealed partial class ControlSurfaceServer : IAsyncDisposable
public sealed class ControlSurfaceServer : IAsyncDisposable
{
public const int DefaultPort = 9755;
@ -343,16 +340,680 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
}
// ─── handlers ───────────────────────────────────────────────────────
//
// Endpoint bodies live in Services/ControlSurface/Endpoints/*.cs as
// partials of this class. See HomeEndpoints, ParticipantsEndpoints,
// PresetsEndpoints, TeamsEndpoints, TopologyEndpoints, NotesEndpoints,
// and ThumbnailEndpoint. The WebSocket push surface is at
// Services/ControlSurface/WebSocketHub.cs.
private object GetServerInfo()
{
// Best-effort engine snapshot — wrapped in try/catch so a transient
// controller error doesn't 500 the homepage poll.
var settings = TryRead(() => _controller.GlobalSettings);
var groups = TryRead(() => _controller.GroupSettings);
return new
{
product = "TeamsISO",
version = typeof(ControlSurfaceServer).Assembly.GetName().Version?.ToString() ?? "unknown",
engine = new
{
framerateHz = settings?.FramerateHz,
targetResolution = settings?.Resolution.ToString(),
aspectMode = settings?.Aspect.ToString(),
audioMode = settings?.Audio.ToString(),
discoveryGroups = groups?.DiscoveryGroups,
outputGroups = groups?.OutputGroups,
},
// recording status fields removed alongside the rest of the recording surface.
endpoints = new[]
{
"GET / (this)",
"GET /ui (HTML control panel)",
"GET /participants",
"GET /ws (WebSocket: live participant snapshots)",
"POST /participants/{id}/iso",
"POST /participants/iso (body: displayName + enabled)",
"POST /presets/{name}/apply",
"POST /presets/refresh-discovery",
"POST /presets/stop-all",
"POST /teams/mute, /camera, /leave, /share, /raise-hand",
"POST /notes (body: text)",
},
};
}
private static T? TryRead<T>(Func<T> reader) where T : class
{
try { return reader(); }
catch { return null; }
}
private object GetParticipants()
{
var vm = _viewModel();
if (vm is null) return new { participants = Array.Empty<object>() };
// Synchronously snapshot on the UI thread — ObservableCollection isn't safe
// to enumerate from this request handler's thread-pool task, and the
// ParticipantViewModel property reads chase data-binding state.
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null) return new { participants = Array.Empty<object>() };
var globals = _controller.GlobalSettings;
var list = dispatcher.Invoke(() => vm.Participants.Select(p => {
var ovr = _controller.GetIsoOverride(p.Id);
return (object)new
{
id = p.Id,
displayName = p.DisplayName,
isOnline = p.IsOnline,
isEnabled = p.IsEnabled,
customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName,
stateLabel = p.StateLabel,
// Effective settings = override if set, else globals. The web
// UI uses this to show the current per-row values without a
// separate round-trip to /global.
effective = new
{
framerate = (ovr ?? globals).Framerate.ToString(),
resolution = (ovr ?? globals).Resolution.ToString(),
aspect = (ovr ?? globals).Aspect.ToString(),
audio = (ovr ?? globals).Audio.ToString(),
isOverride = ovr is not null,
},
};
}).ToArray());
return new { participants = list, globals = new {
framerate = globals.Framerate.ToString(),
resolution = globals.Resolution.ToString(),
aspect = globals.Aspect.ToString(),
audio = globals.Audio.ToString(),
} };
}
/// <summary>
/// POST /participants/{id}/override — set or replace the per-pipeline
/// override. Body fields: framerate (enum string), resolution (enum
/// string), aspect (enum string), audio (enum string). All fields are
/// optional; missing fields fall back to the current global value.
/// </summary>
private async Task<object> SetIsoOverrideByIdAsync(string path, JsonElement body)
{
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override")
return new { ok = false, error = "expected /participants/{id}/override" };
if (!Guid.TryParse(segments[1], out var id))
return new { ok = false, error = "invalid id" };
var g = _controller.GlobalSettings;
var framerate = TryParseEnum(body, "framerate", g.Framerate);
var resolution = TryParseEnum(body, "resolution", g.Resolution);
var aspect = TryParseEnum(body, "aspect", g.Aspect);
var audio = TryParseEnum(body, "audio", g.Audio);
var ovr = new FrameProcessingSettings(framerate, resolution, aspect, audio);
await _controller.SetIsoOverrideAsync(id, ovr, CancellationToken.None);
return new { ok = true, id, effective = new
{
framerate = ovr.Framerate.ToString(),
resolution = ovr.Resolution.ToString(),
aspect = ovr.Aspect.ToString(),
audio = ovr.Audio.ToString(),
isOverride = true,
} };
}
/// <summary>DELETE /participants/{id}/override — pipeline reverts to global settings.</summary>
private async Task<object> ClearIsoOverrideByIdAsync(string path)
{
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override")
return new { ok = false, error = "expected /participants/{id}/override" };
if (!Guid.TryParse(segments[1], out var id))
return new { ok = false, error = "invalid id" };
await _controller.SetIsoOverrideAsync(id, null, CancellationToken.None);
return new { ok = true, id, cleared = true };
}
/// <summary>
/// Parse an enum value from a JSON body, falling back to a default when
/// the field is missing or the value doesn't match any enum member.
/// Case-insensitive. Used by SetIsoOverrideByIdAsync for the four
/// FrameProcessingSettings enums.
/// </summary>
private static TEnum TryParseEnum<TEnum>(JsonElement body, string field, TEnum fallback)
where TEnum : struct, Enum
{
if (body.ValueKind != JsonValueKind.Object) return fallback;
if (!body.TryGetProperty(field, out var prop)) return fallback;
if (prop.ValueKind != JsonValueKind.String) return fallback;
var s = prop.GetString();
if (string.IsNullOrEmpty(s)) return fallback;
return Enum.TryParse<TEnum>(s, ignoreCase: true, out var result) ? result : fallback;
}
private object RefreshDiscovery()
{
_controller.RefreshDiscovery();
return new { ok = true, action = "refresh-discovery" };
}
private async Task<object> StopAllAsync()
{
var vm = _viewModel();
if (vm is null) return new { ok = false, error = "view-model not ready" };
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null) return new { ok = false, error = "dispatcher not ready" };
// Snapshot the enabled set on the UI thread — ObservableCollection isn't
// safe to enumerate from a thread-pool task, and reading the IsEnabled
// property indirectly walks the data-binding system.
var enabled = await dispatcher.InvokeAsync(() =>
vm.Participants.Where(p => p.IsEnabled).ToArray());
foreach (var p in enabled)
{
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
catch { /* defensive */ }
await dispatcher.InvokeAsync(() => p.IsEnabled = false);
}
return new { ok = true, action = "stop-all", count = enabled.Length };
}
private object InvokeTeams(Func<TeamsControlBridge.InvokeResult> invoke, string action)
{
var result = invoke();
return new
{
ok = result == TeamsControlBridge.InvokeResult.Invoked,
action,
result = result.ToString(),
};
}
// SetRecording and DropMarker methods removed alongside the rest of the recording surface.
/// <summary>
/// Report the current NDI machine topology. "mode" is "hidden" when local
/// senders are confined to the private group (raw Teams sources invisible
/// to the rest of the LAN), "public" otherwise. Reads the machine NDI
/// config file directly — no caching, so the result reflects whatever
/// state the file is in right now (including manual edits).
/// </summary>
private object GetTopology()
{
try
{
var (mode, sends, recvs) = NdiAccessManagerConfig.ReadCurrent();
return new
{
mode,
senders = sends,
receivers = recvs,
configPath = NdiAccessManagerConfig.ConfigPath,
};
}
catch (Exception ex)
{
return new { ok = false, error = ex.Message };
}
}
/// <summary>
/// Apply the transcoder topology: machine senders → <c>teamsiso-input</c>,
/// receivers → <c>public + teamsiso-input</c>; engine groups updated to
/// match (discover from teamsiso-input, broadcast on public). Operator
/// MUST restart Teams afterward for it to read the new NDI config.
/// </summary>
private async Task<object> ApplyTopologyAsync()
{
var result = NdiAccessManagerConfig.ApplyTranscoderTopology();
if (!result.Success)
{
return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath };
}
// Mirror what the WPF settings VM does so the engine groups + machine
// config stay in lockstep.
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings(
DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup,
OutputGroups: "public");
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
return new
{
ok = true,
mode = "hidden",
backupPath = result.BackupPath,
note = "Restart Microsoft Teams for the new NDI config to take effect there.",
};
}
/// <summary>
/// Restore the machine NDI defaults: senders + receivers both on
/// <c>public</c>. Engine groups go back to null/defaults too. Operator
/// must restart Teams for it to broadcast on public again.
/// </summary>
private async Task<object> RestoreTopologyAsync()
{
var result = NdiAccessManagerConfig.RestoreDefaults();
if (!result.Success)
{
return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath };
}
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings(
DiscoveryGroups: null,
OutputGroups: null);
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
return new
{
ok = true,
mode = "public",
backupPath = result.BackupPath,
note = "Restart Microsoft Teams for the new NDI config to take effect there.",
};
}
/// <summary>
/// Encode the engine's most recent processed frame for the given
/// participant as a JPEG. Returns null when no pipeline is running for
/// this participant or the frame can't be encoded for any reason.
/// </summary>
private byte[]? TryEncodeThumbnailJpeg(Guid participantId)
{
// Encode as a raw 32-bpp BMP. BMP is trivial to write byte-by-byte
// and every browser decodes it. JPEG would be smaller, but the
// System.Windows.Media.Imaging path NREs on non-UI threads and
// marshaling 1Hz JPEG encodes through the WPF dispatcher hurts
// responsiveness. ~40KB per 192-wide BMP is fine over LAN.
try
{
var frame = _controller.GetLatestProcessedFrame(participantId);
if (frame is null)
{
_logger?.LogDebug("Thumbnail: no frame for {Id}", participantId);
return null;
}
if (frame.Pixels.Length == 0)
{
_logger?.LogDebug("Thumbnail: empty pixel buffer for {Id} ({W}x{H})", participantId, frame.Width, frame.Height);
return null;
}
// Nearest-neighbor downscale to ~192 wide. Source is BGRA32.
const int targetWidth = 192;
var ratio = (double)frame.Height / frame.Width;
var targetHeight = Math.Max(1, (int)(targetWidth * ratio));
return EncodeBmpDownscaled(frame.Pixels.Span, frame.Width, frame.Height, targetWidth, targetHeight);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Thumbnail encode failed for {Id}", participantId);
return null;
}
}
/// <summary>
/// Nearest-neighbor downscale of a BGRA32 source then write a 32-bpp
/// top-down BMP. Returns the full BMP file bytes including the 14-byte
/// BMP header and 40-byte BITMAPINFOHEADER. Browsers handle it directly
/// (no JPEG / PNG codec needed in-process).
/// </summary>
private static byte[] EncodeBmpDownscaled(ReadOnlySpan<byte> srcBgra, int srcW, int srcH, int dstW, int dstH)
{
var pixelBytes = dstW * dstH * 4;
var bmp = new byte[54 + pixelBytes];
// BMP file header (14 bytes): 'BM', file size, reserved, pixel offset.
bmp[0] = (byte)'B'; bmp[1] = (byte)'M';
WriteUInt32LE(bmp, 2, (uint)bmp.Length);
WriteUInt32LE(bmp, 6, 0);
WriteUInt32LE(bmp, 10, 54);
// DIB header (BITMAPINFOHEADER, 40 bytes): negative height = top-down.
WriteUInt32LE(bmp, 14, 40);
WriteInt32LE(bmp, 18, dstW);
WriteInt32LE(bmp, 22, -dstH);
WriteUInt16LE(bmp, 26, 1);
WriteUInt16LE(bmp, 28, 32);
WriteUInt32LE(bmp, 30, 0);
WriteUInt32LE(bmp, 34, (uint)pixelBytes);
WriteUInt32LE(bmp, 38, 2835);
WriteUInt32LE(bmp, 42, 2835);
WriteUInt32LE(bmp, 46, 0);
WriteUInt32LE(bmp, 50, 0);
// Nearest-neighbor downscale, top-down (matches negative-height header).
var srcStride = srcW * 4;
var dstOffset = 54;
for (var dy = 0; dy < dstH; dy++)
{
var sy = (int)((long)dy * srcH / dstH);
for (var dx = 0; dx < dstW; dx++)
{
var sx = (int)((long)dx * srcW / dstW);
var si = sy * srcStride + sx * 4;
bmp[dstOffset++] = srcBgra[si];
bmp[dstOffset++] = srcBgra[si + 1];
bmp[dstOffset++] = srcBgra[si + 2];
bmp[dstOffset++] = srcBgra[si + 3];
}
}
return bmp;
}
private static void WriteUInt16LE(byte[] buf, int offset, ushort value)
{
buf[offset] = (byte)(value & 0xFF);
buf[offset + 1] = (byte)((value >> 8) & 0xFF);
}
private static void WriteInt32LE(byte[] buf, int offset, int value) => WriteUInt32LE(buf, offset, (uint)value);
private static void WriteUInt32LE(byte[] buf, int offset, uint value)
{
buf[offset] = (byte)(value & 0xFF);
buf[offset + 1] = (byte)((value >> 8) & 0xFF);
buf[offset + 2] = (byte)((value >> 16) & 0xFF);
buf[offset + 3] = (byte)((value >> 24) & 0xFF);
}
// Legacy WPF-imaging path kept dead-coded for posterity. The BMP path
// above is what's wired through the endpoint. If we ever want JPEG
// again, marshal this to the dispatcher and call from there.
private byte[]? TryEncodeThumbnailJpeg_WpfDeadCode(Guid participantId)
{
try
{
var frame = _controller.GetLatestProcessedFrame(participantId);
if (frame is null) return null;
// 192-wide thumbnail at the source aspect. BGRA32 input.
const int targetWidth = 192;
var ratio = (double)frame.Height / frame.Width;
var targetHeight = Math.Max(1, (int)(targetWidth * ratio));
// WPF imaging is NOT free-threaded by default: BitmapSource and
// friends own DispatcherObject affinity until Freeze() drops it.
// The control surface handler runs on an HttpListener thread (NOT
// the UI dispatcher), so every intermediate bitmap MUST be frozen
// before the next call touches it — otherwise we get a NRE deep
// in MIL when JpegBitmapEncoder.Save tries to walk the frame
// chain across thread boundaries.
var stride = frame.Width * 4;
var source = System.Windows.Media.Imaging.BitmapSource.Create(
frame.Width, frame.Height,
96, 96,
System.Windows.Media.PixelFormats.Bgra32,
null,
frame.Pixels.ToArray(),
stride);
if (source.CanFreeze) source.Freeze();
var transform = new System.Windows.Media.ScaleTransform(
(double)targetWidth / frame.Width,
(double)targetHeight / frame.Height);
if (transform.CanFreeze) transform.Freeze();
var scaled = new System.Windows.Media.Imaging.TransformedBitmap(source, transform);
if (scaled.CanFreeze) scaled.Freeze();
var bitmapFrame = System.Windows.Media.Imaging.BitmapFrame.Create(scaled);
if (bitmapFrame.CanFreeze) bitmapFrame.Freeze();
using var ms = new System.IO.MemoryStream();
var encoder = new System.Windows.Media.Imaging.JpegBitmapEncoder { QualityLevel = 60 };
encoder.Frames.Add(bitmapFrame);
encoder.Save(ms);
return ms.ToArray();
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "Thumbnail encode failed for {Id}", participantId);
return null;
}
}
private object AppendNote(JsonElement body, System.Collections.Specialized.NameValueCollection query)
{
var text = TryGetString(body, query, "text");
if (string.IsNullOrWhiteSpace(text))
return new { ok = false, error = "text required" };
var ok = NotesService.Append(text);
return new { ok, action = "note", path = NotesService.TodayPath };
}
// RollRecordingAsync handler removed alongside the rest of the recording surface.
private async Task<object> ToggleIsoByIdAsync(string path, JsonElement body, System.Collections.Specialized.NameValueCollection query)
{
// path = /participants/<guid>/iso
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "iso")
return NotFound();
if (!Guid.TryParse(segments[1], out var id))
return new { ok = false, error = "invalid id" };
return await ToggleByIdAsync(id, body, query);
}
private async Task<object> ToggleIsoByNameAsync(JsonElement body, System.Collections.Specialized.NameValueCollection query)
{
var displayName = TryGetString(body, query, "displayName");
if (string.IsNullOrWhiteSpace(displayName))
return new { ok = false, error = "displayName required" };
var vm = _viewModel();
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (vm is null || dispatcher is null)
return new { ok = false, error = "view-model not ready" };
var p = await dispatcher.InvokeAsync(() => vm.Participants.FirstOrDefault(x =>
string.Equals(x.DisplayName, displayName, StringComparison.OrdinalIgnoreCase)));
if (p is null) return new { ok = false, error = "participant not found", displayName };
return await ToggleByIdAsync(p.Id, body, query);
}
private async Task<object> ToggleByIdAsync(Guid id, JsonElement body, System.Collections.Specialized.NameValueCollection query)
{
var enabled = TryGetBool(body, query, "enabled");
var customName = TryGetString(body, query, "customName");
var vm = _viewModel();
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (vm is null || dispatcher is null)
return new { ok = false, error = "view-model not ready" };
// Look up the VM and snapshot its current state on the UI thread —
// ObservableCollection enumeration and view-model property reads both
// need to happen there.
var lookup = await dispatcher.InvokeAsync(() =>
{
var p = vm.Participants.FirstOrDefault(x => x.Id == id);
return p is null
? null
: new { Pvm = p, p.IsEnabled, p.CustomName };
});
if (lookup is null) return new { ok = false, error = "participant not found", id };
var target = enabled ?? !lookup.IsEnabled;
var nameToUse = !string.IsNullOrEmpty(customName) ? customName : lookup.CustomName;
if (target == lookup.IsEnabled && string.IsNullOrEmpty(customName))
return new { ok = true, id, enabled = lookup.IsEnabled, action = "noop" };
// Apply CustomName change first (if any) on the UI thread so a subsequent
// EnableIsoAsync sees the new name.
if (!string.IsNullOrEmpty(customName))
await dispatcher.InvokeAsync(() => lookup.Pvm.CustomName = customName);
if (target)
{
await _controller.EnableIsoAsync(id,
string.IsNullOrWhiteSpace(nameToUse) ? null : nameToUse,
CancellationToken.None);
await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = true);
}
else
{
await _controller.DisableIsoAsync(id, CancellationToken.None);
await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = false);
}
return new { ok = true, id, enabled = target };
}
private async Task<object> ApplyPresetAsync(string path)
{
// path = /presets/<name>/apply
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 3 || segments[0] != "presets" || segments[2] != "apply")
return NotFound();
var name = Uri.UnescapeDataString(segments[1]);
var preset = OperatorPresetStore.Find(name);
if (preset is null) return new { ok = false, error = "preset not found", name };
var vm = _viewModel();
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (vm is null || dispatcher is null)
return new { ok = false, error = "view-model not ready" };
// Snapshot participants on the UI thread — ObservableCollection enumeration
// and ParticipantViewModel state reads both need to happen there.
// PresetApplier marshals subsequent property writes via the dispatcher.
var snapshot = await dispatcher.InvokeAsync(() => vm.Participants.ToList());
var result = await PresetApplier.ApplyAsync(
preset, snapshot, _controller, dispatcher);
return new
{
ok = true,
name = preset.Name,
matched = result.Matched,
changed = result.Changed,
skipped = result.Skipped,
};
}
[SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")]
private object NotFound() => new { error = "not found" };
// ─── WebSocket push ─────────────────────────────────────────────────
/// <summary>
/// Owns a single client connection until it closes. Sends an immediate
/// snapshot on connect (so the client doesn't have to wait up to 250ms
/// for the next push tick), then sits in a receive loop draining any
/// incoming text — we ignore client→server messages for v1 since all
/// commands are REST. The receive loop is the canonical way to detect
/// graceful close: when WebSocket.ReceiveAsync returns CloseReceived,
/// we close back and remove the client.
/// </summary>
private async Task HandleWebSocketAsync(WebSocket ws)
{
var clientId = Guid.NewGuid();
_clients[clientId] = ws;
_logger?.LogInformation("WebSocket client {Id} connected.", clientId);
try
{
// Initial snapshot — fetch synchronously on the UI thread so the
// ObservableCollection isn't enumerated cross-thread.
await SendAsync(ws, await GetSnapshotJsonAsync());
var buf = new byte[1024];
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buf), CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
break;
}
// Ignore any client-sent messages for now; future bidirectional
// commands could route through here.
}
}
catch (WebSocketException) { /* client crashed; drop */ }
catch (ObjectDisposedException) { /* Stop() aborted us */ }
catch (OperationCanceledException) { /* server shutting down */ }
finally
{
_clients.TryRemove(clientId, out _);
// Don't double-dispose: Stop() already disposed the WebSocket if it's
// tearing us down. Aborting an already-disposed socket is a no-op
// throw which we catch + ignore.
try { ws.Dispose(); } catch { /* defensive */ }
_logger?.LogInformation("WebSocket client {Id} disconnected.", clientId);
}
}
/// <summary>
/// Dispatcher-tick handler. Reads the current participants snapshot, and if
/// it differs from what we last pushed, broadcasts the new JSON to every
/// connected client. Diffing on the JSON string is cheap and saves wire
/// bytes when nothing's actually changing — typical operator workflow has
/// long periods of no state churn between meetings.
/// </summary>
private async Task PushSnapshotIfChangedAsync()
{
if (_clients.IsEmpty) return;
string snapshot;
try { snapshot = await GetSnapshotJsonAsync(); }
catch { return; }
if (snapshot == _lastPushedSnapshot) return;
_lastPushedSnapshot = snapshot;
var bytes = Encoding.UTF8.GetBytes(snapshot);
foreach (var (id, ws) in _clients.ToArray())
{
if (ws.State != WebSocketState.Open)
{
_clients.TryRemove(id, out _);
continue;
}
try
{
await ws.SendAsync(
new ArraySegment<byte>(bytes),
WebSocketMessageType.Text,
endOfMessage: true,
CancellationToken.None);
}
catch
{
_clients.TryRemove(id, out _);
try { ws.Dispose(); } catch { /* defensive */ }
}
}
}
private static async Task SendAsync(WebSocket ws, string text)
{
var bytes = Encoding.UTF8.GetBytes(text);
await ws.SendAsync(
new ArraySegment<byte>(bytes),
WebSocketMessageType.Text,
endOfMessage: true,
CancellationToken.None);
}
/// <summary>
/// Build the same payload as <c>GET /participants</c> but as a JSON string
/// for direct WebSocket Send. Reads the ObservableCollection via the UI
/// dispatcher because WPF's ObservableCollection isn't thread-safe to
/// enumerate from a non-UI thread.
/// </summary>
private async Task<string> GetSnapshotJsonAsync()
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
var participants = dispatcher is null
? Array.Empty<object>()
: await dispatcher.InvokeAsync(() =>
{
var vm = _viewModel();
if (vm is null) return Array.Empty<object>();
return vm.Participants.Select(p => (object)new
{
id = p.Id,
displayName = p.DisplayName,
isOnline = p.IsOnline,
isEnabled = p.IsEnabled,
customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName,
stateLabel = p.StateLabel,
}).ToArray();
});
return JsonSerializer.Serialize(new { type = "participants", participants }, JsonOpts);
}
// ─── helpers ────────────────────────────────────────────────────────
private static async Task<JsonElement> ReadBodyAsync(HttpListenerRequest req)

View file

@ -19,16 +19,8 @@ public static class NotesService
{
private static readonly object _gate = new();
/// <summary>
/// Test-only seam — when set, overrides the default
/// %LOCALAPPDATA%\TeamsISO\Notes path. Lets tests write to a
/// tempdir without polluting the dev's real notes folder.
/// InternalsVisibleTo grants TeamsISO.App.Tests access.
/// </summary>
internal static string? DirectoryOverride { get; set; }
private static string NotesDirectory =>
DirectoryOverride ?? Path.Combine(
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Notes");

View file

@ -139,10 +139,7 @@ public sealed class OscBridge : IAsyncDisposable
}
}
// Internal so unit tests can construct an OscMessage and verify
// route dispatch reaches the right controller / TeamsControlBridge /
// NotesService call without driving the full UDP receive loop.
internal async Task DispatchAsync(OscMessage msg)
private async Task DispatchAsync(OscMessage msg)
{
var addr = msg.Address;
switch (addr)

View file

@ -1,177 +0,0 @@
using System.Runtime.InteropServices;
namespace TeamsISO.App.Services;
/// <summary>
/// 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 <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 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 = 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;
}
}
}

View file

@ -271,6 +271,168 @@ 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;
/// <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
@ -320,14 +482,7 @@ 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.
/// </summary>
/// <summary>
/// Return the visible top-level windows owned by any Teams process.
/// Exposed internal so <see cref="TeamsEmbedHost"/> 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.
/// </summary>
internal static List<IntPtr> EnumerateTopLevelTeamsWindows()
private static List<IntPtr> FindTeamsTopLevelWindows()
{
var teamsPids = new HashSet<uint>(
TeamsProcessNames
@ -354,7 +509,7 @@ public static class TeamsLauncher
/// </summary>
public static int HideWindows()
{
var windows = EnumerateTopLevelTeamsWindows();
var windows = FindTeamsTopLevelWindows();
foreach (var w in windows) ShowWindow(w, SW_HIDE);
return windows.Count;
}
@ -455,7 +610,7 @@ public static class TeamsLauncher
/// </summary>
public static bool SendShortcut(ShortcutModifiers modifiers, int virtualKey)
{
var windows = EnumerateTopLevelTeamsWindows();
var windows = FindTeamsTopLevelWindows();
if (windows.Count == 0) return false;
var hwnd = windows[^1];

View file

@ -25,60 +25,35 @@ namespace TeamsISO.App.Services;
/// </summary>
public sealed class ThemeManager
{
public static ThemeManager Current { get; } = new(
isSystemDark: ReadSystemDarkFromRegistry,
loadPreference: TryLoadPreferenceFromDisk,
savePreference: TrySavePreferenceToDisk,
subscribeToSystemPreference: true);
public static ThemeManager Current { get; } = new();
// Pack URIs (rather than relative "/Themes/…") so the resolution
// works equally well from production (where Application.Current's
// base URI is the TeamsISO entry assembly) and from xUnit tests
// (where it's the test assembly — relative URIs would miss).
private const string DarkUri = "pack://application:,,,/TeamsISO;component/Themes/Theme.Dark.xaml";
private const string LightUri = "pack://application:,,,/TeamsISO;component/Themes/Theme.Light.xaml";
private const string DarkUri = "/Themes/Theme.Dark.xaml";
private const string LightUri = "/Themes/Theme.Light.xaml";
private const string PreferenceKeySystem = "System";
private const string PreferenceKeyDark = "Dark";
private const string PreferenceKeyLight = "Light";
// Test seams. The production singleton wires these to the real
// registry / UIPreferences. Tests construct via the internal ctor
// with their own stubs so they don't touch HKCU or %LOCALAPPDATA%.
private readonly Func<bool> _isSystemDark;
private readonly Action<string> _savePreference;
internal ThemeManager(
Func<bool> isSystemDark,
Func<string?> loadPreference,
Action<string> savePreference,
bool subscribeToSystemPreference)
private ThemeManager()
{
_isSystemDark = isSystemDark;
_savePreference = savePreference;
// Hydrate preference from the seam on first access. Disk / load
// failures fall back to defaults so the app always boots into a
// deterministic theme.
// Hydrate preference from disk on first access. UIPreferences.Load()
// is best-effort — disk failures fall back to defaults so the app
// always boots into a deterministic theme.
try
{
var loaded = loadPreference();
if (IsValidPreference(loaded))
var prefs = UIPreferences.Load();
if (IsValidPreference(prefs.Theme))
{
_preference = loaded!;
_preference = prefs.Theme;
}
}
catch
{
// Defensive — ctor must not throw or the app loses theming.
// Defensive — singleton ctor must not throw or the app loses theming.
}
// Re-evaluate when Windows app-mode flips, but only when the
// operator hasn't pinned a preference. The explicit choice wins.
// Tests opt out so they don't latch into a process-wide event.
if (subscribeToSystemPreference)
{
SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged;
}
SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged;
}
private string _preference = PreferenceKeySystem;
@ -98,7 +73,7 @@ public sealed class ThemeManager
{
PreferenceKeyDark => PreferenceKeyDark,
PreferenceKeyLight => PreferenceKeyLight,
_ => _isSystemDark() ? PreferenceKeyDark : PreferenceKeyLight,
_ => IsSystemDark() ? PreferenceKeyDark : PreferenceKeyLight,
};
/// <summary>
@ -114,7 +89,7 @@ public sealed class ThemeManager
}
_preference = preference;
try { _savePreference(preference); }
try { UIPreferences.SetTheme(preference); }
catch { /* persistence is best-effort */ }
Apply();
}
@ -169,7 +144,7 @@ public sealed class ThemeManager
}
}
var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Absolute) };
var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Relative) };
if (old is null)
{
dicts.Insert(0, fresh);
@ -185,10 +160,9 @@ public sealed class ThemeManager
/// <summary>
/// Read Windows' AppsUseLightTheme registry value. 1 = light, 0 = dark.
/// Returns true (dark) on any read failure — the dark scene is the
/// default per DESIGN.md so a missing value still lands somewhere
/// sensible. Backs the singleton's _isSystemDark seam.
/// default per DESIGN.md so a missing value still lands somewhere sensible.
/// </summary>
private static bool ReadSystemDarkFromRegistry()
private static bool IsSystemDark()
{
try
{
@ -206,31 +180,6 @@ public sealed class ThemeManager
return true;
}
/// <summary>
/// Load the operator's persisted theme preference from
/// %LOCALAPPDATA%\TeamsISO\ui-prefs.json. Returns null on any read
/// failure (missing file, corrupt JSON, schema mismatch) so the
/// caller falls back to the in-memory default of "System". Backs
/// the singleton's loadPreference seam.
/// </summary>
private static string? TryLoadPreferenceFromDisk()
{
try { return UIPreferences.Load().Theme; }
catch { return null; }
}
/// <summary>
/// Persist the operator's theme preference to ui-prefs.json. Errors
/// are swallowed — persistence is best-effort and a single failed
/// save shouldn't break the in-session UI experience. Backs the
/// singleton's savePreference seam.
/// </summary>
private static void TrySavePreferenceToDisk(string preference)
{
try { UIPreferences.SetTheme(preference); }
catch { /* best-effort */ }
}
private void OnSystemPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
{
if (e.Category != UserPreferenceCategory.General) return;

View file

@ -164,26 +164,15 @@ public static class UpdateChecker
return result;
}
/// <summary>
/// Test-only seam — when set, overrides the default
/// %LOCALAPPDATA%\TeamsISO path that holds the cooldown stamp +
/// the opt-out flag. Tests use this to write to a tempdir so
/// CheckIfDueAsync's throttle path can be exercised without
/// hitting real disk paths or the real network (the throttle
/// short-circuits before the HTTP call).
/// </summary>
internal static string? StateDirectoryOverride { get; set; }
private static string StateDirectory => StateDirectoryOverride ??
private static string CooldownPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO");
private static string CooldownPath =>
Path.Combine(StateDirectory, "last-update-check.txt");
"TeamsISO", "last-update-check.txt");
private static string OptOutPath =>
Path.Combine(StateDirectory, "no-update-check.flag");
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "no-update-check.flag");
/// <summary>
/// Whether launch-time update checks are enabled. Inverted-flag-file storage:
@ -230,10 +219,9 @@ public static class UpdateChecker
/// Parse a "vX.Y.Z" or "X.Y.Z(.N)" string into a Version. Strips any
/// pre-release suffix ("-alpha", "-beta") so the comparison is on
/// numeric components only — pre-release vs. release ordering is a
/// follow-up if we need it. Internal so tests can pin parsing
/// behaviour without HTTP.
/// follow-up if we need it.
/// </summary>
internal static Version? TryParseSemVer(string s)
private static Version? TryParseSemVer(string s)
{
var trimmed = s.TrimStart('v', 'V');
var dash = trimmed.IndexOf('-');

View file

@ -13,15 +13,7 @@ namespace TeamsISO.App.Services;
/// </summary>
public static class WindowStateStore
{
/// <summary>
/// Test-only seam — when set, overrides the default
/// %LOCALAPPDATA%\TeamsISO\window.json path. Lets tests verify
/// the serialization round-trip without polluting the dev's
/// real placement state.
/// </summary>
internal static string? PathOverride { get; set; }
private static string Path => PathOverride ??
private static readonly string Path =
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO",

View file

@ -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.
/// • TeamsEmbedHost.RestoreEmbed is idempotent — safe to call even if
/// • TeamsLauncher.RestoreEmbed is idempotent — safe to call even if
/// embedding never succeeded.
/// </summary>
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 (!TeamsEmbedHost.EmbedTeamsInto(src.Handle, w, h))
if (!TeamsLauncher.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.
TeamsEmbedHost.ResizeEmbedded((int)e.NewSize.Width, (int)e.NewSize.Height);
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 { TeamsEmbedHost.RestoreEmbed(); }
try { TeamsLauncher.RestoreEmbed(); }
catch { /* defensive — restore is best-effort */ }
}

View file

@ -39,17 +39,6 @@
</AssemblyAttribute>
</ItemGroup>
<!--
Strings.resx — user-facing English MessageBox copy. Embedded as a
.NET resource so ResourceManager can resolve TeamsISO.App.Properties.Strings
by basename. Strings.Designer.cs is hand-written (see file comment).
-->
<ItemGroup>
<EmbeddedResource Update="Properties\Strings.resx">
<LogicalName>TeamsISO.App.Properties.Strings.resources</LogicalName>
</EmbeddedResource>
</ItemGroup>
<!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. -->
<ItemGroup>
<Resource Include="Assets\dragon-mark.png" />

View file

@ -319,7 +319,11 @@
</Setter>
</Style>
<!-- ISO toggle: pill, status-coded -->
<!-- ISO toggle: rounded-rect (Radius.M) to match the rest of the button
family, status-coded background (LIVE cyan / ERROR coral / NO SIGNAL
amber). Previously a full pill (CornerRadius=999); pill made the LIVE
indicator visually distinct from the toolbar buttons in a way that
read as "different control type" rather than "different state". -->
<Style x:Key="Wd.Button.IsoToggle" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="11"/>
@ -340,7 +344,7 @@
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="999">
CornerRadius="{StaticResource Radius.M}">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="{TemplateBinding Padding}"/>

View file

@ -109,7 +109,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
: Visible.FirstOrDefault();
}
internal static bool Matches(PaletteCommand c, string query)
private static bool Matches(PaletteCommand c, string query)
{
if (c.Label.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
if (c.Category.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;

View file

@ -1,149 +0,0 @@
using TeamsISO.App.Services;
namespace TeamsISO.App.ViewModels;
// Bulk operations that touch every (or every-enabled) participant —
// Stop all ISOs, Enable all online, Snapshot all enabled frames.
// Split out of MainViewModel.cs so the main file isn't dominated by
// long async iteration loops.
//
// The RecordingCommands partial originally planned at this slot is
// intentionally absent: the recording surface was axed earlier in the
// May 2026 batch (see commit 1d1ce6a). What remains is bulk-state
// manipulation across the participants collection.
public sealed partial class MainViewModel
{
/// <summary>
/// Enable ISOs for every online + non-enabled participant in
/// parallel-ish (sequential await, but each individual EnableIsoAsync
/// is fast). Tolerates per-participant failures so one bad source
/// doesn't abort the rest.
/// </summary>
private async Task EnableAllOnlineAsync()
{
var candidates = Participants.Where(p => p.IsOnline && !p.IsEnabled).ToArray();
var enabled = 0;
foreach (var p in candidates)
{
try
{
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
? OutputNameTemplate.Render(
OutputNameTemplate.Get(),
p.Id,
p.DisplayName)
: p.CustomName;
// 3-arg overload (no recordOverride) — recording surface axed,
// so the engine's per-pipeline recorder sink stays unattached.
await _controller.EnableIsoAsync(p.Id, resolvedName, CancellationToken.None);
p.IsEnabled = true;
enabled++;
}
catch
{
// Per-participant best-effort — one bad source shouldn't
// abort the bulk operation.
}
}
Toast.Show(enabled == 0
? "No participants to enable"
: $"Enabled {enabled} ISO(s)");
}
/// <summary>
/// Emergency-stop: disable every running ISO. Confirmation dialog with
/// default-No guards mid-show misclicks; the regret cost of yanking 5
/// ISOs is far higher than the Enter-press cost of the prompt.
/// </summary>
private async Task StopAllIsosAsync()
{
// Snapshot first so the collection doesn't mutate while we iterate.
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
if (enabled.Length == 0)
{
Toast.Show("No ISOs to stop");
return;
}
var confirm = System.Windows.MessageBox.Show(
$"Stop {enabled.Length} running ISO(s)?\n\nThis tears down every active pipeline immediately.",
"TeamsISO — Stop all ISOs",
System.Windows.MessageBoxButton.YesNo,
System.Windows.MessageBoxImage.Warning,
System.Windows.MessageBoxResult.No);
if (confirm != System.Windows.MessageBoxResult.Yes) return;
foreach (var p in enabled)
{
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
catch { /* defensive */ }
p.IsEnabled = false;
}
Toast.Show($"Stopped {enabled.Length} ISO(s)");
}
/// <summary>
/// Save a PNG of every currently-enabled participant's latest
/// processed frame to a timestamped subdirectory under
/// %USERPROFILE%\Pictures\TeamsISO\. One folder per Snapshot All click
/// so back-to-back clicks don't comingle. Useful for end-of-meeting
/// archives, recapping who showed up, etc.
/// </summary>
private void SnapshotAll()
{
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
if (enabled.Length == 0)
{
Toast.Warn("No enabled participants to snapshot");
return;
}
var rootDir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
"TeamsISO",
$"snapshots-{DateTimeOffset.Now:yyyyMMdd_HHmmss}");
try
{
System.IO.Directory.CreateDirectory(rootDir);
}
catch (Exception ex)
{
Toast.Warn($"Couldn't create snapshot dir: {ex.Message}");
return;
}
var saved = 0;
var failed = 0;
foreach (var p in enabled)
{
try
{
var frame = _controller.GetLatestProcessedFrame(p.Id);
if (frame is null || frame.Pixels.IsEmpty) { failed++; continue; }
var safeName = string.Join("_", p.DisplayName.Split(System.IO.Path.GetInvalidFileNameChars()));
if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant";
var path = System.IO.Path.Combine(rootDir, $"{safeName}.png");
var stride = frame.Width * 4;
var bmp = new System.Windows.Media.Imaging.WriteableBitmap(
frame.Width, frame.Height, 96, 96,
System.Windows.Media.PixelFormats.Bgra32, null);
bmp.WritePixels(
new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
frame.Pixels.ToArray(), stride, 0);
using var fs = new System.IO.FileStream(path, System.IO.FileMode.Create);
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bmp));
encoder.Save(fs);
saved++;
}
catch { failed++; }
}
Toast.Show(failed > 0
? $"Saved {saved} snapshot(s) ({failed} failed) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}"
: $"Saved {saved} snapshot(s) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}");
}
}

View file

@ -1,108 +0,0 @@
using System.Windows.Threading;
using TeamsISO.App.Services;
namespace TeamsISO.App.ViewModels;
// Auto-apply-last-preset path. Split out of MainViewModel.cs so the
// pending-preset bookkeeping doesn't clutter the main file.
//
// Lifecycle:
// • InitializeAsync (in main file) reads operator preference + last-applied
// name from OperatorPresetStore and sets _pendingPresetName + deadline.
// • OnParticipantsChanged (in main file) calls TryAutoApplyPendingPreset
// once participants populate.
// • RequestApplyPresetOnStartup is the CLI hook: `--apply-preset NAME`.
public sealed partial class MainViewModel
{
// Set on InitializeAsync from disk; cleared once we successfully apply
// (so we don't re-apply when the participant list later mutates). The
// grace deadline gives Teams enough time to publish all initial sources
// after engine start before we attempt the apply — applying before
// everyone's visible would partially-restore the routing and silently
// drop assignments for late-appearing participants.
private string? _pendingPresetName;
private DateTimeOffset _pendingPresetDeadline;
private bool _pendingPresetApplied;
/// <summary>
/// CLI override for the launch-time auto-apply. Called from App.OnStartup
/// after parsing <c>--apply-preset</c>. Sets the same pending-preset state
/// that the user-toggled auto-apply path uses, so a single trigger flow
/// covers both. Wins over the persisted preference (operator's CLI intent
/// is more recent than what's on disk).
/// </summary>
public void RequestApplyPresetOnStartup(string presetName)
{
_pendingPresetName = presetName;
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
_pendingPresetApplied = false;
}
/// <summary>
/// Reads the operator's auto-apply preference + last-applied preset name
/// from disk and seeds the pending-preset state. Called by InitializeAsync
/// during engine startup. Failures are swallowed — a preset read fault
/// should never block the engine from coming up.
/// </summary>
private void LoadPendingPresetFromPreferences()
{
try
{
var pref = OperatorPresetStore.GetStartupPreference();
if (pref.AutoApplyOnStartup && !string.IsNullOrEmpty(pref.LastAppliedName))
{
_pendingPresetName = pref.LastAppliedName;
// 30s grace window is generous: Teams typically advertises all
// existing participants within 510s of NDI discovery starting.
// After this deadline we apply with whoever is visible.
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
}
}
catch { /* preset read failures shouldn't block engine startup */ }
}
/// <summary>
/// Attempts to apply <c>_pendingPresetName</c> if either every preset
/// assignment matches a live participant, or the grace deadline has
/// passed. Idempotent — repeat calls without state change are no-ops;
/// once we fire we flag <c>_pendingPresetApplied</c> so subsequent
/// participant churn doesn't trigger a second apply. Failures (missing
/// preset on disk, preset that no longer matches anyone) are swallowed:
/// the operator can always re-apply manually via the dialog. Delegates
/// to <see cref="PresetApplier.ApplyAsync"/> for the actual
/// reconciliation so the dialog, REST surface, and this auto-apply path
/// all share a single implementation.
/// </summary>
private void TryAutoApplyPendingPreset()
{
OperatorPresetStore.Preset? preset;
try { preset = OperatorPresetStore.Find(_pendingPresetName!); }
catch { preset = null; }
if (preset is null)
{
_pendingPresetApplied = true; // give up; nothing on disk to apply
return;
}
var liveNames = new HashSet<string>(
Participants.Select(p => p.DisplayName),
StringComparer.OrdinalIgnoreCase);
var allPresent = preset.Assignments.All(a => liveNames.Contains(a.DisplayName));
if (!allPresent && DateTimeOffset.UtcNow < _pendingPresetDeadline)
return; // wait for the rest of the meeting to populate
_pendingPresetApplied = true;
var captured = preset;
// Snapshot the participants list since we're about to await on a
// worker thread; the live ObservableCollection isn't safe to
// enumerate from outside the dispatcher.
var snapshot = Participants.ToList();
_ = Task.Run(async () =>
{
var result = await PresetApplier.ApplyAsync(
captured, snapshot, _controller, _dispatcher);
await _dispatcher.InvokeAsync(() =>
Toast.Show($"Auto-applied preset \"{captured.Name}\" — {result.Matched} participant(s)"));
});
}
}

View file

@ -1,130 +0,0 @@
using System.Windows.Threading;
using TeamsISO.App.Services;
namespace TeamsISO.App.ViewModels;
// Teams launch / in-call / join-by-URL command helpers — split out of
// MainViewModel.cs so the body methods don't live alongside the
// constructor wiring + reactive subscriptions. The four command
// PROPERTIES are declared back in MainViewModel.cs (public API surface);
// this file holds the helpers they invoke.
public sealed partial class MainViewModel
{
/// <summary>
/// Wraps a <see cref="TeamsControlBridge"/> invocation in a RelayCommand
/// that translates the result to a user-visible toast. Centralizes the
/// toast wording so the four control commands stay consistent.
/// </summary>
private RelayCommand MakeTeamsCommand(string label, Func<TeamsControlBridge.InvokeResult> invoke, string successMessage)
{
return new RelayCommand(() =>
{
switch (invoke())
{
case TeamsControlBridge.InvokeResult.Invoked:
Toast.Show(successMessage);
break;
case TeamsControlBridge.InvokeResult.TeamsNotRunning:
Toast.Warn("Teams isn't running.");
break;
case TeamsControlBridge.InvokeResult.ControlNotFound:
Toast.Warn($"{label} control not found — are you in a call?");
break;
case TeamsControlBridge.InvokeResult.InvokeFailed:
Toast.Warn($"{label} button found but disabled.");
break;
}
});
}
/// <summary>
/// Body of <c>JoinMeetingCommand</c>. Trims the pasted URL, hands it to
/// <see cref="TeamsLauncher.TryJoinMeeting"/>, and runs the auto-hide
/// follow-up if the operator has that preference set.
/// </summary>
private void JoinPastedMeeting()
{
var url = (_joinMeetingUrl ?? string.Empty).Trim();
if (string.IsNullOrEmpty(url)) return;
if (TeamsLauncher.TryJoinMeeting(url, out var error))
{
Toast.Show("Joining Teams meeting…");
JoinMeetingUrl = string.Empty;
if (Settings.AutoHideTeamsWindows)
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
}
else
{
Toast.Warn($"Could not join: {error}");
}
}
/// <summary>
/// Pull the meaningful "meeting title" out of Teams' raw window title.
/// Teams uses formats like:
/// "Weekly Standup | Microsoft Teams"
/// "Meeting with Alice | Microsoft Teams"
/// "Microsoft Teams" (no meeting, just the app)
/// Strip the trailing " | Microsoft Teams" so the IN-CALL pill stays
/// short and readable. Truncate beyond 50 chars so a long meeting
/// subject doesn't push the rest of the IN-CALL bar off screen.
/// </summary>
internal static string ExtractMeetingTitle(string windowTitle)
{
if (string.IsNullOrWhiteSpace(windowTitle)) return string.Empty;
var t = windowTitle.Trim();
foreach (var sep in new[] { " | Microsoft Teams", " | Teams", " - Microsoft Teams", " - Teams" })
{
var idx = t.IndexOf(sep, StringComparison.Ordinal);
if (idx > 0) { t = t.Substring(0, idx).Trim(); break; }
}
if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty;
if (t.Length > 50) t = t.Substring(0, 47) + "…";
return t;
}
/// <summary>
/// Meeting-state probe — runs on every 1Hz stats tick. We fire the UIA
/// traversal on a worker thread because it can take 50200ms in a busy
/// call; the result is marshalled back to the dispatcher to update the
/// view-model properties. One-tick latency on the displayed state is
/// preferable to a UI hiccup.
/// </summary>
private void PollTeamsMeetingState()
{
try
{
var teamsRunning = TeamsLauncher.IsRunning();
if (!teamsRunning)
{
TeamsMeetingState = string.Empty;
IsTeamsInCall = false;
return;
}
_ = Task.Run(() =>
{
try
{
// Single UIA traversal returns all three signals (in-call /
// muted / camera-off) so we don't pay for three walks of
// the same descendant tree at 1Hz.
var snap = TeamsControlBridge.DetectCallState();
var inCall = snap.IsInCall;
var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
_dispatcher.InvokeAsync(() =>
{
IsTeamsInCall = inCall;
TeamsMeetingState = inCall
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
: "READY";
IsLocalMuted = inCall && (snap.IsMuted ?? false);
IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false);
});
}
catch { /* UIA flakiness shouldn't crash the stats tick */ }
});
}
catch { /* defensive — probe failures must never break the tick */ }
}
}

View file

@ -14,14 +14,8 @@ namespace TeamsISO.App.ViewModels;
/// Top-level view model for the main window. Owns the live collection of <see cref="ParticipantViewModel"/>,
/// the global settings panel, and the alert banner. Subscribes to <see cref="IIsoController"/>'s observables
/// and marshals updates onto the UI dispatcher.
///
/// Split across partial files by responsibility:
/// • <c>MainViewModel.cs</c> — fields, properties, constructor (wires commands), OnStatsTick, Dispose
/// • <c>MainViewModel.TeamsCommands.cs</c> — Mute / Cam / Leave / Share + Join URL + Teams meeting-state poll
/// • <c>MainViewModel.PresetCommands.cs</c> — auto-apply-last-preset path
/// • <c>MainViewModel.BulkCommands.cs</c> — Stop all / Enable all / Snapshot all
/// </summary>
public sealed partial class MainViewModel : ObservableObject, IDisposable
public sealed class MainViewModel : ObservableObject, IDisposable
{
private readonly IIsoController _controller;
private readonly Dispatcher _dispatcher;
@ -31,8 +25,15 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
private string _statusText = "Starting…";
// _pendingPresetName / Deadline / Applied + the auto-apply path
// moved to MainViewModel.PresetCommands.cs.
// Auto-apply-last-preset bookkeeping. Set on InitializeAsync from disk;
// cleared once we successfully apply (so we don't re-apply when the
// participant list later mutates). The grace deadline gives Teams enough
// time to publish all initial sources after engine start before we attempt
// the apply — applying before everyone's visible would partially-restore
// the routing and silently drop assignments for late-appearing participants.
private string? _pendingPresetName;
private DateTimeOffset _pendingPresetDeadline;
private bool _pendingPresetApplied;
public ObservableCollection<ParticipantViewModel> Participants { get; } = new();
@ -430,7 +431,25 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
}
});
JoinMeetingCommand = new RelayCommand(JoinPastedMeeting);
JoinMeetingCommand = new RelayCommand(() =>
{
// Trim + handle the operator pasting whitespace around the URL.
var url = (_joinMeetingUrl ?? string.Empty).Trim();
if (string.IsNullOrEmpty(url)) return;
if (TeamsLauncher.TryJoinMeeting(url, out var error))
{
Toast.Show("Joining Teams meeting…");
JoinMeetingUrl = string.Empty;
// If the operator has auto-hide on, kick off the hide watcher
// so the Teams meeting window goes away as soon as it renders.
if (Settings.AutoHideTeamsWindows)
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
}
else
{
Toast.Warn($"Could not join: {error}");
}
});
ToggleMuteCommand = MakeTeamsCommand(
label: "Mute",
@ -450,13 +469,202 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
successMessage: "Opened share tray");
}
// Body methods extracted to themed partial files:
// MainViewModel.BulkCommands.cs — EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll
// MainViewModel.TeamsCommands.cs — MakeTeamsCommand, JoinPastedMeeting,
// ExtractMeetingTitle, PollTeamsMeetingState
// MainViewModel.PresetCommands.cs — RequestApplyPresetOnStartup,
// LoadPendingPresetFromPreferences,
// TryAutoApplyPendingPreset
/// <summary>
/// Wraps a <see cref="TeamsControlBridge"/> invocation in a RelayCommand that
/// translates the result to a user-visible toast. Centralizes the toast wording
/// so the four control commands stay consistent.
/// </summary>
private RelayCommand MakeTeamsCommand(string label, Func<TeamsControlBridge.InvokeResult> invoke, string successMessage)
{
return new RelayCommand(() =>
{
switch (invoke())
{
case TeamsControlBridge.InvokeResult.Invoked:
Toast.Show(successMessage);
break;
case TeamsControlBridge.InvokeResult.TeamsNotRunning:
Toast.Warn("Teams isn't running.");
break;
case TeamsControlBridge.InvokeResult.ControlNotFound:
Toast.Warn($"{label} control not found — are you in a call?");
break;
case TeamsControlBridge.InvokeResult.InvokeFailed:
Toast.Warn($"{label} button found but disabled.");
break;
}
});
}
/// <summary>
/// Issues DisableIsoAsync for every participant whose ISO is currently enabled.
/// Each disable is awaited sequentially so we don't try to tear down N pipelines
/// in parallel and trip channel-completion races; for ~10 participants this is
/// still sub-second total. Failures are swallowed (best-effort emergency stop).
/// </summary>
// RollRecordingAsync removed — recording feature axed.
/// <summary>
/// Enable ISOs for every online + non-enabled participant in parallel-ish
/// (sequential await, but each individual EnableIsoAsync is fast). Tolerates
/// per-participant failures so one bad source doesn't abort the rest.
/// </summary>
private async Task EnableAllOnlineAsync()
{
var candidates = Participants.Where(p => p.IsOnline && !p.IsEnabled).ToArray();
var enabled = 0;
foreach (var p in candidates)
{
try
{
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
p.Id,
p.DisplayName)
: p.CustomName;
// 3-arg overload (no recordOverride) — recording surface axed,
// so the engine's per-pipeline recorder sink stays unattached.
await _controller.EnableIsoAsync(p.Id, resolvedName, CancellationToken.None);
p.IsEnabled = true;
enabled++;
}
catch
{
// Per-participant best-effort — one bad source shouldn't abort the bulk operation.
}
}
Toast.Show(enabled == 0
? "No participants to enable"
: $"Enabled {enabled} ISO(s)");
}
private async Task StopAllIsosAsync()
{
// Snapshot first so the collection doesn't mutate while we iterate.
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
if (enabled.Length == 0)
{
Toast.Show("No ISOs to stop");
return;
}
// Confirm before tearing down — this button is an "emergency stop" but
// mis-clicks during a show are easy. The dialog cost is negligible
// (one Enter press) and the regret cost is huge (yanking 5 ISOs mid-
// broadcast). Default selection is No so accidental hits cancel.
var confirm = System.Windows.MessageBox.Show(
$"Stop {enabled.Length} running ISO(s)?\n\nThis tears down every active pipeline immediately.",
"TeamsISO — Stop all ISOs",
System.Windows.MessageBoxButton.YesNo,
System.Windows.MessageBoxImage.Warning,
System.Windows.MessageBoxResult.No);
if (confirm != System.Windows.MessageBoxResult.Yes) return;
foreach (var p in enabled)
{
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
catch { /* defensive */ }
p.IsEnabled = false;
}
Toast.Show($"Stopped {enabled.Length} ISO(s)");
}
/// <summary>
/// Save a PNG of every currently-enabled participant's latest processed
/// frame to a timestamped subdirectory under
/// %USERPROFILE%\Pictures\TeamsISO\. One folder per Snapshot All click
/// so back-to-back clicks don't comingle. Useful for end-of-meeting
/// archives, recapping who showed up, etc.
/// </summary>
private void SnapshotAll()
{
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
if (enabled.Length == 0)
{
Toast.Warn("No enabled participants to snapshot");
return;
}
var rootDir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
"TeamsISO",
$"snapshots-{DateTimeOffset.Now:yyyyMMdd_HHmmss}");
try
{
System.IO.Directory.CreateDirectory(rootDir);
}
catch (Exception ex)
{
Toast.Warn($"Couldn't create snapshot dir: {ex.Message}");
return;
}
var saved = 0;
var failed = 0;
foreach (var p in enabled)
{
try
{
var frame = _controller.GetLatestProcessedFrame(p.Id);
if (frame is null || frame.Pixels.IsEmpty) { failed++; continue; }
var safeName = string.Join("_", p.DisplayName.Split(System.IO.Path.GetInvalidFileNameChars()));
if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant";
var path = System.IO.Path.Combine(rootDir, $"{safeName}.png");
var stride = frame.Width * 4;
var bmp = new System.Windows.Media.Imaging.WriteableBitmap(
frame.Width, frame.Height, 96, 96,
System.Windows.Media.PixelFormats.Bgra32, null);
bmp.WritePixels(
new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
frame.Pixels.ToArray(), stride, 0);
using var fs = new System.IO.FileStream(path, System.IO.FileMode.Create);
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bmp));
encoder.Save(fs);
saved++;
}
catch { failed++; }
}
Toast.Show(failed > 0
? $"Saved {saved} snapshot(s) ({failed} failed) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}"
: $"Saved {saved} snapshot(s) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}");
}
// FormatBytes removed — its only caller was the recording free-space footer
// label, which went away with the rest of the recording surface.
/// <summary>
/// Pull the meaningful "meeting title" out of Teams' raw window title.
/// Teams uses formats like:
/// "Weekly Standup | Microsoft Teams"
/// "Meeting with Alice | Microsoft Teams"
/// "Microsoft Teams" (no meeting, just the app)
/// Strip the trailing " | Microsoft Teams" so the IN-CALL pill stays
/// short and readable. Truncate beyond 50 chars so a long meeting
/// subject doesn't push the rest of the IN-CALL bar off screen.
/// </summary>
internal static string ExtractMeetingTitle(string windowTitle)
{
if (string.IsNullOrWhiteSpace(windowTitle)) return string.Empty;
var t = windowTitle.Trim();
// Common separator patterns Teams uses across locales.
foreach (var sep in new[] { " | Microsoft Teams", " | Teams", " - Microsoft Teams", " - Teams" })
{
var idx = t.IndexOf(sep, StringComparison.Ordinal);
if (idx > 0) { t = t.Substring(0, idx).Trim(); break; }
}
// If after stripping we're left with just "Microsoft Teams" the
// window has no meeting context — return empty so the pill stays
// at "IN CALL" without a stale title.
if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty;
if (t.Length > 50) t = t.Substring(0, 47) + "…";
return t;
}
private void OnStatsTick(object? sender, EventArgs e)
{
@ -569,10 +777,52 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
}
// Teams meeting state — UIA traversal at 1Hz; off-thread so a slow
// UIA call doesn't stall the UI tick. Implementation in
// MainViewModel.TeamsCommands.cs.
PollTeamsMeetingState();
// Teams meeting state — UIA traversal at 1Hz. We probe by looking
// for the Leave button in Teams' automation tree (present iff in a
// call) and surface the result as a status pill in the IN-CALL bar.
// Offloaded to a Task so a slow UIA call doesn't stall the UI tick;
// the property update is dispatched back here on next tick.
try
{
var teamsRunning = TeamsLauncher.IsRunning();
if (!teamsRunning)
{
TeamsMeetingState = string.Empty;
IsTeamsInCall = false;
}
else
{
// Fire the UIA probe off-thread — it walks the full descendant
// tree of every Teams window and can take 50-200ms in a busy
// call. We can tolerate one-tick latency on the displayed
// state much more easily than a UI hiccup.
_ = Task.Run(() =>
{
try
{
// Single UIA traversal returns all three signals — in-call,
// muted, camera-off — so we don't pay for three walks of
// the same descendant tree at 1Hz.
var snap = TeamsControlBridge.DetectCallState();
var inCall = snap.IsInCall;
var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
_dispatcher.InvokeAsync(() =>
{
IsTeamsInCall = inCall;
TeamsMeetingState = inCall
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
: "READY";
// Mute / camera state — only meaningful in-call.
IsLocalMuted = inCall && (snap.IsMuted ?? false);
IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false);
// Auto-record-on-call hook removed alongside recording feature.
});
}
catch { /* UIA flakiness shouldn't crash the stats tick */ }
});
}
}
catch { /* defensive — probe failures must never break the tick */ }
// Control-surface state — peek at App's owned services.
var app = System.Windows.Application.Current as App;
@ -603,12 +853,36 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
await _controller.StartAsync(cancellationToken);
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
// Auto-apply last preset bookkeeping. We don't apply here —
// participants haven't been discovered yet — instead we record
// the intent and let OnParticipantsChanged trigger the apply
// once the meeting has populated. Implementation in
// MainViewModel.PresetCommands.cs.
LoadPendingPresetFromPreferences();
// Auto-apply last preset bookkeeping. We don't apply here — participants
// haven't been discovered yet — instead we record the intent and let
// OnParticipantsChanged trigger the apply once the meeting has populated.
try
{
var pref = Services.OperatorPresetStore.GetStartupPreference();
if (pref.AutoApplyOnStartup && !string.IsNullOrEmpty(pref.LastAppliedName))
{
_pendingPresetName = pref.LastAppliedName;
// 30s grace window is generous: Teams typically advertises all
// existing participants within 510s of NDI discovery starting.
// After this deadline we apply with whoever is visible.
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
}
}
catch { /* preset read failures shouldn't block engine startup */ }
}
/// <summary>
/// CLI override for the launch-time auto-apply. Called from App.OnStartup
/// after parsing <c>--apply-preset</c>. Sets the same pending-preset state
/// that the user-toggled auto-apply path uses, so a single trigger flow
/// covers both. Wins over the persisted preference (operator's CLI intent
/// is more recent than what's on disk).
/// </summary>
public void RequestApplyPresetOnStartup(string presetName)
{
_pendingPresetName = presetName;
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
_pendingPresetApplied = false;
}
private void OnParticipantsChanged(IReadOnlyList<Participant> incoming)
@ -686,6 +960,50 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
}
}
/// <summary>
/// Attempts to apply <see cref="_pendingPresetName"/> if either every preset
/// assignment matches a live participant, or the grace deadline has passed.
/// Idempotent — repeat calls without state change are no-ops; once we fire we
/// flag <c>_pendingPresetApplied</c> so subsequent participant churn doesn't
/// trigger a second apply. Failures (missing preset on disk, preset that no
/// longer matches anyone) are swallowed: the operator can always re-apply
/// manually via the dialog. Delegates to <see cref="PresetApplier.ApplyAsync"/>
/// for the actual reconciliation so the dialog, REST surface, and this auto-
/// apply path all share a single implementation.
/// </summary>
private void TryAutoApplyPendingPreset()
{
Services.OperatorPresetStore.Preset? preset;
try { preset = Services.OperatorPresetStore.Find(_pendingPresetName!); }
catch { preset = null; }
if (preset is null)
{
_pendingPresetApplied = true; // give up; nothing on disk to apply
return;
}
var liveNames = new HashSet<string>(
Participants.Select(p => p.DisplayName),
StringComparer.OrdinalIgnoreCase);
var allPresent = preset.Assignments.All(a => liveNames.Contains(a.DisplayName));
if (!allPresent && DateTimeOffset.UtcNow < _pendingPresetDeadline)
return; // wait for the rest of the meeting to populate
_pendingPresetApplied = true;
var captured = preset;
// Snapshot the participants list since we're about to await on a worker
// thread; the live ObservableCollection isn't safe to enumerate from
// outside the dispatcher.
var snapshot = Participants.ToList();
_ = Task.Run(async () =>
{
var result = await PresetApplier.ApplyAsync(
captured, snapshot, _controller, _dispatcher);
await _dispatcher.InvokeAsync(() =>
Toast.Show($"Auto-applied preset \"{captured.Name}\" — {result.Matched} participant(s)"));
});
}
private static bool IsLocalSelf(Participant p) =>
string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);

View file

@ -1,107 +0,0 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
using TeamsISO.Engine.Pipeline;
namespace TeamsISO.App.Tests.Fakes;
// Minimal IIsoController stub for tests that need to instantiate
// services in the App layer (ControlSurfaceServer, OscBridge, etc.)
// without spinning up the real engine + NDI runtime.
//
// Everything is a sensible no-op default; tests that need a specific
// behaviour (e.g. "EnableIsoAsync was called with these args") subclass
// or replace methods via the action hooks.
internal sealed class StubIsoController : IIsoController
{
private readonly BehaviorSubject<IReadOnlyList<Participant>> _participants =
new(Array.Empty<Participant>());
private readonly BehaviorSubject<EngineAlert?> _alerts = new(default);
public IObservable<IReadOnlyList<Participant>> Participants => _participants;
public IObservable<EngineAlert> Alerts => _alerts.Where(a => a is not null)!;
public FrameProcessingSettings GlobalSettings { get; set; } = new(
TargetFramerate.Fps30, TargetResolution.R1080p, AspectMode.Letterbox, AudioMode.Auto);
public NdiGroupSettings GroupSettings { get; set; } = new(
DiscoveryGroups: null, OutputGroups: null);
public bool RecordingEnabled { get; private set; }
public string? RecordingDirectory { get; private set; }
public Func<Guid, IsoHealthStats>? GetStatsHandler { get; set; }
public Func<Guid, ProcessedFrame?>? GetLatestProcessedFrameHandler { get; set; }
public Func<Guid, FrameProcessingSettings?>? GetIsoOverrideHandler { get; set; }
public IsoHealthStats GetStats(Guid participantId) =>
GetStatsHandler?.Invoke(participantId) ?? IsoHealthStats.Empty;
public ProcessedFrame? GetLatestProcessedFrame(Guid participantId) =>
GetLatestProcessedFrameHandler?.Invoke(participantId);
public FrameProcessingSettings? GetIsoOverride(Guid participantId) =>
GetIsoOverrideHandler?.Invoke(participantId);
public List<(Guid Id, string? Name)> EnableCalls { get; } = new();
public List<Guid> DisableCalls { get; } = new();
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken cancellationToken)
{
EnableCalls.Add((participantId, customName));
return Task.CompletedTask;
}
public Task EnableIsoAsync(Guid participantId, string? customName, bool? recordOverride, CancellationToken cancellationToken)
{
EnableCalls.Add((participantId, customName));
return Task.CompletedTask;
}
public Task DisableIsoAsync(Guid participantId, CancellationToken cancellationToken)
{
DisableCalls.Add(participantId);
return Task.CompletedTask;
}
public Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken cancellationToken)
{
GlobalSettings = settings;
return Task.CompletedTask;
}
public Task SetIsoOverrideAsync(Guid participantId, FrameProcessingSettings? settings, CancellationToken cancellationToken) =>
Task.CompletedTask;
public Task SetGroupSettingsAsync(NdiGroupSettings groupSettings, CancellationToken cancellationToken)
{
GroupSettings = groupSettings;
return Task.CompletedTask;
}
public bool RefreshDiscoveryCalled { get; private set; }
public void RefreshDiscovery() => RefreshDiscoveryCalled = true;
public void SetRecording(bool enabled, string? outputDirectory)
{
RecordingEnabled = enabled;
RecordingDirectory = outputDirectory;
}
public void AddRecordingMarker(string label) { /* no-op for stub */ }
public ValueTask DisposeAsync()
{
_participants.Dispose();
_alerts.Dispose();
return ValueTask.CompletedTask;
}
// Used by tests to push synthetic participant snapshots through the
// observable chain.
public void PublishParticipants(params Participant[] participants) =>
_participants.OnNext(participants);
}

View file

@ -1,200 +0,0 @@
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Text.Json;
using System.Windows;
using System.Windows.Media;
using FluentAssertions;
using TeamsISO.App.Services;
using TeamsISO.App.Tests.Fakes;
using TeamsISO.App.ViewModels;
using TeamsISO.Engine.Domain;
using Xunit;
namespace TeamsISO.App.Tests.Integration;
// End-to-end-ish integration tests that need a live WPF Application +
// STA dispatcher. All three live in one class + share a
// WpfHostFixture so Application is created exactly once for the
// suite (Application is one-per-AppDomain — multiple test classes
// trying to construct it independently collide).
//
// Coverage per the punch list:
// • App-startup headless smoke — construct App's bootstrap layers
// on STA, verify XAML resource resolution + theme apply + VM
// wiring + MainWindow construction.
// • ControlSurface integration — boot the server on an ephemeral
// port, populate a real view-model, hit /participants, verify
// the JSON includes the live participant.
// • Theme swap — Dark → Light dictionary swap, brush key resolves
// to a different value afterward.
[Collection(WpfHostCollection.Name)]
public sealed class IntegrationTests
{
private readonly WpfHostFixture _wpf;
public IntegrationTests(WpfHostFixture wpf) => _wpf = wpf;
private static int PickFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try { return ((IPEndPoint)listener.LocalEndpoint).Port; }
finally { listener.Stop(); }
}
private async Task SeedDarkThemeAsync()
{
await _wpf.Run(() =>
{
var dicts = _wpf.Application.Resources.MergedDictionaries;
dicts.Clear();
dicts.Add(new ResourceDictionary
{
Source = new Uri("pack://application:,,,/TeamsISO;component/Themes/Theme.Dark.xaml", UriKind.Absolute),
});
});
}
[Fact]
public async Task ThemeXaml_DarkAndLight_BothLoadWithDistinctWdCanvas()
{
// Verifies the real XAML files load via pack URIs (the
// production code path) and that the two theme files
// produce different brushes for the same key. End-to-end
// exercise of the resource pipeline that doesn't depend on
// Application.Resources global state — both dicts are
// loaded fresh in this call.
//
// We don't test ThemeManager.SwapColorDictionary here
// because Application.Resources is process-wide and
// sibling-test mutations make the state observably non-
// deterministic in xUnit's parallel-collection model;
// ThemeManagerTests (Services/) cover the swap state
// machine against stubbed seams. This test guards the
// distinct-XAML-files claim, which is what would otherwise
// get refactored out by accident.
await _wpf.Run(() =>
{
var darkDict = new ResourceDictionary
{
Source = new Uri(
"pack://application:,,,/TeamsISO;component/Themes/Theme.Dark.xaml",
UriKind.Absolute),
};
var lightDict = new ResourceDictionary
{
Source = new Uri(
"pack://application:,,,/TeamsISO;component/Themes/Theme.Light.xaml",
UriKind.Absolute),
};
var darkCanvas = ((SolidColorBrush)darkDict ["Wd.Canvas"]).Color;
var lightCanvas = ((SolidColorBrush)lightDict["Wd.Canvas"]).Color;
darkCanvas.Should().Be(Color.FromRgb(0x0A, 0x0A, 0x0A),
"Theme.Dark.xaml's Wd.Canvas is the documented #0A0A0A");
lightCanvas.Should().Be(Color.FromRgb(0xFA, 0xFA, 0xFB),
"Theme.Light.xaml's Wd.Canvas is the documented #FAFAFB");
});
}
[Fact]
public async Task AppStartup_FullChain_Constructs_WithoutThrowing()
{
// Headless smoke for the App.OnStartup wiring sequence:
// 1. Application + theme resources are loaded.
// 2. ThemeManager.Apply() resolves brush keys end-to-end.
// 3. MainViewModel constructs against a stub controller.
// 4. MainWindow ctor resolves DataContext + finds the brushes
// its templates reference.
await SeedDarkThemeAsync();
await _wpf.Run(() =>
{
_wpf.Application.Resources.MergedDictionaries.Add(new ResourceDictionary
{
Source = new Uri(
"pack://application:,,,/TeamsISO;component/Themes/WildDragonTheme.xaml",
UriKind.Absolute),
});
});
// Everything DependencyObject-touching has to run on the STA
// dispatcher (Window / DataContext / TryFindResource all
// VerifyAccess). Do the assertions inside the Run callback so
// we never marshal a DependencyObject reference back to the
// test thread.
await _wpf.Run(() =>
{
var tm = new ThemeManager(
isSystemDark: () => true,
loadPreference: () => "Dark",
savePreference: _ => { },
subscribeToSystemPreference: false);
tm.Apply();
var controller = new StubIsoController();
var vm = new MainViewModel(controller, _wpf.Dispatcher);
try
{
var window = new MainWindow(vm);
vm.Settings.Should().NotBeNull("MainViewModel wires GlobalSettingsViewModel");
vm.AlertBanner.Should().NotBeNull();
window.DataContext.Should().BeSameAs(vm);
window.TryFindResource("Wd.Canvas").Should().NotBeNull(
"Wd.Canvas is defined in Theme.Dark.xaml and used by MainWindow.xaml");
}
finally
{
vm.Dispose();
}
});
}
[Fact]
public async Task ControlSurface_GetParticipants_ReturnsLiveViewModelState()
{
var controller = new StubIsoController();
var vm = await _wpf.Run(() => new MainViewModel(controller, _wpf.Dispatcher));
// Publish a participant through the controller observable and
// wait for the dispatcher to drain the InvokeAsync(Background)
// marshal that adds Alice to the Participants collection.
controller.PublishParticipants(new Participant(
Id: Guid.NewGuid(),
DisplayName: "Alice",
CurrentSource: null,
FirstSeen: DateTimeOffset.UtcNow,
LastSeen: DateTimeOffset.UtcNow));
// Drain the queue at ApplicationIdle so the Background-priority
// add has time to complete before we look.
await _wpf.Dispatcher.InvokeAsync(() => { },
System.Windows.Threading.DispatcherPriority.ApplicationIdle).Task;
var server = new ControlSurfaceServer(controller, () => vm, logger: null);
var port = PickFreePort();
server.Start(port);
await Task.Delay(50);
using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") };
try
{
var res = await client.GetAsync("/participants");
res.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await res.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var participants = doc.RootElement.GetProperty("participants");
participants.GetArrayLength().Should().Be(1);
participants[0].GetProperty("displayName").GetString().Should().Be("Alice");
}
finally
{
server.Stop();
await _wpf.Run(() => vm.Dispose());
await controller.DisposeAsync();
}
}
}

View file

@ -1,87 +0,0 @@
using System.Threading;
using System.Windows;
using System.Windows.Threading;
namespace TeamsISO.App.Tests.Integration;
/// <summary>
/// Shared WPF Application + STA dispatcher fixture. Created once for
/// every integration test class that asks for it; all test methods
/// post their work to the fixture's dispatcher via <see cref="Run"/>.
///
/// Rationale: <see cref="Application"/> is one-per-AppDomain. Tests
/// that each instantiate their own (or use Xunit.StaFact's per-test
/// STA) collide on the second call ("Cannot create more than one
/// Application instance in the same AppDomain"). A long-lived
/// fixture creates exactly one Application on a dedicated STA thread
/// and reuses its dispatcher for the lifetime of the test class.
/// </summary>
public sealed class WpfHostFixture : IDisposable
{
private readonly Thread _uiThread;
private readonly ManualResetEventSlim _ready = new(false);
private Dispatcher? _dispatcher;
private Application? _application;
private Exception? _initFailure;
public WpfHostFixture()
{
_uiThread = new Thread(() =>
{
try
{
// Application is process-singleton; only construct if the
// current AppDomain hasn't already minted one (e.g. another
// fixture in the same run).
_application = Application.Current ?? new Application();
_dispatcher = Dispatcher.CurrentDispatcher;
_ready.Set();
Dispatcher.Run();
}
catch (Exception ex)
{
_initFailure = ex;
_ready.Set();
}
});
_uiThread.SetApartmentState(ApartmentState.STA);
_uiThread.IsBackground = true;
_uiThread.Start();
_ready.Wait();
if (_initFailure is not null)
throw new InvalidOperationException("WPF host thread failed to initialise.", _initFailure);
}
public Application Application => _application!;
public Dispatcher Dispatcher => _dispatcher!;
/// <summary>
/// Marshal <paramref name="work"/> onto the fixture's STA dispatcher
/// and await its completion. Exceptions inside <paramref name="work"/>
/// surface back to the caller intact.
/// </summary>
public Task<T> Run<T>(Func<T> work) =>
_dispatcher!.InvokeAsync(work).Task;
public Task Run(Action work) =>
_dispatcher!.InvokeAsync(work).Task;
public void Dispose()
{
try { _dispatcher?.InvokeShutdown(); } catch { /* defensive */ }
try { _uiThread.Join(TimeSpan.FromSeconds(2)); } catch { /* defensive */ }
_ready.Dispose();
}
}
/// <summary>
/// Marks an integration test class as sharing the single
/// <see cref="WpfHostFixture"/> Application + Dispatcher. xUnit
/// instantiates the fixture once per collection and injects it via
/// constructor.
/// </summary>
[CollectionDefinition(Name)]
public sealed class WpfHostCollection : ICollectionFixture<WpfHostFixture>
{
public const string Name = "WpfHost (shared Application + Dispatcher)";
}

View file

@ -1,201 +0,0 @@
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using FluentAssertions;
using TeamsISO.App.Services;
using TeamsISO.App.Tests.Fakes;
namespace TeamsISO.App.Tests.Services;
// End-to-end-ish smoke tests for ControlSurfaceServer. Each test boots
// the server on an OS-assigned free port (127.0.0.1 only — no urlacl
// required), makes a real HTTP request via HttpClient, and asserts
// against the response. The tests share a StubIsoController and a
// null view-model — endpoints that need a UI dispatcher degrade
// gracefully (return empty arrays) which is enough to verify the
// route table.
//
// We don't exercise the WebSocket path here — ClientWebSocket adds
// non-trivial timing complexity and the upgrade is verified by the
// 426/101 status arc of `/ws` on a non-WS GET (we hit it and confirm
// the server doesn't 500).
public sealed class ControlSurfaceServerTests
{
private static int PickFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try { return ((IPEndPoint)listener.LocalEndpoint).Port; }
finally { listener.Stop(); }
}
private static async Task<(ControlSurfaceServer Server, HttpClient Client, int Port)> BootAsync()
{
var controller = new StubIsoController();
var server = new ControlSurfaceServer(controller, () => null, logger: null);
var port = PickFreePort();
server.Start(port, bindToLan: false);
// HttpListener accepts on a background task; give it a beat so
// the first request doesn't race the bind.
await Task.Delay(50);
var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") };
return (server, client, port);
}
[Fact]
public async Task GetRoot_Returns200_WithServerInfoBody()
{
var (server, client, _) = await BootAsync();
try
{
var res = await client.GetAsync("/");
res.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await res.Content.ReadAsStringAsync();
body.Should().Contain("\"product\":\"TeamsISO\"");
body.Should().Contain("\"endpoints\"");
}
finally
{
server.Stop();
client.Dispose();
}
}
[Fact]
public async Task GetUnknownPath_Returns200_WithErrorBody()
{
// Quirk: the route table's catch-all arm returns NotFound() (an
// object {error:"not found"}) rather than null, so the response
// pipeline writes 200 OK with that body instead of branching to
// 404. The body is the disambiguator, matching the rest of the
// surface's "200 + {ok:false,error:…}" convention. Pinning this
// so a deliberate move to a true 404 is a conscious decision,
// not an accident.
var (server, client, _) = await BootAsync();
try
{
var res = await client.GetAsync("/this-route-does-not-exist");
res.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await res.Content.ReadAsStringAsync();
body.Should().Contain("\"error\":\"not found\"");
}
finally
{
server.Stop();
client.Dispose();
}
}
[Fact]
public async Task GetParticipants_Returns200_WithEmptyListWhenNoViewModel()
{
// No dispatcher / no view-model in tests — the endpoint should
// gracefully return participants=[] rather than throwing.
var (server, client, _) = await BootAsync();
try
{
var res = await client.GetAsync("/participants");
res.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await res.Content.ReadAsStringAsync();
body.Should().Contain("\"participants\":[]");
}
finally
{
server.Stop();
client.Dispose();
}
}
[Fact]
public async Task PostPresetsRefreshDiscovery_HitsControllerAndReturnsOk()
{
var controller = new StubIsoController();
var server = new ControlSurfaceServer(controller, () => null, logger: null);
var port = PickFreePort();
server.Start(port);
await Task.Delay(50);
using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") };
try
{
var res = await client.PostAsync("/presets/refresh-discovery", content: null);
res.StatusCode.Should().Be(HttpStatusCode.OK);
controller.RefreshDiscoveryCalled.Should().BeTrue();
}
finally
{
server.Stop();
}
}
[Fact]
public async Task PostPresetApply_MissingPreset_RespondsWithOkFalseAndPresetNotFound()
{
// Preset name that demonstrably doesn't exist on disk → endpoint
// returns 200 with {"ok":false,"error":"preset not found",...}.
// We don't 404 on missing presets because the operator may have
// typed the wrong name; clearer payload is friendlier.
var (server, client, _) = await BootAsync();
try
{
var res = await client.PostAsync(
"/presets/__nonexistent_preset_for_test__/apply",
content: null);
res.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await res.Content.ReadAsStringAsync();
body.Should().Contain("\"ok\":false");
body.Should().Contain("\"error\":\"preset not found\"");
}
finally
{
server.Stop();
client.Dispose();
}
}
[Fact]
public async Task GetUi_Returns200_WithEmbeddedHtml()
{
var (server, client, _) = await BootAsync();
try
{
var res = await client.GetAsync("/ui");
res.StatusCode.Should().Be(HttpStatusCode.OK);
res.Content.Headers.ContentType?.MediaType.Should().Be("text/html");
var body = await res.Content.ReadAsStringAsync();
body.Should().Contain("<html", "the response should be a real HTML document");
}
finally
{
server.Stop();
client.Dispose();
}
}
[Fact]
public async Task OptionsRequest_Returns204_WithCorsHeaders()
{
// Companion / browser-based controllers preflight POSTs; the
// server must answer 204 with the allow-origin/allow-methods
// headers or the actual call gets blocked by CORS.
var (server, client, _) = await BootAsync();
try
{
var req = new HttpRequestMessage(HttpMethod.Options, "/participants");
var res = await client.SendAsync(req);
res.StatusCode.Should().Be(HttpStatusCode.NoContent);
res.Headers.GetValues("Access-Control-Allow-Origin").Should().Contain("*");
}
finally
{
server.Stop();
client.Dispose();
}
}
}

View file

@ -1,104 +0,0 @@
using System.IO;
using FluentAssertions;
using TeamsISO.App.Services;
namespace TeamsISO.App.Tests.Services;
// Unit tests for NotesService — the append-only show-notes log.
// Uses the DirectoryOverride seam so writes land in a tempdir and
// don't pollute the dev's real %LOCALAPPDATA%\TeamsISO\Notes folder.
//
// Shares NotesStateCollection with any sibling class that mutates
// NotesService.DirectoryOverride (the same static-state-shared-via-
// parallel-classes problem the PresetStoreCollection solves).
[Collection(NotesStateCollection.Name)]
public sealed class NotesServiceTests : IDisposable
{
private readonly string _tempDir;
private readonly string? _previousOverride;
public NotesServiceTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"teamsiso-notes-{Guid.NewGuid():N}");
_previousOverride = NotesService.DirectoryOverride;
NotesService.DirectoryOverride = _tempDir;
}
public void Dispose()
{
NotesService.DirectoryOverride = _previousOverride;
try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); }
catch { /* test cleanup is best-effort */ }
}
[Fact]
public void Append_WritesHeaderAndLine_OnFirstCall()
{
var ok = NotesService.Append("first note");
ok.Should().BeTrue();
File.Exists(NotesService.TodayPath).Should().BeTrue();
var content = File.ReadAllText(NotesService.TodayPath);
content.Should().StartWith("# TeamsISO show notes — ");
content.Should().Contain("— first note");
}
[Fact]
public void Append_PrependsTimestampPrefix_InCanonicalFormat()
{
NotesService.Append("checkpoint");
var content = File.ReadAllText(NotesService.TodayPath);
// Each appended line follows "- **HH:mm:ss** — <text>" so a
// reader can scan the file as Markdown without preprocessing.
content.Should().MatchRegex(@"- \*\*\d{2}:\d{2}:\d{2}\*\* — checkpoint");
}
[Fact]
public void Append_AppendsAdditionalLines_AfterTheFirst()
{
NotesService.Append("alpha");
NotesService.Append("beta");
NotesService.Append("gamma");
var content = File.ReadAllText(NotesService.TodayPath);
content.Should().Contain("alpha");
content.Should().Contain("beta");
content.Should().Contain("gamma");
// Header written exactly once, not before every line.
var headerCount = content.Split("# TeamsISO show notes —").Length - 1;
headerCount.Should().Be(1);
}
[Fact]
public void Append_TrimsLeadingAndTrailingWhitespace()
{
NotesService.Append(" padded ");
var content = File.ReadAllText(NotesService.TodayPath);
content.Should().Contain("— padded");
content.Should().NotContain(" padded "); // leading-whitespace gone
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t\n")]
public void Append_RejectsEmptyOrWhitespaceText(string text)
{
var ok = NotesService.Append(text);
ok.Should().BeFalse();
File.Exists(NotesService.TodayPath).Should().BeFalse(
"an empty append shouldn't create the daily file");
}
[Fact]
public void TodayPath_ReflectsCurrentDate_AndOverride()
{
var path = NotesService.TodayPath;
Path.GetDirectoryName(path).Should().Be(_tempDir);
Path.GetFileName(path).Should().MatchRegex(@"\d{4}-\d{2}-\d{2}\.md");
}
}

View file

@ -1,14 +0,0 @@
namespace TeamsISO.App.Tests.Services;
/// <summary>
/// Serializes any test class that mutates
/// <c>NotesService.DirectoryOverride</c>. Without this, xUnit runs the
/// classes in parallel collections and one ctor can clobber the
/// override another's test is depending on (manifests as a brand-new
/// notes file landing in the WRONG temp dir mid-test).
/// </summary>
[CollectionDefinition(Name)]
public sealed class NotesStateCollection
{
public const string Name = "NotesService (DirectoryOverride mutators)";
}

View file

@ -5,18 +5,15 @@ using TeamsISO.App.Services;
namespace TeamsISO.App.Tests.Services;
/// <summary>
/// Unit tests for <see cref="OperatorPresetStore"/>. Each test redirects
/// the store's file path to a per-test temp path via the internal
/// Unit tests for <see cref="OperatorPresetStore"/>. Each test redirects the
/// store's file path to a per-test temp path via the internal
/// <c>PathOverride</c> hook so the operator's real
/// <c>%LOCALAPPDATA%\TeamsISO\presets.json</c> is never touched.
///
/// IDisposable on the test class cleans up the temp path after each
/// test. Shares <see cref="PresetStoreCollection"/> with any other
/// class that mutates <see cref="OperatorPresetStore.PathOverride"/> —
/// xUnit's parallel execution would otherwise let a sibling class's
/// ctor clobber our path mid-test.
/// IDisposable on the test class cleans up the temp path after each test.
/// We don't use [Collection] because each test's path is per-test-unique
/// (Path.GetTempFileName) so parallel xUnit execution can't collide.
/// </summary>
[Collection(PresetStoreCollection.Name)]
public sealed class OperatorPresetStoreTests : IDisposable
{
private readonly string _tempPath;

View file

@ -1,117 +0,0 @@
using System.IO;
using FluentAssertions;
using TeamsISO.App.Services;
using TeamsISO.App.Tests.Fakes;
namespace TeamsISO.App.Tests.Services;
// Tests for the OscBridge.DispatchAsync routing. We construct
// OscMessage instances directly (skipping the UDP receive loop) and
// assert that the right address resolves to the right controller call.
//
// The toggle / preset paths require Application.Current.Dispatcher,
// which doesn't exist in xUnit's default execution context — those
// paths return early on the null check, so we verify the bail rather
// than the happy path. The full toggle path is covered in branch 11's
// integration test that boots a real dispatcher.
//
// Shares NotesStateCollection with NotesServiceTests — both classes
// mutate NotesService.DirectoryOverride and would otherwise race.
[Collection(NotesStateCollection.Name)]
public sealed class OscBridgeDispatchTests : IDisposable
{
private readonly string _tempNotesDir;
private readonly string? _previousNotesOverride;
public OscBridgeDispatchTests()
{
_tempNotesDir = Path.Combine(Path.GetTempPath(), $"teamsiso-osc-{Guid.NewGuid():N}");
_previousNotesOverride = NotesService.DirectoryOverride;
NotesService.DirectoryOverride = _tempNotesDir;
}
public void Dispose()
{
NotesService.DirectoryOverride = _previousNotesOverride;
try { if (Directory.Exists(_tempNotesDir)) Directory.Delete(_tempNotesDir, recursive: true); }
catch { /* best-effort */ }
}
private static (OscBridge Bridge, StubIsoController Controller) NewBridge()
{
var controller = new StubIsoController();
// OscBridge takes Func<MainViewModel?> — returning null exercises
// the "no VM yet" graceful path in handlers that need it.
var bridge = new OscBridge(controller, () => null, logger: null);
return (bridge, controller);
}
[Fact]
public async Task RefreshDiscoveryAddress_CallsControllerRefreshDiscovery()
{
var (bridge, controller) = NewBridge();
await bridge.DispatchAsync(new OscMessage { Address = "/teamsiso/refresh-discovery" });
controller.RefreshDiscoveryCalled.Should().BeTrue();
}
[Fact]
public async Task UnknownAddress_NoOpsCleanly()
{
var (bridge, controller) = NewBridge();
await bridge.DispatchAsync(new OscMessage { Address = "/teamsiso/nope/never" });
controller.RefreshDiscoveryCalled.Should().BeFalse();
controller.EnableCalls.Should().BeEmpty();
controller.DisableCalls.Should().BeEmpty();
}
[Fact]
public async Task NotesAddress_AppendsViaNotesService()
{
var (bridge, _) = NewBridge();
await bridge.DispatchAsync(new OscMessage
{
Address = "/teamsiso/notes",
TypeTag = ",s",
Args = new object[] { "tracked through OSC" },
});
File.Exists(NotesService.TodayPath).Should().BeTrue();
File.ReadAllText(NotesService.TodayPath).Should().Contain("tracked through OSC");
}
[Fact]
public async Task StopAllAddress_NoOpsWhenViewModelIsNull()
{
// Without a view-model, the stop-all path returns before touching
// the controller. The point of this test is to pin that the bail
// is clean — no thrown exception, no controller traffic.
var (bridge, controller) = NewBridge();
await bridge.DispatchAsync(new OscMessage { Address = "/teamsiso/stop-all" });
controller.DisableCalls.Should().BeEmpty();
}
[Fact]
public async Task IsoByNameAddress_NoOpsWhenViewModelIsNull()
{
// /teamsiso/iso "Jane" 1 — verifies the bail when no VM is
// wired; doesn't fire EnableIsoAsync. The dispatcher-equipped
// version of this round-trip lives in branch 11.
var (bridge, controller) = NewBridge();
await bridge.DispatchAsync(new OscMessage
{
Address = "/teamsiso/iso",
TypeTag = ",sT",
Args = new object[] { "Jane", true },
});
controller.EnableCalls.Should().BeEmpty();
}
}

View file

@ -1,4 +1,3 @@
using System.IO;
using System.Text;
using FluentAssertions;
using TeamsISO.App.Services;

View file

@ -1,164 +0,0 @@
using System.IO;
using FluentAssertions;
using TeamsISO.App.Services;
using TeamsISO.App.Tests.Fakes;
using TeamsISO.App.ViewModels;
using TeamsISO.Engine.Domain;
namespace TeamsISO.App.Tests.Services;
// PresetApplier reconciles a saved preset's per-display-name assignments
// against the live participant view-model list. Tests pin the four
// transitions (enable→stay, disable→stay, off→enable, on→disable) plus
// the partial-meeting path where the preset references participants
// who aren't currently present.
//
// We share a collection with OperatorPresetStoreTests because both
// classes mutate OperatorPresetStore.PathOverride; xUnit's default
// parallelism would otherwise let one class clobber the other's path
// mid-run.
[Collection(PresetStoreCollection.Name)]
public sealed class PresetApplierTests : IDisposable
{
private readonly string _tempPresets;
private readonly string? _previousPresetOverride;
public PresetApplierTests()
{
_tempPresets = Path.Combine(Path.GetTempPath(), $"teamsiso-presets-{Guid.NewGuid():N}.json");
_previousPresetOverride = OperatorPresetStore.PathOverride;
OperatorPresetStore.PathOverride = _tempPresets;
}
public void Dispose()
{
OperatorPresetStore.PathOverride = _previousPresetOverride;
try { if (File.Exists(_tempPresets)) File.Delete(_tempPresets); }
catch { /* cleanup best-effort */ }
}
private static ParticipantViewModel MakeParticipant(
StubIsoController controller, string displayName, bool isEnabled = false)
{
var participant = new Participant(
Id: Guid.NewGuid(),
DisplayName: displayName,
CurrentSource: null,
FirstSeen: DateTimeOffset.UtcNow,
LastSeen: DateTimeOffset.UtcNow);
return new ParticipantViewModel(controller, participant) { IsEnabled = isEnabled };
}
private static OperatorPresetStore.Preset Preset(params (string Name, bool Enabled, string? Custom)[] rows) =>
new(
Name: "test-preset",
SavedAt: DateTimeOffset.UtcNow,
Assignments: rows.Select(r =>
new OperatorPresetStore.Assignment(r.Name, r.Custom, r.Enabled)).ToList());
[Fact]
public async Task Apply_EnablesParticipantsThatPresetSaysEnabled_AndAreCurrentlyOff()
{
var controller = new StubIsoController();
var alice = MakeParticipant(controller, "Alice", isEnabled: false);
var bob = MakeParticipant(controller, "Bob", isEnabled: false);
var preset = Preset(("Alice", true, "ALICE_OUT"), ("Bob", true, null));
var result = await PresetApplier.ApplyAsync(preset, new[] { alice, bob }, controller, dispatcher: null);
result.Matched.Should().Be(2);
result.Changed.Should().Be(2);
result.Skipped.Should().Be(0);
controller.EnableCalls.Should().HaveCount(2);
controller.EnableCalls.Should().Contain(c => c.Id == alice.Id && c.Name == "ALICE_OUT");
controller.EnableCalls.Should().Contain(c => c.Id == bob.Id && c.Name == null);
alice.IsEnabled.Should().BeTrue();
bob.IsEnabled.Should().BeTrue();
alice.CustomName.Should().Be("ALICE_OUT");
bob.CustomName.Should().Be(string.Empty);
}
[Fact]
public async Task Apply_DisablesParticipantsThatPresetSaysOff_AndAreCurrentlyEnabled()
{
var controller = new StubIsoController();
var alice = MakeParticipant(controller, "Alice", isEnabled: true);
var preset = Preset(("Alice", false, null));
var result = await PresetApplier.ApplyAsync(preset, new[] { alice }, controller, dispatcher: null);
result.Matched.Should().Be(1);
result.Changed.Should().Be(1);
controller.DisableCalls.Should().ContainSingle().Which.Should().Be(alice.Id);
alice.IsEnabled.Should().BeFalse();
}
[Fact]
public async Task Apply_NoControllerCall_WhenStateAlreadyMatchesPreset()
{
var controller = new StubIsoController();
var alice = MakeParticipant(controller, "Alice", isEnabled: true);
var preset = Preset(("Alice", true, null));
var result = await PresetApplier.ApplyAsync(preset, new[] { alice }, controller, dispatcher: null);
result.Matched.Should().Be(1);
result.Changed.Should().Be(0,
"the participant is already enabled; preset says enabled — no controller traffic");
controller.EnableCalls.Should().BeEmpty();
controller.DisableCalls.Should().BeEmpty();
}
[Fact]
public async Task Apply_MatchesByDisplayName_CaseInsensitive()
{
// Operator typed "Alice" when saving the preset; the live
// participant comes back as "alice". The join must be case-
// insensitive or the preset never finds the row.
var controller = new StubIsoController();
var alice = MakeParticipant(controller, "alice", isEnabled: false);
var preset = Preset(("Alice", true, null));
var result = await PresetApplier.ApplyAsync(preset, new[] { alice }, controller, dispatcher: null);
result.Matched.Should().Be(1);
alice.IsEnabled.Should().BeTrue();
}
[Fact]
public async Task Apply_CountsSkipped_WhenPresetReferencesAbsentParticipants()
{
var controller = new StubIsoController();
var alice = MakeParticipant(controller, "Alice", isEnabled: false);
// Preset names Alice + a Bob who never joined.
var preset = Preset(("Alice", true, null), ("Bob", true, null));
var result = await PresetApplier.ApplyAsync(preset, new[] { alice }, controller, dispatcher: null);
result.Matched.Should().Be(1);
result.Skipped.Should().Be(1, "Bob is named in the preset but not in the meeting");
result.Changed.Should().Be(1);
}
[Fact]
public async Task Apply_IgnoresLiveParticipantsThatThePresetDoesntName()
{
// Carol joined the meeting but the saved preset only references
// Alice. Carol's row must NOT be touched (no enable / disable
// / customName change).
var controller = new StubIsoController();
var alice = MakeParticipant(controller, "Alice", isEnabled: false);
var carol = MakeParticipant(controller, "Carol", isEnabled: true);
var carolCustomBefore = carol.CustomName;
var preset = Preset(("Alice", true, null));
await PresetApplier.ApplyAsync(preset, new[] { alice, carol }, controller, dispatcher: null);
carol.IsEnabled.Should().BeTrue("Carol wasn't named, so her state stands");
carol.CustomName.Should().Be(carolCustomBefore);
controller.EnableCalls.Should().ContainSingle().Which.Id.Should().Be(alice.Id);
controller.DisableCalls.Should().BeEmpty();
}
}

View file

@ -1,14 +0,0 @@
namespace TeamsISO.App.Tests.Services;
/// <summary>
/// Serializes any test class that mutates
/// <c>OperatorPresetStore.PathOverride</c> — without this, xUnit runs
/// fixtures in parallel across the assembly and a sibling class can
/// clobber the path mid-test, leading to flakes that look like data
/// corruption.
/// </summary>
[CollectionDefinition(Name)]
public sealed class PresetStoreCollection
{
public const string Name = "PresetStore (PathOverride mutators)";
}

View file

@ -1,156 +0,0 @@
using FluentAssertions;
using TeamsISO.App.Services;
namespace TeamsISO.App.Tests.Services;
// Unit tests for ThemeManager — exercise the resolve / set / toggle
// state machine behind the test-only constructor that takes stub seams
// instead of touching HKCU and %LOCALAPPDATA%. Apply() and the
// SystemEvents subscription are intentionally NOT exercised here:
// they require Application.Current and a real WPF dispatcher, both of
// which would couple these tests to the host runtime.
public sealed class ThemeManagerTests
{
private static ThemeManager NewManager(
bool systemDark = true,
string? initialPreference = null,
Action<string>? captureSave = null) =>
new ThemeManager(
isSystemDark: () => systemDark,
loadPreference: () => initialPreference,
savePreference: captureSave ?? (_ => { }),
subscribeToSystemPreference: false);
[Fact]
public void Set_DarkThenLight_RoundTripsPreferenceAndResolution()
{
var saves = new List<string>();
var tm = NewManager(systemDark: false, captureSave: saves.Add);
tm.Set("Dark");
tm.Preference.Should().Be("Dark");
tm.ResolveTheme().Should().Be("Dark");
tm.Set("Light");
tm.Preference.Should().Be("Light");
tm.ResolveTheme().Should().Be("Light");
saves.Should().Equal("Dark", "Light");
}
[Theory]
[InlineData(true, "Dark")]
[InlineData(false, "Light")]
public void ResolveTheme_FollowsSystem_WhenPreferenceIsSystem(bool isSystemDark, string expected)
{
var tm = NewManager(systemDark: isSystemDark, initialPreference: "System");
tm.Preference.Should().Be("System");
tm.ResolveTheme().Should().Be(expected);
}
[Fact]
public void Toggle_FromSystemDark_PinsToOppositeOfCurrent()
{
// System currently resolves to Dark → toggle should flip
// *preference* to Light (the opposite of the currently-displayed
// theme), not back to System. The point of the click is a
// visible change.
var tm = NewManager(systemDark: true, initialPreference: "System");
tm.Toggle();
tm.Preference.Should().Be("Light");
tm.ResolveTheme().Should().Be("Light");
}
[Fact]
public void Toggle_FromSystemLight_PinsToOppositeOfCurrent()
{
var tm = NewManager(systemDark: false, initialPreference: "System");
tm.Toggle();
tm.Preference.Should().Be("Dark");
tm.ResolveTheme().Should().Be("Dark");
}
[Fact]
public void Toggle_FromDark_FlipsToLight()
{
var tm = NewManager(initialPreference: "Dark");
tm.Toggle();
tm.Preference.Should().Be("Light");
}
[Fact]
public void Toggle_FromLight_FlipsToDark()
{
var tm = NewManager(initialPreference: "Light");
tm.Toggle();
tm.Preference.Should().Be("Dark");
}
[Theory]
[InlineData("invalid")]
[InlineData("dark")] // case-sensitive — we accept exactly Dark
[InlineData("LIGHT")]
[InlineData("")]
public void Set_RejectsInvalidPreferenceWithArgumentException(string bad)
{
var tm = NewManager();
var act = () => tm.Set(bad);
act.Should().Throw<ArgumentException>()
.WithParameterName("preference");
}
[Fact]
public void Constructor_DefaultsToSystem_WhenLoadReturnsNull()
{
// Simulates a fresh install / corrupt prefs file: loadPreference
// returns null; the manager falls back to the in-memory default
// of "System" rather than throwing.
var tm = NewManager(initialPreference: null);
tm.Preference.Should().Be("System");
}
[Fact]
public void Constructor_DefaultsToSystem_WhenLoadReturnsInvalidValue()
{
// A prefs file written by a future version with an unknown
// value mustn't poison the in-memory state — invalid loads
// fall back to the default, same as a missing file.
var tm = NewManager(initialPreference: "Rainbow");
tm.Preference.Should().Be("System");
}
[Fact]
public void Constructor_HonoursPersistedPreference()
{
var tm = NewManager(initialPreference: "Dark");
tm.Preference.Should().Be("Dark");
}
[Fact]
public void Constructor_SurvivesLoadException()
{
// The production singleton hits disk via UIPreferences.Load; a
// disk fault must NOT escape the ctor or the app loses theming
// entirely. Verify the swallow.
var tm = new ThemeManager(
isSystemDark: () => true,
loadPreference: () => throw new InvalidOperationException("disk faulted"),
savePreference: _ => { },
subscribeToSystemPreference: false);
tm.Preference.Should().Be("System");
}
}

View file

@ -1,118 +0,0 @@
using System.IO;
using FluentAssertions;
using TeamsISO.App.Services;
namespace TeamsISO.App.Tests.Services;
// UpdateChecker unit tests.
//
// We don't exercise CheckAsync (the real HTTP call against Forgejo) —
// tests must not depend on the network. Coverage instead:
// • TryParseSemVer: version-comparison parsing across the inputs the
// real release stream produces.
// • CheckIfDueAsync throttle: a recent cooldown stamp short-circuits
// and returns null *before* CheckAsync runs (which would otherwise
// fire an HTTP request).
public sealed class UpdateCheckerTests : IDisposable
{
private readonly string _tempDir;
private readonly string? _previousOverride;
public UpdateCheckerTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"teamsiso-update-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
_previousOverride = UpdateChecker.StateDirectoryOverride;
UpdateChecker.StateDirectoryOverride = _tempDir;
}
public void Dispose()
{
UpdateChecker.StateDirectoryOverride = _previousOverride;
try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); }
catch { /* cleanup best-effort */ }
}
[Theory]
[InlineData("v1.2.3", "1.2.3")]
[InlineData("V1.2.3", "1.2.3")] // case-insensitive 'v' strip
[InlineData("1.2.3", "1.2.3")]
[InlineData("v1.2.3.4", "1.2.3.4")] // 4-segment .NET-style versions
[InlineData("v1.2.3-alpha", "1.2.3")] // pre-release suffix stripped
[InlineData("v1.2.3-beta.4", "1.2.3")]
public void TryParseSemVer_AcceptsExpectedForms(string input, string expected)
{
UpdateChecker.TryParseSemVer(input).Should().Be(Version.Parse(expected));
}
[Theory]
[InlineData("not-a-version")]
[InlineData("v.invalid")]
[InlineData("")]
public void TryParseSemVer_ReturnsNullOnGarbage(string input)
{
UpdateChecker.TryParseSemVer(input).Should().BeNull();
}
[Fact]
public void TryParseSemVer_OrderingIsSemantic()
{
// The CheckAsync comparison is "latest > current" — pin the
// ordering across the version arc the release process actually
// produces.
var older = UpdateChecker.TryParseSemVer("v0.1.0")!;
var newer = UpdateChecker.TryParseSemVer("v0.2.0")!;
var newest = UpdateChecker.TryParseSemVer("v1.0.0")!;
(newer > older).Should().BeTrue();
(newest > newer).Should().BeTrue();
(newest > older).Should().BeTrue();
(older > newer).Should().BeFalse();
}
[Fact]
public async Task CheckIfDueAsync_ReturnsNull_WhenCooldownStampIsRecent()
{
// Pre-write a "we just checked" stamp. The throttle should
// short-circuit and return null without firing the HTTP call,
// which means the test passes deterministically offline.
File.WriteAllText(
Path.Combine(_tempDir, "last-update-check.txt"),
DateTimeOffset.UtcNow.ToString("o"));
var result = await UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
result.Should().BeNull("a stamp inside the cooldown window suppresses the check");
}
[Fact]
public async Task CheckIfDueAsync_ReturnsNull_WhenStampIsOldButCooldownIsLargerThanGap()
{
// Edge case: stamp 1h old, cooldown 24h → still suppressed.
File.WriteAllText(
Path.Combine(_tempDir, "last-update-check.txt"),
DateTimeOffset.UtcNow.AddHours(-1).ToString("o"));
var result = await UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
result.Should().BeNull();
}
[Fact]
public void LaunchCheckEnabled_RoundTrips()
{
// Default (no flag file) → enabled.
UpdateChecker.LaunchCheckEnabled.Should().BeTrue();
UpdateChecker.LaunchCheckEnabled = false;
UpdateChecker.LaunchCheckEnabled.Should().BeFalse(
"writing the opt-out flag should be visible immediately");
File.Exists(Path.Combine(_tempDir, "no-update-check.flag"))
.Should().BeTrue();
UpdateChecker.LaunchCheckEnabled = true;
UpdateChecker.LaunchCheckEnabled.Should().BeTrue();
File.Exists(Path.Combine(_tempDir, "no-update-check.flag"))
.Should().BeFalse("re-enabling should remove the opt-out flag");
}
}

View file

@ -1,126 +0,0 @@
using System.IO;
using System.Text.Json;
using System.Windows;
using FluentAssertions;
using TeamsISO.App.Services;
namespace TeamsISO.App.Tests.Services;
// Round-trip tests for WindowStateStore.Save / TryApply. Constructing a
// real WPF Window inside an xUnit fact is awkward (no Application.Run,
// no dispatcher), so we exercise the JSON layer + the placement-validity
// rejection logic by writing snapshots directly to disk and reading
// them back. Save is exercised by serializing a Snapshot record
// inline and asserting JsonSerializer can round-trip it through the
// shape WindowStateStore writes.
//
// The full Window.Left/Width property writes inside TryApply aren't
// covered here — they require a WPF Window instance, which means an
// Application.Current + dispatcher. We instead cover the bail paths
// (file missing, too-small, off-screen) which is where regressions
// typically land.
public sealed class WindowStateStoreTests : IDisposable
{
private readonly string _tempPath;
private readonly string? _previousOverride;
public WindowStateStoreTests()
{
_tempPath = Path.Combine(Path.GetTempPath(), $"teamsiso-window-{Guid.NewGuid():N}.json");
_previousOverride = WindowStateStore.PathOverride;
WindowStateStore.PathOverride = _tempPath;
}
public void Dispose()
{
WindowStateStore.PathOverride = _previousOverride;
try { if (File.Exists(_tempPath)) File.Delete(_tempPath); }
catch { /* best-effort */ }
}
private static void WriteSnapshot(string path, WindowStateStore.Snapshot snap)
{
File.WriteAllText(path, JsonSerializer.Serialize(snap, new JsonSerializerOptions { WriteIndented = true }));
}
[Fact]
public void Snapshot_JsonRoundTrips_CleanlyThroughTheSameSerializerShape()
{
// Write a Snapshot record through the same JsonSerializer.Serialize
// call WindowStateStore.Save uses; read it back and verify all
// five fields survive. Coverage gap (Save's own Window reads)
// intentional — see file header.
var snap = new WindowStateStore.Snapshot(
Left: 120, Top: 80, Width: 1024, Height: 768, State: WindowState.Maximized);
WriteSnapshot(_tempPath, snap);
var roundTripped = JsonSerializer.Deserialize<WindowStateStore.Snapshot>(File.ReadAllText(_tempPath));
roundTripped.Should().NotBeNull();
roundTripped!.Left.Should().Be(120);
roundTripped.Top.Should().Be(80);
roundTripped.Width.Should().Be(1024);
roundTripped.Height.Should().Be(768);
roundTripped.State.Should().Be(WindowState.Maximized);
}
[Fact]
public void TryApply_NoFile_ReturnsFalse()
{
File.Exists(_tempPath).Should().BeFalse();
// We can't construct a Window without STA; we *can* exercise
// the bail path that returns before any Window property is
// touched by passing null and catching the NRE through the
// store's own try/catch — which makes TryApply return false.
var result = WindowStateStore.TryApply(null!);
result.Should().BeFalse();
}
[Fact]
public void TryApply_TooSmallSnapshot_RejectsBeforeTouchingWindow()
{
// 100×100 is below the 320×240 floor. TryApply should return
// false without throwing on the null window.
WriteSnapshot(_tempPath, new WindowStateStore.Snapshot(0, 0, 100, 100, WindowState.Normal));
var result = WindowStateStore.TryApply(null!);
result.Should().BeFalse();
}
[Fact]
public void TryApply_AbsurdlyLargeSnapshot_RejectsBeforeTouchingWindow()
{
// 20000×20000 is above the safety ceiling. Again no throw.
WriteSnapshot(_tempPath, new WindowStateStore.Snapshot(0, 0, 20000, 20000, WindowState.Normal));
var result = WindowStateStore.TryApply(null!);
result.Should().BeFalse();
}
[Fact]
public void TryApply_FullyOffScreenSnapshot_RejectsBeforeTouchingWindow()
{
// Way off the virtual screen — no corner falls inside any
// monitor's working area.
WriteSnapshot(_tempPath, new WindowStateStore.Snapshot(
Left: -99999, Top: -99999, Width: 800, Height: 600, State: WindowState.Normal));
var result = WindowStateStore.TryApply(null!);
result.Should().BeFalse();
}
[Fact]
public void TryApply_GarbageJson_ReturnsFalseRatherThanThrowing()
{
File.WriteAllText(_tempPath, "{ this is not valid json");
var result = WindowStateStore.TryApply(null!);
result.Should().BeFalse();
}
}

View file

@ -6,17 +6,15 @@
because TeamsISO.App is net8.0-windows + WinExe — a pure net8.0 test
project can't reference it.
Tests cover services that are mostly framework-free, but
ControlSurfaceServer transitively references System.Windows.Threading
(DispatcherTimer) and System.Windows.Application — UseWPF=true pulls
in those types so test code compiles against the App's project
reference without "could not load type" errors at run time.
We DON'T reference WPF or System.Windows here — the tests cover services
that are intentionally framework-free even though they live in the host
assembly. Future test cases that touch WPF types (e.g. WriteableBitmap)
would need <UseWPF>true</UseWPF> added.
-->
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
@ -31,7 +29,6 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageReference Include="Xunit.StaFact" Version="1.1.11" />
</ItemGroup>
<ItemGroup>

View file

@ -1,94 +0,0 @@
using FluentAssertions;
using TeamsISO.App.ViewModels;
namespace TeamsISO.App.Tests.ViewModels;
// Unit tests for the CommandPaletteViewModel.Matches predicate — the
// case-insensitive Contains check across Label / Category / Keywords
// that powers the v2 Ctrl+K filter.
//
// We don't build a full CommandPaletteViewModel here (that requires a
// MainViewModel + IIsoController fake — out of scope). Matches is the
// behaviorally-relevant unit; pinning it across a representative
// query set guards against accidental regressions when someone adds a
// scoring algorithm or swaps Contains for StartsWith.
public sealed class CommandPaletteMatchesTests
{
private static PaletteCommand Cmd(string category, string label, string? keywords = null) =>
new(category, label, keywords, Shortcut: null, Invoke: () => { });
[Theory]
// Label substrings — the dominant match path
[InlineData("Quick", "Stop all ISOs", null, "stop", true)]
[InlineData("Quick", "Stop all ISOs", null, "STOP", true)] // case-insensitive
[InlineData("Quick", "Stop all ISOs", null, "all", true)]
[InlineData("Quick", "Stop all ISOs", null, "ISO", true)]
// Category match — operator types the section name
[InlineData("Teams", "Mute / unmute", null, "teams", true)]
[InlineData("App", "Help", null, "app", true)]
// Keywords match — synonym path. The Network/topology command has
// "ndi groups isolate" in its keywords blob.
[InlineData("Network", "Apply transcoder topology", "ndi groups isolate", "ndi", true)]
[InlineData("Network", "Apply transcoder topology", "ndi groups isolate", "isolate", true)]
// No-match — none of label/category/keywords contain the query
[InlineData("Quick", "Stop all ISOs", null, "espresso", false)]
[InlineData("Teams", "Mute / unmute", "microphone audio toggle", "monitor", false)]
public void Matches_Predicate(string category, string label, string? keywords, string query, bool expected)
{
CommandPaletteViewModel.Matches(Cmd(category, label, keywords), query)
.Should().Be(expected);
}
[Fact]
public void Matches_OperatorTypingShortToken_HitsExpectedCategorySpread()
{
// "mute" should match the Teams command but not the App theme
// commands — pins the cross-category selectivity that makes
// the palette useful at all. If a future change makes Matches
// too permissive (e.g. by indexing the Invoke delegate's
// method name), the second assertion catches it.
var muteCmd = Cmd("Teams", "Mute / unmute", keywords: "microphone audio silence toggle");
var themeCmd = Cmd("App", "Theme: dark", keywords: "appearance night mode");
CommandPaletteViewModel.Matches(muteCmd, "mute").Should().BeTrue();
CommandPaletteViewModel.Matches(themeCmd, "mute").Should().BeFalse();
}
[Fact]
public void Matches_AcrossTheFullPaletteVocabulary_StaysDeterministic()
{
// Sanity check: a representative slice of the palette's real
// commands gives stable matches for the most common operator
// queries. Pin the count of hits for each query so a careless
// refactor that flips the predicate's polarity blows up here
// instead of in production.
var commands = new[]
{
Cmd("Quick", "Enable all online", "ISOs enable everyone start everything live"),
Cmd("Quick", "Stop all ISOs", "panic stop everything kill disable"),
Cmd("Quick", "Refresh discovery", "rediscover sources rebuild finder NDI"),
Cmd("Teams", "Mute / unmute", "microphone audio silence toggle"),
Cmd("Teams", "Toggle camera", "video webcam on off"),
Cmd("Teams", "Leave call", "exit end disconnect quit"),
Cmd("Network", "Apply transcoder topology", "ndi groups isolate teamsiso-input private"),
Cmd("App", "Theme: dark", "appearance night mode"),
Cmd("App", "Theme: light", "appearance day mode bright"),
Cmd("App", "Theme: follow Windows", "system auto"),
Cmd("App", "Help", "shortcuts cheatsheet f1"),
};
int Hits(string q) => commands.Count(c => CommandPaletteViewModel.Matches(c, q));
Hits("theme").Should().Be(3, "three App theme commands carry 'Theme' in the label");
Hits("stop").Should().Be(1);
Hits("ndi").Should().Be(2, "Refresh discovery (NDI in keywords) + Apply transcoder topology");
// "App" matches case-insensitively against the four App-category
// commands AND substring-matches inside "Apply transcoder topology" —
// a real operator typing "app" would see five rows, which is
// exactly what Contains delivers. Pinning this so a future move
// to a stricter (StartsWith / token-boundary) algorithm has to
// re-decide that affordance deliberately.
Hits("App").Should().Be(5, "four App-category commands + 'Apply' transcoder topology");
Hits("xyzzy").Should().Be(0);
}
}

View file

@ -182,72 +182,6 @@ public class IsoControllerTests : IDisposable
alerts.Should().Contain(a => a is EngineAlert.NdiRuntimeMismatch);
}
[Fact]
public async Task SetRecording_TogglesEnabledAndStoresDirectory()
{
await using var controller = NewController();
controller.RecordingEnabled.Should().BeFalse();
controller.RecordingDirectory.Should().BeNull();
controller.SetRecording(enabled: true, outputDirectory: @"D:\Recordings\Show1");
controller.RecordingEnabled.Should().BeTrue();
controller.RecordingDirectory.Should().Be(@"D:\Recordings\Show1");
controller.SetRecording(enabled: false, outputDirectory: null);
controller.RecordingEnabled.Should().BeFalse();
controller.RecordingDirectory.Should().BeNull();
}
[Fact]
public async Task AddRecordingMarker_NoOpsCleanly_WhenNoActiveRecorders()
{
// No pipelines have ever started → no recorders are attached.
// AddRecordingMarker must not throw on the empty-recorder path
// (the UI Ctrl+M binding fires regardless of recording state).
await using var controller = NewController();
var act = () => controller.AddRecordingMarker("test marker");
act.Should().NotThrow();
}
[Fact]
public async Task RefreshDiscovery_SetsRefreshFlagOnDiscoveryService()
{
// RefreshDiscovery is a fire-and-forget that just sets a flag
// the discovery loop honours on its next tick. We exercise it
// and verify the loop subsequently re-emits the current source
// set as freshly-added (which is the observable contract).
await using var controller = NewController();
var seenLists = new List<IReadOnlyList<Participant>>();
using var sub = controller.Participants.Subscribe(p => seenLists.Add(p));
await controller.StartAsync(CancellationToken.None);
_interop.Sources.Add("PC1 (Teams - Jane)");
var deadline = DateTime.UtcNow.AddSeconds(2);
while (seenLists.LastOrDefault()?.Any() != true && DateTime.UtcNow < deadline)
await Task.Delay(20);
seenLists.Last().Should().HaveCount(1);
var emitsBefore = seenLists.Count;
// Trigger a refresh — the discovery loop should re-emit. We
// don't care exactly how many emissions land, just that the
// observable kept producing rather than stalling.
controller.RefreshDiscovery();
var refreshDeadline = DateTime.UtcNow.AddSeconds(2);
while (seenLists.Count <= emitsBefore && DateTime.UtcNow < refreshDeadline)
await Task.Delay(20);
seenLists.Count.Should().BeGreaterThan(emitsBefore,
"the refresh flag should drive a re-emission within the discovery interval");
}
private static async Task<Guid> WaitForFirstParticipantAsync(IsoController controller)
{
var tcs = new TaskCompletionSource<Guid>();

View file

@ -0,0 +1,10 @@
namespace TeamsISO.Engine.Tests;
public class SmokeTest
{
[Fact]
public void TestProjectIsWired()
{
Assert.True(true);
}
}