Compare commits

..

20 commits

Author SHA1 Message Date
ab47cccd42 release: cut v1.0.0 — trim internal docs, polish README/CHANGELOG/MSI metadata
Some checks failed
CI / build-and-test (push) Failing after 31s
Release / build-msi (push) Failing after 5s
2026-05-17 19:03:33 -04:00
99d6d80754 ui(iso): inline-editable Output name + default to speaker display name
Some checks failed
CI / build-and-test (push) Failing after 26s
2026-05-16 23:34:08 -04:00
dfdfa9e0e1 ui(brand): superimposed dragon watermark behind participants, theme-flipped (white/dark, black/light)
Some checks failed
CI / build-and-test (push) Failing after 27s
2026-05-16 19:10:36 -04:00
80d9baf2d0 ui(header): drop Cmd+K button + swap settings glyph for a true gear (U+2699)
Some checks failed
CI / build-and-test (push) Failing after 26s
2026-05-16 18:55:46 -04:00
d880941ad5 fix(ndi): canonicalize 'public' -> 'Public' in discovery + sender group strings (the real bug)
Some checks failed
CI / build-and-test (push) Failing after 26s
6+ hours of misdiagnosis today, root cause finally found this evening: the user's config.json persisted ndiGroups.discoveryGroups = 'public,teamsiso-input'. NDI group names are case-sensitive in the runtime. Teams broadcasts to the canonical 'Public' (capital P) group. Lowercase 'public' didn't match -> NDI Find returned zero sources forever. NDI Studio Monitor sees Teams sources because it uses default groups (no filter = 'Public'). Every TeamsISO launch that read the config got zero -> looked like a TeamsISO bug.

Fix: add NdiInteropPInvoke.NormalizeGroups that case-folds 'Public' specifically (the most common operator footgun) while passing through custom group names (e.g. 'teamsiso-input') verbatim. Wire it into CreateFinder and CreateSender. End-to-end test: restored bad lowercase config -> launched via Start Menu shortcut -> Serilog now logs 'NDI finder created with groups: Public,teamsiso-input' (note capital P) -> REST returns 2 participants. 264/264 tests passing (Engine 124 +12 NormalizeGroups cases, App 131, Integration 9).

Also adds InternalsVisibleTo on the NdiInterop project so the engine test project can cover the internal helper directly.
2026-05-16 18:33:49 -04:00
1cdd4ebd04 fix(installer+wpf): REVERT runas /trustlevel demotion (it was the bug, not the fix)
Some checks failed
CI / build-and-test (push) Failing after 26s
Massive misdiagnosis correction. The 2025-05-16 effort to 'fix elevation' has been actively breaking every Start Menu / Desktop shortcut launch since rc7. Empirical retrace:

  - Elevated PowerShell -> Process.Start(exe) -> elevated TeamsISO -> WORKS
  - Elevated PowerShell with --keep-elevation -> elevated TeamsISO -> WORKS (vm.Participants.Count=2)
  - Non-elevated PS Process.Start(exe) -> medium TeamsISO -> WORKS
  - ANY launch through runas /trustlevel:0x20000 -> SAFER-restricted TeamsISO -> BROKEN (window appears, zero managed code runs past BAML parse, no logs, no port binds)

The SAFER-restricted token that runas /trustlevel produces breaks .NET 8 WPF apphost in a way that leaves the process apparently alive (with the MainWindow.xaml rendering the empty state from default property values) but executing zero managed code. So my StartupTrace, Serilog file sink, and ControlSurface bind all silently failed for every shortcut launch. Looked exactly like 'cold-start NDI Find stuck at zero' from the outside but had nothing to do with NDI.

Revert:
  - installer/Package.wxs: shortcuts target the .exe directly, no runas wrapper
  - App.xaml.cs: removed ShouldDeElevate, TryDeElevateAndExit, RelaunchEnvVar, --keep-elevation/--relaunched handling. The check is gone, not just disabled, so future-me can't bring it back without re-discovering the same bug.

Kept:
  - StartupTrace (still useful for any future startup mystery)
  - Self-healing NDI Find rebuild (c30a616) - still valuable for legitimate stuck-finder cases
  - System.Management PackageReference - TryGetParentProcessName still used in StartupTrace

Verified post-revert: Start Menu shortcut click -> PID 43060 -> full trace -> REST 2 participants. 252/252 tests still passing.
2026-05-16 16:27:23 -04:00
ea940ffac4 test(engine): extract ShouldAutoRebuild as pure fn + cover 6 cases
Some checks failed
CI / build-and-test (push) Failing after 27s
The self-heal trigger from c30a616 was time-based logic embedded in the RunAsync poll loop — easy to regress on a future refactor without anyone noticing. Pull it out into a public static ShouldAutoRebuild(sinceStart, sinceLastSeen, sinceLastRebuild) that returns the rebuild reason or null. RunAsync just calls it and acts on the result.

Six new test cases cover the matrix:
  - never seen + before warmup       -> hold
  - never seen + after warmup        -> rebuild
  - never seen + recent rebuild      -> backoff
  - had sources + long-gone          -> rebuild
  - had sources + recently gone      -> grace window
  - had sources + recent rebuild     -> backoff

112/112 Engine tests passing (was 106; +6 new).
2026-05-16 13:38:44 -04:00
aaa2a76814 docs(next-steps): NDI Find stuck-at-zero was the real bug; self-heal in c30a616
Some checks failed
CI / build-and-test (push) Failing after 26s
2026-05-16 13:36:58 -04:00
c30a6163c8 fix(engine): self-healing NDI discovery + unified poll loop
Some checks failed
CI / build-and-test (push) Failing after 26s
When a process spawns and NDI Find returns zero sources at cold start, the finder can stay stuck on zero forever even when other processes can see Teams' broadcasts. Observed today: a user's PID launched at 12:50, ran for 9+ minutes showing 0 sources, while a parallel PID launched at 12:59 immediately discovered 2 sources. Same exe, same install, same Teams meeting, same medium-integrity SAFER token. The first process's finder simply got into a bad state at construction (suspected: NIC-bind race against mDNS responder readiness, or a SAFER-token quirk in the NDI runtime's IPC layer).

The fix: auto-rebuild the finder when (a) we've never seen a source and 5s have passed since startup, or (b) the source set has been empty for 15s after previously containing entries. Both paths back off (>=5s and >=10s between rebuilds respectively) so we don't churn during legitimate empty periods.

Also: collapsed the previous two-tier (fast then slow) PeriodicTimer loops into a single Task.Delay loop with a dynamic interval. Same behavior (200ms for first 3s, then operator-configured pollInterval), less code, easier to thread the self-healing logic through. The finder is still disposed in a try/finally so cancellation paths don't leak.

246/246 tests still passing. The Discovery tests use PollOnce directly so RunAsync changes don't affect them.
2026-05-16 13:35:22 -04:00
54ee578fe9 fix(wpf): de-elevate via runas env-var marker (CLI arg breaks runas /trustlevel)
Some checks failed
CI / build-and-test (push) Failing after 26s
The earlier de-elevation attempts failed because runas /trustlevel:0x20000 rejects any args after the program path (returns exit code 1 silently). Switch the relaunch loop-guard from --relaunched CLI arg to TEAMSISO_RELAUNCHED env var, which runas inherits and propagates cleanly. Also: always demote when elevated regardless of parent (the parent==explorer heuristic was too narrow; the runas demotion is cheap enough to do unconditionally), and add a StartupTrace fallback log at %LOCALAPPDATA%\\TeamsISO\\startup-trace.log that captures every checkpoint in OnStartup so future launch failures can be diagnosed without Serilog being up.

Verified end-to-end: elevated parent (PID 47536, isAdmin=True) -> spawns runas -> medium-integrity child (PID 51228, isAdmin=False) -> NDI discovery succeeds (vm.Participants.Count=2 at +5s). The TryDeElevateAndExit now returns bool so spawn failures fall through to normal startup instead of leaving the process in a zombie state.

Opt-out: --keep-elevation CLI arg bypasses the demotion.
2026-05-16 12:16:55 -04:00
2552d46210 fix(installer): wrap shortcut Target in 'runas /trustlevel:0x20000'
Some checks failed
CI / build-and-test (push) Failing after 27s
The in-process ShouldDeElevate check (commit 191b2c5) didn't fire on the test box because ParticipantPID resolution against Win32_Process can return null fast enough that the check skips before the elevated explorer-spawned TeamsISO has fully booted. Belt-and-braces: ALSO wrap the shortcut Target so the runas demotion happens at shell-launch time, before TeamsISO.exe even runs. Result on the dev box: clicking the Start Menu / Desktop shortcut now lands a working medium-integrity TeamsISO with NDI discovery succeeding, regardless of explorer's elevation.

Uses [SystemFolder]runas.exe (resolved by MSI at install time) and Show='minimized' to hide the brief runas console flash.
2026-05-16 11:43:54 -04:00
0e73746b58 docs(next-steps): root cause was explorer-spawn elevation, fix shipped in 191b2c5
Some checks failed
CI / build-and-test (push) Failing after 27s
2026-05-16 11:39:31 -04:00
191b2c5f52 fix(wpf): de-elevate when spawned by elevated explorer (NDI mDNS isolation)
Some checks failed
CI / build-and-test (push) Failing after 27s
Observed behavior: on admin-user boxes with UAC effectively disabled, double-clicking the Start Menu / Desktop shortcut spawns TeamsISO with elevated File Explorer as parent. NDI Find then returns zero sources even when Teams is broadcasting — same exe spawned from any other parent (PowerShell, cmd, runas, etc.) discovers sources fine. Suspected window-station / desktop-handle inheritance quirk in NDI's mDNS layer; can't fix from inside the runtime.

Workaround: in OnStartup, if parent IS explorer.exe AND we're elevated AND we haven't already re-launched (--relaunched guard), re-spawn ourselves via 'runas /trustlevel:0x20000' to drop to medium integrity. Original process Shutdowns; only the medium child remains. Verified by reproducing the failure case in an elevated PowerShell, then watching the same runas command produce a working child (REST returns participants, log writes work).

Add PackageReference for System.Management (Win32_Process via ManagementObjectSearcher) so the parent-PID lookup compiles.
2026-05-16 11:36:52 -04:00
e01fa364e8 docs(next-steps): cold-start launch fix verified — 3 launch paths green
Some checks failed
CI / build-and-test (push) Failing after 26s
2026-05-16 11:24:37 -04:00
09e5b59dfd fix: cold-start discovery + installer shortcuts + single-instance hardening
Some checks failed
CI / build-and-test (push) Failing after 26s
Three independent fixes bundled because all were chasing the same operator
report: 'I just installed, launched from the shortcut, no participants.'

1) NdiDiscoveryService: poll immediately, then ramp from 200ms to the
   configured interval over the first 3 seconds. PeriodicTimer.WaitForNext-
   TickAsync waits the full interval before its first tick, so for a 500ms
   discovery interval the operator stared at 'no ndi sources yet' for half
   a second on every cold start. Force-poll up front (catches the runtime
   cache), then run a fast inner loop for ~3s while mDNS replies trickle
   in. Both loops share a try/finally so the NDI finder is always disposed.

2) MainViewModel.IsDiscovering: new boolean, true for 8s after engine start
   AS LONG AS no participants have arrived. MainWindow.xaml swaps the
   empty-state copy on this binding:
     IsDiscovering=true  -> 'scanning for ndi sources...' (cyan dot)
     IsDiscovering=false -> 'no ndi sources visible -- is teams in a
                            meeting?' + Refresh CTA
   The old copy ('no ndi sources yet -- open teams and start a meeting')
   was being shown immediately at launch even when discovery just hadn't
   run yet, making the app look broken.

3) App.xaml.cs: single-instance mutex moved from Local\ to Global\. On
   admin-user boxes with UAC disabled, launches from different parents
   (elevated File Explorer, non-elevated shell, etc.) can land in slightly
   different security contexts and a Local\ name can be invisible to the
   sibling. Global\ namespace closes that hole — both processes see the
   same mutex regardless of integrity. Belt-and-braces against future
   dual-instance file/port contention.

4) installer/Package.wxs: add a Desktop shortcut component (per-machine
   feature, HKCU keypath per ICE38/ICE43). Operators who can't find the
   Start Menu entry get the Desktop icon. Both shortcuts target the
   installed exe, NOT a stale path under publish/.
2026-05-16 11:23:19 -04:00
f47edfb2f6 ISO toggle: widen column 110->124, tighten padding so 'Enable' fits
Some checks failed
CI / build-and-test (push) Failing after 28s
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 08:57:27 -04:00
47914fcd77 ISO toggle: square corners to match the rest of the button family
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-16 08:56:50 -04:00
dba7dcc8a8 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-16 08:56:43 -04:00
6c9bee7391 fix(wpf): catch participant-left race in ToggleIsoAsync, toast instead of crash
Some checks failed
CI / build-and-test (push) Failing after 27s
The operator path: click Enable on a participant -> AsyncRelayCommand fires
ToggleIsoAsync -> IsoController.EnableIsoAsync(id) -> tracker lookup -> throws
InvalidOperationException 'Participant <guid> not currently visible on the
network' when the participant has departed between the click and the engine
resolving the id.

Previously this exception escaped AsyncRelayCommand.Execute via the unawaited
Task in ICommand.Execute, hit System.Threading.Tasks.Task.ThrowAsync, and
ended up in Dispatcher.UnhandledException — which the App.CrashHandlers path
treats as a fatal and fires the crash dialog. Fatal in the log captured
during this morning's session at 08:08:27.

Wrap the EnableIsoAsync / DisableIsoAsync calls in try/catch:
  - InvalidOperationException -> toast 'X just left the meeting'; leave
    IsEnabled at its current value (engine state of record)
  - Exception -> toast 'Couldn't toggle ISO for X: <message>'; same rationale
  - finally clause still flips IsProcessing back so the spinner clears

No new tests — the race is hard to trigger deterministically without
introducing a mocking seam on the controller. The behavior change is small
and the surface is the only call site for EnableIso/DisableIso from the
participant row.
2026-05-16 08:48:06 -04:00
84861dafa5 test: integration — App+MainWindow STA smoke, control-surface live VM, theme XAML load
Some checks failed
CI / build-and-test (push) Failing after 30s
Punch-list items 26 + 27 — three integration tests that need a live
WPF Application + STA dispatcher, sharing one WpfHostFixture so
Application is created exactly once for the suite (it's
one-per-AppDomain and any second `new Application()` throws).

* src/tests/TeamsISO.App.Tests/Integration/WpfHostFixture.cs (new)
  — long-lived STA thread that hosts a single Application instance
  and a Dispatcher; tests marshal work onto it via Run<T>() /
  Run(Action). WpfHostCollection wraps it as an
  ICollectionFixture so xUnit injects the shared fixture into
  any test class that opts in.
* src/tests/TeamsISO.App.Tests/Integration/IntegrationTests.cs
  (new) — single test class carrying all three cases:
  - AppStartup_FullChain_Constructs_WithoutThrowing — pre-loads
    Theme.Dark.xaml + WildDragonTheme.xaml via pack URIs, calls
    ThemeManager.Apply(), constructs MainViewModel with the stub
    controller, constructs MainWindow with the VM as DataContext,
    and asserts the Wd.Canvas brush key resolves on the live
    window. All DependencyObject access happens inside a single
    Dispatcher.Invoke so we never marshal a DO reference across
    threads (WPF's VerifyAccess would throw).
  - ControlSurface_GetParticipants_ReturnsLiveViewModelState —
    boots a ControlSurfaceServer on an ephemeral port against
    a real MainViewModel; publishes a synthetic participant
    through the stub controller's observable; drains the
    dispatcher to ApplicationIdle so the Background-priority
    add lands before the REST call; asserts the JSON includes
    Alice. Complements branch-9 route-smoke tests (which used a
    null view-model) by exercising the dispatcher-marshalling
    path.
  - ThemeXaml_DarkAndLight_BothLoadWithDistinctWdCanvas — loads
    both theme files directly via pack URIs and asserts the two
    canvas brushes are the documented #0A0A0A and #FAFAFB. Doesn't
    test ThemeManager.SwapColorDictionary against Application.
    Resources (the swap STATE test was flaky under xUnit's
    parallel-collection model — Application.Resources is
    process-wide and sibling tests' mutations made the read
    non-deterministic). The unit-layer ThemeManagerTests already
    cover the swap state machine against stubbed seams; this
    integration test guards that the real XAML files load and
    produce the documented colours.

Production code change to support both tests AND a longstanding
correctness issue:
* ThemeManager.SwapColorDictionary now constructs its replacement
  ResourceDictionary with a `pack://application:,,,/TeamsISO;component/Themes/…`
  absolute URI instead of the relative `/Themes/…` form. The
  relative form resolves against Application.Current's base URI —
  which is the entry assembly in production (TeamsISO) but the
  test assembly in xUnit. The pack URI is unambiguous in both
  contexts. Production behaviour is identical (still resolves to
  the same XAML files in the App assembly).

Notes-state collection: NotesServiceTests + OscBridgeDispatchTests
now share a NotesStateCollection xUnit collection because both
mutate the static NotesService.DirectoryOverride; without the
collection xUnit's parallel-collection scheduling let one class's
ctor clobber the override mid-test.

Xunit.StaFact 1.1.11 package added to the test csproj — primary
use was the early WpfFact-based iteration of these tests, kept
because Xunit.StaFact provides the [WpfFact] alternative if a
future test wants per-test STA without sharing the fixture.

Final test totals: 56 → 131 in App.Tests; 103 → 106 in
Engine.Tests. 237 tests pass. Build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:34:09 -04:00
55 changed files with 1363 additions and 6445 deletions

View file

@ -4,337 +4,83 @@ All notable changes to TeamsISO are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [1.0.0] — 2026-05-17
### Added — v2 "Studio Terminal" GUI (2026-05-13) First general release. Windows-only, .NET 8 WPF, NDI 6.
The May 2026 ground-up redesign — explicit anti-reference to "the v1 ### Engine
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/`.
- **PRODUCT.md** + **DESIGN.md**: impeccable context for the redesign. - **Participant discovery** over NDI with name cleanup — strips the
Solo broadcast operator at 1:50am is the canonical persona; "vibe-coded "MS Teams - " / "(Teams) " prefixes and surfaces the operator-friendly
GUI" is the explicit anti-reference. Tokens cover dark + light palettes display name.
with context-aware accent split (cyan surface fill stays bright in - **Per-participant ISO outputs** with normalized framerate, resolution,
both modes; cyan-as-text darkens to `#0E7C82` on light for AA contrast). aspect mode, and audio routing. Each ISO is an individually-addressable
- **Theme system** (`Themes/Theme.Dark.xaml`, `Theme.Light.xaml`, NDI source.
`WildDragonTheme.xaml`) + `Services/ThemeManager.cs` singleton that - **NDI Groups** support — discovery and sender. One-click "Apply
swaps the merged dictionary at runtime, reads transcoder topology" pins Teams' raw broadcasts to a private
`HKCU\…\AppsUseLightTheme` for System mode, subscribes to `teamsiso-input` group while TeamsISO re-emits on `Public`.
`SystemEvents.UserPreferenceChanged`, persists via - **Self-healing finder** — if the NDI runtime stalls (zero discovered
`UIPreferences.Theme`. `Ctrl+T` toggles dark ↔ light. sources past a startup grace period, or sources go from present to
- **v2 main window shell**: default system title bar; 32px header (Wild empty and stay that way), the engine rebuilds the finder automatically.
Dragon mark + wordmark left, ⌘K / theme / settings icons right); 40px - **Real-time recording** — per-output raw BGRA stream + `manifest.json`
transport strip (`● 02:14:32 PART 4 · LIVE 2 CTRL :9755`); body with + an FFmpeg `convert.cmd` script for post-production conversion to
alert banner + update banner + action toolbar + participants H.264 MKV. Recording is opt-in globally and per-participant.
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.
### Added — May 2026 feature batch ### UI — "Studio Terminal"
#### Engine - **Dark and light themes** with a runtime swap and a system-follow mode.
- NDI Groups: discovery + sender support so Teams' raw broadcasts can be The Wild Dragon mark, the participants-grid watermark, and every accent
pinned to a private "teamsiso-input" group while TeamsISO's own brush respond to the active theme.
normalized outputs broadcast on Public. - **Header**: brand mark, theme toggle, settings gear.
- One-click "Apply transcoder topology" writes `ndi-config.v1.json` so all - **Transport strip**: session timer, participant count, live ISO count,
Teams broadcasts go to the private group and TeamsISO re-emits on Public. control-surface URL — at-a-glance status.
- `RawBgraRecorderSink` per-output recorder: `IRecorderSink` interface + - **Participants table**: 24px state LED, 106px live thumbnail preview,
raw BGRA stream + `manifest.json` + `convert.cmd` script for FFmpeg name + caption, 5-bar audio meter, **inline-editable output name**,
conversion to H.264 MKV. CFG button (per-row override editor), ISO enable pill.
- Recording markers: `IRecorderSink.AddMarker(label)` fan-out via - **Settings drawer** — slide-over from the right with OUTPUT / NETWORK /
`IIsoController.AddRecordingMarker`. Markers land in `manifest.json` APP tabs.
under `markers[]` for post-production chaptering. - **Ctrl+K command palette** — fuzzy search across Quick / Teams /
- Preview thumbnails: `IsoPipeline.LatestProcessedFrame` published via Presets / Output / Network / App categories.
`Volatile.Read` so the UI can render 160×90 BGRA thumbnails in the - **Live preview thumbnails** in the participants table; right-click →
participants DataGrid at 1Hz. Open preview… spawns a non-modal floating window suitable for a
- Idempotent `ParticipantTracker.HandleAdded`: re-emitting Added for an secondary monitor.
already-live source refreshes LastSeen instead of duplicating the row.
Fixes the "click Refresh and rows ghost-duplicate" bug introduced by
the Refresh-Discovery affordance.
- `IIsoController.RefreshDiscovery()` rebuilds the NDI finder on the next
poll tick — useful right after applying a new transcoder topology.
- `IIsoController.AddRecordingMarker(label)` fan-out to every active recorder.
- `IIsoController.SetRecording(enabled, dir)` global recording toggle;
per-participant override via `EnableIsoAsync(...recordOverride...)`.
#### Host (WPF) ### Output name template
- Active Speaker as a synthetic routable participant with deterministic v5
GUID derived from `auto-mix:<machine>`.
- Auto-disable on departure: when a participant's NDI source disappears,
optionally tear down their pipeline.
- Operator presets: chromeless `Presets…` dialog with Save / Apply /
Delete / Duplicate / Export / Import. Persisted at
`%LOCALAPPDATA%\TeamsISO\presets.json`. Bundle format
`teamsiso-presets-bundle/v1` for migration between machines.
- Auto-apply last preset on launch (configurable, off by default).
- `--apply-preset NAME` CLI flag for desktop-shortcut workflows.
- `PresetApplier` — single source of truth for "apply preset to live
participants" used by the dialog, REST surface, and auto-apply path.
- Live preview thumbnails per participant (160×90 BGRA WriteableBitmap).
- Right-click context menu on participant rows: Toggle ISO, Record-this-
participant, Copy NDI source name.
- Live filter input (substring match on display name).
- "Enable all online" + "Stop all ISOs" + "Refresh" header actions.
- Per-participant recording opt-out checkbox (Rec column).
- Custom NDI output name template with `{name}`/`{guid}`/`{machine}`/
`{timestamp}` tokens.
- Phase E.1 — Launcher: rail "Launch / Stop Teams" toggle.
- Phase E.2 — Window orchestration: hide / show Teams windows from the rail.
- Phase E.3 — In-call controls (UIA): Mute, Camera, Share, Leave, Raise hand,
plus PostMessage shortcut forwarding fallback. Candidate names localized
for English / German / Spanish / French / Portuguese / Japanese.
- Crash diagnostics: AppDomain + Dispatcher + TaskScheduler unhandled
exception handlers wired to Serilog.Critical + user-facing dialog.
- First-launch onboarding dialog with 5-step setup checklist.
- About dialog gained "Show welcome", "Check for updates", "Export diagnostics"
buttons.
- Diagnostic bundle export: zips logs + config + presets + version metadata
into `~/Downloads/teamsiso-diagnostics-<ts>.zip` for bug reports.
- Update check: manual via About + auto-on-launch banner (throttled to 24h,
opt-out via flag file at `%LOCALAPPDATA%\TeamsISO\no-update-check.flag`).
- Disk space watcher auto-disables recording at <1GB free.
- Settings panel refactored into OUTPUT / NETWORK / DISPLAY tabs.
- Reset-to-defaults button in OUTPUT tab.
- Enriched footer: REC badge, control-surface badge, session timer (HH:MM:SS
since first ISO went live), dynamic status text ("3/5 ISOs live · 2 recording").
- Window-scoped keyboard shortcuts: F1 (help), Ctrl+M (marker), Ctrl+Shift+S
(stop all), Ctrl+R (refresh discovery).
- F1 help / cheat-sheet dialog.
- `UIPreferences` static persists `HideLocalSelf`, `AutoDisableOnDeparture`,
`ParticipantSort` (JoinOrder / Alphabetical / OnlineFirst) across launches
to `%LOCALAPPDATA%\TeamsISO\ui-prefs.json`.
- Pop-out per-participant preview window (right-click → Open preview…)
refreshes at ~20Hz and is multi-monitor friendly.
- Configurable participant sort order via the DISPLAY tab dropdown.
- Stop-All confirms before tearing down running pipelines (catches
mid-show misclicks).
- About dialog gained "Logs / Recordings / Notes" folder shortcut buttons.
- `NotesWindow` inline viewer for today's show-notes file with 2s polling.
- Duplicate-preset action in the Presets dialog with smart `(copy N)`
name suggestions.
- `--apply-preset NAME` command-line flag for desktop-shortcut workflows.
- New `TeamsISO.App.Tests` net8.0-windows test project. Initial coverage:
`OperatorPresetStoreTests` (round-trip, name collisions, schema, bundle
import/export, garbage-file resilience), `OutputNameTemplateTests` (token
expansion + sanitization), `OscMessageTests` (wire-format parsing of
int/float/string/T/F type tags). Backed by an `InternalsVisibleTo` grant
+ a test-only `OperatorPresetStore.PathOverride` hook.
- `IsoHealthStats.PeakAudioLevel` field + DataGrid VU-bar UI scaffolding.
Engine still emits 0.0 (audio capture is a focused follow-up); the bar's
decay logic is in place so it animates as soon as engine-side audio
parsing lands.
- `MediaFoundationRecorderSink` scaffold under `#if MF_AVAILABLE` for
inline H.264 encoding via Vortice.MediaFoundation. ~10× smaller files
than the raw BGRA recorder. Activation steps documented at
`docs/REAL-TIME-RECORDING.md`.
- System-tray icon + minimize-to-tray toggle. Adds
`<UseWindowsForms>true</UseWindowsForms>` for `NotifyIcon`; the
`TrayIconHost` lives on `App` (process lifetime, not main-window
lifetime). Right-click menu has Show / Stop all ISOs / Exit.
- Built-in NDI test pattern: `TeamsISO.Console --test-pattern` broadcasts
a synthetic 1280×720 30fps source named `TEAMSISO_TEST` showing SMPTE
color bars + a moving sweep band. Verifies NDI runtime, sender
configuration, and downstream discovery without needing Teams running.
Backed by `TestPatternGenerator` in the engine + 4 unit tests covering
buffer size, alpha, color distinctness, and sweep animation.
- Always-toast on participant disconnect, regardless of `AutoDisableOnDeparture`
setting. Distinguishes "ISO torn down" (auto-disable on) from "ISO still
running on slate" (auto-disable off) so operators don't miss a silent
drop mid-show.
- **Restart this ISO** right-click action — disable + brief delay + re-enable
for one participant only. Useful when a single feed flakes without
affecting other ISOs.
- **Roll recording** action: rolls every active recording into a new chunk
(disable + re-enable each pipeline; recorder finalizes its `manifest.json`
and starts a fresh subdirectory). Surfaced via `MainViewModel.RollRecordingCommand`,
REST `POST /recording/roll`, and OSC `/teamsiso/recording/roll`. Useful
for chaptering between show segments.
- **Engine audio peak metering**`IsoHealthStats.PeakAudioLevel` now
reports real values (was always 0.0). New `INdiInterop.CaptureAudioPeak`
method polls audio frames; production `NdiInteropPInvoke` parses
`NDIlib_audio_frame_v3_t` and computes max-absolute peak across all
channels. `NdiReceiver` runs a sibling audio capture loop on the same
lifetime so the existing video path is unaffected. UI VU bars in the
participants DataGrid now animate against real source audio. Failures
in the audio loop are caught + logged but never re-thrown — a
misbehaving audio path must never tear down the live video pipeline.
14 new unit tests in `AudioPeakComputerTests` covering FLTP / FLT / PCM
s16 across edge cases (clipping, `short.MinValue` overflow, defensive
`totalSamples`-vs-buffer mismatch handling).
#### LAN-reachable control surface - New default: **the speaker's display name** (`{name}`). Per-participant
- `ControlSurfaceServer.Start(port, bindToLan)` and `OscBridge.Start(port, overrides are inline-editable in the table. Empty-name fallback to
bindToLan)` switch between `127.0.0.1` and all-interfaces (`http://+:port/`, `TEAMSISO_{guid}` keeps the NDI sender uniquely identifiable while a
`IPAddress.Any`) based on the new `ControlSurfaceLanReachable` UI preference. participant's display name resolves upstream.
Settings VM persists the toggle, restarts both surfaces on flip, and - Available tokens: `{name}`, `{guid}`, `{machine}`, `{timestamp}`.
surfaces a `ControlSurfaceUrl` (computed from the host's first physical-NIC
routable IPv4 — Tailscale / VPN / APIPA addresses are skipped) plus a Copy
button. Use case: headless host PC running Teams + TeamsISO; thin client
on the same LAN drives `/ui` or hits the REST endpoints. Closed-network
deployment, no auth — documented as a trusted-LAN-only mode in
`docs/CONTROL-SURFACE.md`. First-time use requires a one-shot
`netsh http add urlacl url=http://+:9755/ user=Everyone` (so the listener
can bind without admin); the diagnostic warning fires the exact command
string in the log if the bind fails.
#### "I only see TeamsISO" — Phase E.1+E.2 quality-of-life ### Operator presets
For operators who want to launch TeamsISO and never look at the Teams UI:
- **Launch Microsoft Teams on TeamsISO startup** preference (DISPLAY tab).
Auto-fires Teams in the background each time TeamsISO starts; the Teams
window appears briefly during boot then can be hidden automatically.
- **Auto-hide Teams windows when launched** preference (DISPLAY tab).
`TeamsLauncher.AutoHideAfterLaunchAsync` polls for 15s after launch
hiding splash, main window, and follow-up panels as they materialize.
Works on top of the existing eye-toggle for manual restore.
- **Quick-join Teams meeting from URL** — small input + Join button in the
IN-CALL bar. Paste a `https://teams.microsoft.com/l/meetup-join/...`
or `msteams:/l/meetup-join/...` link, click Join, Teams launches into
the meeting in one shot. Eliminates the open-Teams → Calendar → find →
click Join dance. Pairs with auto-hide so the operator goes straight
from "I have a meeting link" to "I'm in the meeting, driving routing".
- **Teams meeting state pill** in the IN-CALL bar — shows `IN CALL · <meeting title>`
/ `READY` / empty. UIA-driven probe of Teams' Leave button at 1Hz; the
meeting title comes from Teams' window title with the brand suffix
stripped. So an operator with auto-hide on knows whether they're in a
meeting AND which one without restoring the Teams window. 10 new unit
tests on `MainViewModel.ExtractMeetingTitle`.
- **Rail Launch Teams click semantics** — was ambushing operators with a
"Close all Teams windows now?" dialog whenever Teams was running (e.g.
when hidden via the eye-toggle). Now click = launch / surface / restore;
right-click = stop. `TeamsLauncher.TryLaunch` now collects per-attempt
errors (no more silent fall-through) and adds the AppX-activation
fallback for hosts where the `ms-teams:` URI handler is misconfigured.
- **Auto-record when Teams joins a meeting** preference. Recording auto-
flips ON when Teams transitions into a call (UIA Leave button appears)
and auto-flips OFF when the call ends. Removes the manual Record toggle
step from unattended-show workflows.
- **Phase E.4 (experimental) — SetParent embedding.** Reparents Teams' main
window into a TeamsISO-owned host (`TeamsEmbedWindow`) so Teams appears
visually INSIDE TeamsISO. Strips Teams' window chrome and resizes to
fit. Modern Teams runs WebView2 in its main window which can render
glitches after reparent; if so the operator unticks and falls back to
auto-hide mode. `TeamsLauncher.EmbedTeamsInto` / `RestoreEmbed` /
`ResizeEmbedded` form the lifecycle. Restore-on-close runs in a finally
block so a crash can't leave Teams orphaned with stripped window styles.
- **Right-click → Save current frame** on a participant row. Encodes the
latest `ProcessedFrame` as a PNG under
`%USERPROFILE%\Pictures\TeamsISO\<participant>_<timestamp>.png`.
Useful for highlight reels, social posts, bug reports.
- **Open /ui button** in Settings → DISPLAY → Control surface section.
Fires the URL into the default browser for one-click preview of the
embedded control panel.
- **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.
- **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.
- **Recording drive free space** in the footer (`· 245 GB free`). Coral
tint below 10GB; existing DiskSpaceWatcher still auto-disables at 1GB.
- **Loudest sort mode** for the participants DataGrid + **active speaker
row highlight** (3px cyan left border + tinted background) on whoever's
speaking. Operators react to who's talking without scanning every VU bar.
- **Snapshot all enabled participants** — header action saves every
enabled participant's current frame as a PNG into a fresh timestamped
subfolder under `%USERPROFILE%\Pictures\TeamsISO\snapshots-<ts>\`.
- **NumPad 1-9 (and Digit 1-9) hotkeys** toggle the Nth visible
participant's ISO. Sort + filter aware — index matches what's on screen.
Generic `RelayCommand<T>` added to ViewModels/RelayCommand.cs so XAML
CommandParameter strings convert to the action's T.
#### UI polish — visible affordances on the dark canvas - Save current per-participant ISO assignments + custom output names to
- Hover state on every themed button (Ghost / Caption / RailIcon / IsoToggle) `%LOCALAPPDATA%\TeamsISO\presets.json`. Optional auto-apply on next
was barely distinguishable from the resting state. Bumped `Wd.SurfaceHover` launch.
+ `Wd.SurfaceActive` colours for sufficient contrast, added dedicated
`Wd.Button.HoverBg` / `Wd.Button.PressBg` brushes with a slight chroma tint,
and added cyan accent borders so mouse-hover and tab-focus give an
unmistakable affordance regardless of which surface the button sits on.
IsoToggle keeps its status-coded background (LIVE cyan / ERROR coral /
NO SIGNAL amber) on hover; the affordance is a 2px cyan border pop.
- `IsKeyboardFocused` triggers added on every themed button so tab-cycling
through the UI gives visual feedback (was nothing — `FocusVisualStyle`
was `x:Null` with no replacement).
- ScrollBar restyled: slim transparent track + tinted thumb (Edge / VS Code
pattern) in place of the chunky Win9x default with line-up / line-down
arrow buttons. Track-clicks above/below the thumb still page-scroll.
- ToolTip restyled: SurfaceElevated card with rounded 6px corner + 320px
text wrap, replacing the cream Win98 popup. Affects every tooltip across
MainWindow.
- ContextMenu / MenuItem restyled: dark card with rounded corners + cyan-
tinted hover. Affects the right-click menu on participant rows
(Toggle ISO / Restart this ISO / Open preview / Record / Copy NDI source name).
- CheckBox content no longer clips at the 380px settings panel: template's
StackPanel replaced with a Grid (Auto + *) and a TextWrapping=Wrap
resource injected into the ContentPresenter so long labels flow onto
multiple lines.
- Manual X dismiss on toast notifications for live-show situations where
the operator wants to clear visual clutter without waiting 3s.
- Footer's control-surface badge surfaces the full LAN URL (not just port)
when LAN-reachable mode is on, so a thin client can be configured by
reading the URL straight off the host's footer.
#### Control surface ### Teams orchestration
- REST API on `127.0.0.1:9755` with endpoints for participant ISO toggle (by
Id or display name), preset apply, refresh discovery, stop-all, recording
on/off, marker drop, notes, and Teams in-call commands. Documented at
`docs/CONTROL-SURFACE.md`.
- WebSocket `/ws` pushes live participant state at 4Hz with snapshot diffing.
- OSC bridge on UDP `127.0.0.1:9000` mirrors the REST vocabulary
(`/teamsiso/iso "Jane" 1`, `/teamsiso/preset "..."`, etc.).
- Embedded HTML control panel at `GET /ui` — phone-friendly remote with
live state and one-click action buttons.
- Show notes service: `POST /notes` and `/teamsiso/notes "..."` append
timestamped lines to `%LOCALAPPDATA%\TeamsISO\Notes\<YYYY-MM-DD>.md`.
#### CI / Release - Launch / stop Teams from the app.
- Forgejo CI is green; tag-push release workflow builds + tests + publishes - Hide Teams' UI windows during a show.
+ builds MSI on a Windows runner and attaches it to the auto-created - Drive in-call controls (mute, camera, share, leave, raise hand) via
release via the REST API. UIAutomation.
- Optional MSI + exe code-signing wired into `release.yml` — gated on
`SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD` Forgejo Secrets.
### Fixed ### External control surface
- `.slnf` path-separator mismatch (forward slashes for cross-platform). - REST + WebSocket on `127.0.0.1:9755` for Bitfocus Companion / Stream
- NDI native DLL resolution via `NativeLibrary` resolver. Deck / custom controllers.
- `ExpectedRuntimeVersionPrefix` updated to NDI 6 banner format. - OSC on UDP `127.0.0.1:9000` for TouchOSC.
- `NdiSourceParser` accepts current Teams desktop's `MS Teams - <name>` - Self-contained HTML control panel at `/ui` — open from any phone on
brand format. the LAN.
- ActiveSpeaker source removal no longer poisons the rename-window
heuristic for a Participant joining the same machine within the window.
- `IsoPipeline.State` access synchronized via `Volatile.Read/Write`.
- REST handlers now correctly marshal `ObservableCollection` reads + writes
through the UI dispatcher.
- WebSocket upgrade no longer falls into `res.Close()` finally block (was
killing freshly-upgraded connections).
- `ParticipantViewModel.UpdateThumbnail` defends against malformed frames
(`width*height*4 > Pixels.Length`).
- `HasThumbnail` correctly fires `PropertyChanged` when `Thumbnail`
transitions from null.
- WinForms / WPF `Application` and `MessageBox` namespace collision
(introduced when `<UseWindowsForms>true</UseWindowsForms>` was added for
the system tray) resolved via project-wide `GlobalUsings.cs`.
- `GetLanIPv4()` now skips Tailscale / VPN tunnel adapters and APIPA
(`169.254.x`) so the displayed control-surface URL points at the
routable LAN IP (verified on a host with both Ethernet 10.x and a
Tailscale 169.254 link-local — picker now correctly returns the
Ethernet address).
[Unreleased]: https://forge.wilddragon.net/zgaetano/teamsiso/compare/v0.1.0...HEAD ### Diagnostics & installer
- Rolling daily Serilog logs under `%LOCALAPPDATA%\TeamsISO\logs\`.
- Diagnostic bundle export — zips logs + config + presets for bug reports.
- Forgejo-backed update check (manual or silent-on-launch, throttled to
24h).
- WiX MSI installer with proper Add/Remove Programs metadata, Start Menu
+ Desktop shortcuts, and in-place upgrade.
[1.0.0]: https://forge.wilddragon.net/zgaetano/teamsiso/releases/tag/v1.0.0

340
DESIGN.md
View file

@ -1,340 +0,0 @@
# DESIGN.md — TeamsISO design system
Target framework: **WPF .NET 8**. Tokens are framework-agnostic; the WPF
XAML implementation lives in `src/TeamsISO.App/Themes/`. (A WinUI 3 rebuild
was attempted and rolled back — see
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md` for the v2 shape
this design serves.)
## Color
### Strategy
**Restrained — committed accent + neutral surface.** The surface is the work;
the cyan accent is reserved for live state, focus, and the few moments that
actually need attention. Coral is reserved for destructive and error.
Everything else is neutral.
This means: no rainbow status pills, no per-feature accent colors, no
Slack-style chroma everywhere. If something is cyan, the operator's eye
should know why.
### Scene sentence
**Dark (default):** A solo broadcast operator at 1:50am, ambient room lights
at 5%, leaning into a 24-inch monitor, twenty minutes before a live
international interview.
**Light:** A morning recording session in a glass-walled conference room with
the sun coming through the blinds, monitor brightness at 80%. Or a daytime
producer monitoring a remote interview from a hotel desk during a working
session before lunch.
The default is dark — that's the dominant operator scene. Light mode exists
because not every show happens at 1:50am.
### Dark palette
Every neutral is tinted toward cyan (h ≈ 200, chroma 0.0050.008) so the
dark surface reads as deliberate dark, not as chromatically dead.
| Token | Role | Hex | OKLCH (approx) |
|---|---|---|---|
| `bg.canvas` | Window canvas | `#0A0A0A` | `oklch(0.12 0.005 200)` |
| `bg.rail` | Left rail | `#080808` | `oklch(0.10 0.005 200)` |
| `bg.surface` | Card / row | `#141416` | `oklch(0.18 0.006 200)` |
| `bg.elevated` | Popovers, menus | `#1C1C1F` | `oklch(0.22 0.007 200)` |
| `bg.hover` | Hover fill | `#26272B` | `oklch(0.28 0.008 200)` |
| `bg.active` | Pressed fill | `#33343A` | `oklch(0.34 0.010 200)` |
| `border.subtle` | Hairlines | `#26272B` | `oklch(0.28 0.008 200)` |
| `border.strong` | Hover / focus | `#3A3B40` | `oklch(0.36 0.010 200)` |
| `fg.primary` | Body text | `#F4F4F6` | `oklch(0.96 0.004 200)` |
| `fg.secondary` | Subdued text | `#A3A4AA` | `oklch(0.70 0.006 200)` |
| `fg.tertiary` | Captions | `#6B6C72` | `oklch(0.50 0.006 200)` |
| `fg.disabled` | Disabled | `#404145` | `oklch(0.32 0.006 200)` |
### Light palette
Mirrored token names; cyan-tinted off-white so the surface still reads as
Wild Dragon, not as generic white.
| Token | Role | Hex | OKLCH (approx) |
|---|---|---|---|
| `bg.canvas` | Window canvas | `#FAFAFB` | `oklch(0.98 0.003 200)` |
| `bg.rail` | Left rail | `#F0F1F3` | `oklch(0.95 0.004 200)` |
| `bg.surface` | Card / row | `#FFFFFF` | `oklch(1.00 0.000 200)` |
| `bg.elevated` | Popovers, menus | `#FFFFFF` | `oklch(1.00 0.000 200)` (+ shadow) |
| `bg.hover` | Hover fill | `#ECEEF1` | `oklch(0.93 0.005 200)` |
| `bg.active` | Pressed fill | `#E0E3E7` | `oklch(0.89 0.006 200)` |
| `border.subtle` | Hairlines | `#E5E7EB` | `oklch(0.91 0.004 200)` |
| `border.strong` | Hover / focus | `#D1D5DA` | `oklch(0.85 0.006 200)` |
| `fg.primary` | Body text | `#0A0A0A` | `oklch(0.12 0.005 200)` |
| `fg.secondary` | Subdued text | `#4A4B50` | `oklch(0.36 0.006 200)` |
| `fg.tertiary` | Captions | `#71747A` | `oklch(0.53 0.006 200)` |
| `fg.disabled` | Disabled | `#B3B6BC` | `oklch(0.76 0.005 200)` |
### Accents — context-aware
Some accents work in both modes; others need a darker variant for AA contrast
when used as text on the light canvas. The token table splits them:
| Token | Dark | Light | Reserved for |
|---|---|---|---|
| `accent.cyan.surface` | `#97EDF0` | `#97EDF0` | Primary button fill, badge fill (text on top is near-black in both modes — works) |
| `accent.cyan.text` | `#97EDF0` | `#0E7C82` | Cyan-as-text (links, "live" labels, active state) |
| `accent.cyan.hover` | `#B5F2F4` | `#0890A0` | Cyan hover |
| `accent.cyan.muted` | `#1B3537` | `#E6F8F9` | Cyan tint background, active speaker row fill |
| `accent.coral` | `#FB819C` | `#D43E5C` | Destructive, error, alert (as both border + text) |
| `accent.coral.bg` | `#3A1922` | `#FDECF0` | Coral tint background |
| `status.live` | `#4ADE80` | `#15803D` | Recording active, REC dot, "live" pill |
| `status.live.bg` | `#13261A` | `#DCFCE7` | Live pill background |
| `status.warn` | `#FBBF24` | `#B45309` | Low disk, NDI degraded |
**Discipline.** Cyan is the only color that competes with body text for
attention. It earns its place — wasted cyan is the design failing.
`accent.cyan.surface` (#97EDF0) reads identically in both modes because
its text is always near-black. `accent.cyan.text` exists specifically so
captions and inline labels stay readable on a light canvas.
## Theming
### The toggle
A single icon button (sun ↔ moon) lives in the title bar, positioned to the
left of the window controls. One click swaps the theme. State persists via
`UIPreferences.Theme` (`Dark | Light | System`). Default is `System` which
follows the Windows app-mode preference.
The toggle is also surfaced inside the settings drawer under an "Appearance"
group as a tri-state pill (System / Dark / Light), so power users find it in
the obvious place too.
### Implementation (WPF)
WPF doesn't have WinUI 3's `ThemeDictionary` pattern. The equivalent is to
**split tokens by theme into separate ResourceDictionary files**, all
addressed via `DynamicResource` (NOT `StaticResource`) so the values can
be swapped at runtime.
```
Themes/
Theme.Tokens.xaml ← styles, control templates, key shape (no colors)
Theme.Dark.xaml ← color resources only — Dark variant
Theme.Light.xaml ← color resources only — Light variant
```
`Theme.Dark.xaml` and `Theme.Light.xaml` define the SAME set of keys —
`Wd.Bg.Canvas`, `Wd.Accent.Cyan`, etc. — with different `Color` values.
`Theme.Tokens.xaml` references them via `DynamicResource` from styles and
templates. At startup, `App.xaml` merges `Theme.Tokens.xaml` plus exactly
one of `Theme.Dark.xaml` or `Theme.Light.xaml`. At runtime, `ThemeManager`
swaps the merged dictionary's color file:
```csharp
var app = Application.Current;
var oldDict = app.Resources.MergedDictionaries
.First(d => d.Source?.OriginalString.EndsWith("Theme.Dark.xaml") == true
|| d.Source?.OriginalString.EndsWith("Theme.Light.xaml") == true);
var idx = app.Resources.MergedDictionaries.IndexOf(oldDict);
app.Resources.MergedDictionaries[idx] = new ResourceDictionary {
Source = new Uri($"/Themes/Theme.{newTheme}.xaml", UriKind.Relative)
};
```
`DynamicResource`-backed `SolidColorBrush` instances re-resolve on the
dictionary swap, so the visual tree repaints without an app restart.
### System mode
When `UIPreferences.Theme == "System"`, `ThemeManager` reads
`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme`
at startup. It also subscribes to `SystemEvents.UserPreferenceChanged` so
the app re-resolves the theme when the operator flips Windows app-mode
mid-session. This is the default — operators who don't care get whatever
their Windows session is set to.
## Typography
### Scale (1.25 step ratio enforced)
| Token | Family | Size | Weight | Line-height | Letter-spacing |
|---|---|---|---|---|---|
| `text.display` | Inter | 22 | 600 | 1.2 | -0.01em |
| `text.title` | Inter | 18 | 600 | 1.25 | -0.005em |
| `text.heading` | Inter | 14 | 600 | 1.3 | 0 |
| `text.body` | Inter | 13 | 400 | 1.45 | 0 |
| `text.subtle` | Inter | 13 | 400 | 1.45 | 0 |
| `text.caption` | Inter | 11 | 500 | 1.3 | 0.04em (smallcaps) |
| `text.mono` | JetBrains Mono | 12 | 400 | 1.4 | 0 |
Body text caps at 6575ch where it wraps. Inline status text doesn't wrap —
it truncates with ellipsis.
### Fonts in WPF
Bundled fonts ship in `src/TeamsISO.App/Assets/Fonts/` and resolve via
`pack://application:,,,/Assets/Fonts/#Inter` / `#JetBrains Mono`. The
`<Resource>` glob in `TeamsISO.App.csproj` already covers the `.ttf` files;
new font weights go in the same directory and pick up automatically.
## Spacing (8px grid)
| Token | Value | Use |
|---|---|---|
| `space.xs` | 4 | Icon-to-text, tiny gaps |
| `space.s` | 8 | Row internal padding, pill padding |
| `space.m` | 12 | Card internal padding |
| `space.l` | 16 | Card padding, between cards |
| `space.xl` | 24 | Section gap |
| `space.xxl` | 32 | Page edge padding |
| `space.xxxl` | 48 | Hero section / large blocks |
**Rhythm rule.** No two adjacent regions share the same padding value. The
participant table breathes at `space.xl`; in-row controls compress to
`space.s`. Same padding everywhere is monotony.
## Radii
| Token | Value | Use |
|---|---|---|
| `radius.s` | 6 | Pills, inline tags, menu items |
| `radius.m` | 8 | Buttons, text inputs, dropdowns |
| `radius.l` | 12 | Cards, drawers, modals |
| `radius.pill` | 999 | Status pills, ISO toggle |
## Elevation
Elevation through **tone**, not through shadow. The dark surface makes
realistic drop-shadows look bolted-on. A `bg.elevated` tone difference does
the same job with less visual noise.
| Layer | Background | Border |
|---|---|---|
| Canvas | `bg.canvas` | none |
| Card | `bg.surface` | `border.subtle` |
| Drawer / Popover | `bg.elevated` | `border.strong` |
| Modal | `bg.elevated` | `border.strong` + 50% canvas scrim |
## Icons
**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).
Sizes: 16 (inline), 20 (button), 24 (rail / hero).
Stroke: inherited from font; no hand-stroked paths.
## Motion
- Ease-out exponential (`cubic-bezier(0.16, 1, 0.3, 1)`) for entry.
- Ease-in-out for state changes that aren't entries.
- 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).
## Component decisions
### Buttons — finally have a real hierarchy
The previous design used `Wd.Button.Ghost` for everything. The redesign has
**three commitments**:
| Variant | Use | Look |
|---|---|---|
| `Primary` | Single per surface, the brand action ("Apply", "Start session") | Cyan fill, near-black text |
| `Secondary` | Common operator actions ("Refresh", "Presets") | Transparent fill, `border.strong`, hover cyan border |
| `Tertiary` | Inline, low-frequency ("Dismiss", "Show advanced") | Text-only, no border, cyan on hover |
| `Destructive` | Stop, leave, delete | Coral border, coral text, no fill |
**One Primary per surface.** If a screen has two primaries, the design is
unranked.
### ISO toggle — keep, refine
The status-coded pill (LIVE cyan / ERROR coral / NO SIGNAL amber) is good.
Two evolutions:
1. The hover treatment thickens to a 2px cyan border — preserve.
2. Add a half-height ascender showing instantaneous audio level above the
pill. The operator sees who's talking without needing the active-speaker
row highlight to fire on next tick.
### Tables (Participants)
This is the product. The table gets:
- Row height 56 (current) → 64 to give the audio meter + signal indicator
room to breathe.
- The "active speaker" cyan left-border treatment stays. It's good.
- One participant action per row at rest (the ISO toggle). Other actions
(open preview, custom name, presets) live in a right-click context menu
(already exists) and in a row hover-revealed kebab — *not* visible at rest.
- Column count: avatar+name · NDI signal+codec · audio meter · output name ·
ISO toggle. Five columns. The current six-plus + custom-name editing
inline pushes density too far.
### Status — one place, not three
Recording / disk / session / control-surface state currently lives in:
1. Rail bottom dot (engine status)
2. Header right pill (status text)
3. Footer columns (six monospace fields)
The redesign consolidates to **two places only**:
- **Header right** — session timer, REC indicator + count, disk-free.
These are at-a-glance.
- **Status overlay (popover from rail bottom dot)** — control surface URLs,
log path, version, control-surface tokens. These are on-demand.
The footer goes away entirely. It was theatre, not information.
### Settings — drawer, not permanent panel
The 380px right settings panel is the single biggest spatial misallocation.
Settings are rarely changed mid-show. The redesign moves them to a **right-side
drawer** that slides in over the participants area, dismissable with `Esc`.
The participants table reclaims full width when the drawer is closed.
Trigger: rail "settings" icon. Same affordance as today, different surface.
### Onboarding
First-launch only. Three panes max, each one panes deep — no carousel.
Operator-tone copy ("Pick your NDI groups" not "Welcome to TeamsISO!").
Skippable from the first frame.
### Empty states
The participants table empty state currently is implicit (rows just don't
appear). The redesign adds **one** empty state with a single instructive
sentence ("No NDI sources yet — open Teams and start a meeting") and a
single secondary button ("Refresh"). No illustration. No mascot.
## Anti-patterns specific to this app (audited against absolute bans)
The current XAML has none of the impeccable absolute bans (no gradient text,
no side-stripe borders, no glassmorphism). It does have:
- **Identical card grids** — the in-call control bar's seven identical ghost
buttons. Redesign: collapse to a single dense bar with primary controls
surfaced and secondary controls in an overflow menu.
- **Status duplication** — fix as above.
- **Bespoke SVG icons** — fix as above.
## 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
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.
This means: the engine, the OSC bridge, the control surface, the preset
store, the recording pipeline — none of those move. The redesign is
a frontend-only operation.

View file

@ -6,7 +6,7 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest</AnalysisLevel> <AnalysisLevel>latest</AnalysisLevel>
<Version>1.0.0-alpha.0</Version> <Version>1.0.0</Version>
<Authors>Wild Dragon LLC</Authors> <Authors>Wild Dragon LLC</Authors>
<Company>Wild Dragon LLC</Company> <Company>Wild Dragon LLC</Company>
<Product>TeamsISO</Product> <Product>TeamsISO</Product>

View file

@ -1,81 +0,0 @@
# Where we left off — v2 "Studio Terminal" shell complete (2026-05-15)
## What's done on main
**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.
**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.
**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
```
The shipped helpers `build-and-test.ps1` and `commit-and-push.ps1`
wrap the build + test + push flow.
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.

View file

@ -1,181 +0,0 @@
# PRODUCT.md — TeamsISO
## Register
**Product.** This is a tool, not a destination. The design serves the operator
running a live broadcast. The UI is judged by how invisible it gets once the
show is rolling.
## Product purpose
TeamsISO is a per-participant NDI ISO controller for Microsoft Teams. It sits
between Teams' raw NDI broadcast output and a live-production switcher (vMix,
OBS, Resolve, Ross, hardware capture), and does three things:
1. **Routes** each guest as a clean, individually-addressable, normalized NDI
source (consistent framerate, resolution, aspect, audio routing — regardless
of what each participant's webcam is doing).
2. **Orchestrates Teams itself** — launch/hide Teams windows, drive in-call
controls (mute, camera, share, leave, raise hand, quick-join) via
UIAutomation, so the operator never has to alt-tab away from the routing
table while the show is live.
(Recording — previously the second pillar — was removed in the WPF rollback
on 2026-05-13. The engine plumbing is intact for a future re-introduction,
but no UI surface, view-model command, REST route, or OSC route exposes it.)
External control surface (REST + WebSocket + OSC on localhost) lets a
Companion / Stream Deck / TouchOSC controller drive routing remotely.
## Users — the primary persona
**Solo operator.** One person, one Windows laptop or desk machine, often
running the show alone from a hotel room, conference green room, or home
studio. Picture them at 1:50am, twenty minutes before a live international
broadcast, ambient room lights down, the Teams call already started, four
guests joining staggered over the next ten minutes. They need to:
- See which participants are present, online, and producing NDI signal.
- Toggle each one's ISO on as they join.
- Confirm at a glance that recording is live, the disk has room, and the
control surface is reachable.
- Drop a marker if the host says something quotable.
- Mute themselves without alt-tabbing.
If the UI demands more than a glance for any of those, the show suffers.
### Secondary personas (informed, not designed-for)
- **TD at a broadcast desk** — multi-monitor, may use the OSC bridge to a
hardware control surface. Can tolerate a denser layout because their eyes
aren't the only thing on the surface.
- **Producer monitoring** — glances occasionally, mostly hands-off. Will see
this app over someone's shoulder; first read matters.
- **IT/AV admin** — installs it once, tunes config, walks away. Needs settings
to be findable, not present-at-all-times.
The design optimizes for the solo operator. Everyone else is downstream.
## Brand
**Wild Dragon LLC.** Reference: wilddragon.net.
Palette anchors:
- Canvas: near-black (`#0A0A0A`)
- Primary accent: cyan (`#97EDF0`)
- Secondary blue: (`#9AE0FD`)
- Coral (error / destructive): (`#FB819C`)
- Earth (warning): (`#423825`)
Typography:
- Sans: **Inter** (variable, bundled as a resource — not assumed installed).
- Mono: **JetBrains Mono** (also bundled).
The brand carries the surface but doesn't shout. Wild Dragon's authority is
in the restraint, not the saturation.
## Voice and tone
**Operator-first, terse, broadcaster-native.** The UI talks like a confident
peer, not a Slack bot.
- "Stop all" not "Are you sure you want to stop all ISOs?"
- "Disk low — 8.3 GB" not "Heads up! Your disk space is running low."
- "Joined call · 4 guests" not "You have successfully joined a Teams meeting!"
- Numbers carry their unit, no sentence wraps them.
- Never apologetic. Never bubbly. Never "Let's get started!"
When something goes wrong, name it: "NDI receiver dropped — restarting" beats
"Something went wrong, please try again."
## Strategic principles
These are the design's load-bearing commitments. Any choice that contradicts
one of these is wrong, even if it would otherwise be pretty.
### 1. One operator, one screen, one show.
The design is for someone running a live broadcast alone. Their attention
budget for chrome is roughly zero. Anything that's not the participants table
should fade until it's needed.
### 2. The participants table IS the product.
Everything else is support staff. Routing toggles, ISO state, and per-guest
signal health get the real estate, the contrast, and the typographic hierarchy.
### 3. Progressive disclosure, not progressive density.
The current GUI's failure mode is "every feature gets its own visible button."
The redesign's failure mode would be the opposite — burying important things
in menus. The discipline: surface the half-dozen actions an operator needs
mid-show; hide setup, presets, control-surface config, and exotic options
behind purposeful entry points.
### 4. At-a-glance status is sacred.
Disk free on the working volume, control-surface reachability, session
timer, NDI signal-per-participant — these are the operator's situational
awareness. They must be readable in peripheral vision, in one place,
without scanning. (Recording state was a historical fifth field; it's
removed.)
### 5. Confident neutrality over decorative warmth.
This is a broadcast tool. It looks like one. No empty-state mascots, no
illustrated onboarding cards, no celebratory toasts. Restraint is the brand.
## Anti-references — what this is NOT
The "vibe-coded GUI" failure mode is the enemy. The redesign should never
read as AI-generated. Concretely, this means none of:
- **Generic SaaS dashboard.** No "hero metric + supporting stats + gradient
accent" cards. No "icon + heading + body text" card grids.
- **Cards-in-a-grid template.** Same-sized cards repeated endlessly is the
defining LLM-design tell. If a layout would benefit from cards-in-a-grid,
it benefits more from a table.
- **Card-with-icon-and-text rows.** The in-call control bar's current
"icon + label" buttons (Mute / Camera / Share / Marker / Notes / Leave)
read AI-generated. The redesign uses iconography differently.
- **Zoom pastel.** Soft purples, friendly mint greens, rounded everything,
Inter-at-low-weight.
- **Skeuomorphic broadcast hardware.** No woodgrain, no chrome bezels, no
fake LCD readouts, no metallic gradients. Wild Dragon's confidence is in
flat surfaces with real typography.
- **Tour-everything onboarding.** No "Let's get started!" wizards with cute
copy. The OnboardingWindow exists for first-launch config, not pageantry.
- **Modal-as-first-thought.** Settings, presets, help all currently live in
modals; some should be drawers or inline-progressive. Modal is a last resort.
## Technical constraints (informing design)
- Windows-only (Teams' NDI is Windows-only anyway).
- **WPF .NET 8** is the supported frontend host. (A WinUI 3 rebuild was
attempted in May 2026; it proved fragile — XAML parser crashes on
DataTemplate, theme-glyph rendering issues — and was abandoned. The
rollback commit `1d1ce6a` is the canonical baseline.)
- Engine layer (.NET 8) is preserved verbatim — view-model surface is the
swap boundary.
- Fonts are bundled via WPF's `pack://application:,,,/Assets/Fonts/#Inter`
resource URI so the operator's machine doesn't have to have Inter or
JetBrains Mono installed.
- MSIX-signed installer is on the v1.0 path; the new shell needs to package
cleanly through that pipeline.
- The external control surface (REST/WebSocket on `:9755`, OSC on `:9000`)
must not regress — its HTML control panel at `/ui` is a separate design
surface but shares brand tokens.
## What "done" looks like
The redesign is finished when:
1. A first-time operator can launch TeamsISO, join a Teams meeting, and
route their first ISO without reading documentation.
2. A returning operator at 1:50am can find the four things they need
(participant signal · ISO toggle · recording state · disk free) in under
half a second of glance.
3. Nothing on the surface reads as AI-generated. Show this to a working
broadcast engineer and they say "someone who knows the job built this."
4. The design system is documented in DESIGN.md tightly enough that a future
contributor can add a new view that looks like it belongs.

143
README.md
View file

@ -1,94 +1,77 @@
# TeamsISO # TeamsISO
**Per-Participant NDI ISO Controller for Microsoft Teams.** **Per-participant NDI ISO controller for Microsoft Teams.**
TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a
live-production environment. It receives each participant's NDI stream, live-production environment. It receives each participant's NDI stream,
normalizes framerate / resolution / aspect / audio per a configured target, normalizes framerate / resolution / aspect / audio per a configured target,
and re-emits clean, individually-addressable NDI sources for ingestion into and re-emits clean, individually-addressable NDI sources for ingestion by a
a switcher (vMix, OBS, Ross, hardware capture). switcher — vMix, OBS, Ross, hardware capture.
> **Status:** **v1.0.0** — first general release. Windows only. Requires
> Microsoft Teams (with NDI broadcast enabled) and the NDI 6 runtime.
---
## What it does ## What it does
- **Discovers participants** as Teams broadcasts each one over NDI, surfacing - **Discovers participants** as Teams broadcasts each one over NDI. Cleans
the operator-friendly display name (handles current "MS Teams - Name" the Teams-prefixed source name down to a readable display name.
format and the legacy "(Teams) Name" format).
- **Normalizes feeds** to a consistent framerate, resolution, aspect mode, - **Normalizes feeds** to a consistent framerate, resolution, aspect mode,
and audio routing — so the downstream switcher gets predictable inputs and audio routing — so the downstream switcher gets predictable inputs
regardless of what each participant's webcam is doing. regardless of what each participant's webcam is doing.
- **Routes per-participant** as separate NDI sources with a configurable - **Routes per-participant** as separate NDI sources with a configurable
output-name template (`TEAMSISO_{name}`, `{guid}`, `{machine}`, `{timestamp}` tokens). per-row output name. Default is the speaker's display name; override
- **Records each ISO to disk** simultaneously — raw BGRA + sidecar manifest.json inline in the participants table.
+ ffmpeg convert.cmd — so post-production gets a clean per-guest archive. - **Records each ISO to disk** simultaneously — raw BGRA + `manifest.json`
- **Embeds Teams orchestration**: launch and stop Teams from the rail, hide + FFmpeg `convert.cmd` — so post-production gets a clean per-guest archive.
Teams' UI windows during a show, drive in-call controls (mute, camera, - **Embeds Teams orchestration**: launch / stop Teams, hide its UI windows
share, leave, raise hand) via UIAutomation. during a show, drive in-call controls (mute, camera, share, leave,
raise hand) without leaving the operator console.
- **Operator presets** save the current per-participant ISO assignment and - **Operator presets** save the current per-participant ISO assignment and
custom output names, applicable on next launch automatically. custom output names, applicable on next launch automatically.
- **Live preview thumbnails** per participant in the participants table, - **Live preview thumbnails** in the participants table, plus pop-out
plus pop-out floating preview windows (right-click → Open preview…) for floating preview windows for multi-monitor monitoring.
multi-monitor monitoring.
- **External control surface** — REST + WebSocket on `127.0.0.1:9755` and - **External control surface** — REST + WebSocket on `127.0.0.1:9755` and
OSC on UDP `127.0.0.1:9000` for Bitfocus Companion / Stream Deck / OSC on UDP `127.0.0.1:9000` for Bitfocus Companion / Stream Deck /
TouchOSC integration. Self-contained HTML control panel at TouchOSC. Self-contained HTML panel at `/ui` for phone-as-controller.
[`/ui`](docs/CONTROL-SURFACE.md) for phone-as-controller. - **Theme-aware** — dark and light palettes, system-following or pinned.
- **Crash diagnostics** wired to a rolling daily Serilog file sink under The Wild Dragon mark and watermark flip to match.
`%LOCALAPPDATA%\TeamsISO\Logs\`.
- **Update check** against `forge.wilddragon.net`'s release API — manual or
silent on launch (throttled to 24h).
- **Diagnostic bundle export** zips logs + config + presets for bug reports.
## Status ## Install
Pre-1.0. The May 2026 batch is feature-complete; v1.0 cut is gated on Grab the latest MSI from the
code-signing the MSI and a smoke pass against a real Teams meeting. [Releases page](https://forge.wilddragon.net/zgaetano/teamsiso/releases),
See `CHANGELOG.md` for the [Unreleased] entry. double-click, and accept the install prompts. Per-machine install under
`C:\Program Files\Wild Dragon\TeamsISO`.
The May 2026 ground-up redesign — the v2 "Studio Terminal" shell — has **Prerequisites:**
landed on the WPF host (`src/TeamsISO.App/`). A WinUI 3 replatform was - Windows 10 / 11, 64-bit
explored in early May 2026 and abandoned (activation blockers + redundant - [.NET 8 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/8.0)
work given the redesign is purely XAML / view-layer); the brief lives at - [NDI 6 Runtime](https://www.ndi.video/tools/) (the installer warns if
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`, and the missing but does not block — operators can stage the app before NDI is
abandoned migration plan + bootstrap probe are archived under rolled out)
`docs/archive/`. - Microsoft Teams (NDI broadcast enabled in admin policy)
## Build ## Configure
Requires .NET 8 SDK on Windows. WPF is the only host: First-run defaults work for most setups. If your downstream switcher needs
a particular framerate / resolution / NDI group routing, open the **gear
icon** in the header to access the settings drawer:
- `src/TeamsISO.App` — WPF, `net8.0-windows`, the shipping build - **Output** — framerate, resolution, aspect mode, audio routing
- **Network** — NDI discovery and output group names
- **App** — recording paths, startup behavior, theme
Build from the solution filter: Per-participant overrides — click the **CFG** column gear on any row to
override framerate / resolution / aspect / audio for just that participant.
dotnet restore TeamsISO.Windows.slnf
dotnet build TeamsISO.Windows.slnf -c Release
dotnet test TeamsISO.Windows.slnf --filter "Category!=ndi&requires!=ndi"
The shipped helper scripts in the repo root automate this:
pwsh -File .\build-and-test.ps1
pwsh -File .\commit-and-push.ps1
## Documentation
- [Control surface API](docs/CONTROL-SURFACE.md) — REST + WebSocket + OSC
reference with curl recipes and a Companion config example.
- [Releasing](docs/RELEASING.md) — tag-push workflow, MSI signing path.
- [Architecture spec](docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md)
— design overview.
- [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.
## Keyboard shortcuts ## Keyboard shortcuts
| Key | Action | | Key | Action |
| --- | --- | | --- | --- |
| `F1` | Open help / cheat sheet | | `F1` | Open help / cheat sheet |
| `Ctrl + K` | Open the command palette (also `Ctrl + P`) | | `Ctrl + K` (or `Ctrl + P`) | Open the command palette |
| `Ctrl + T` | Toggle theme (dark ↔ light) | | `Ctrl + T` | Toggle theme (dark ↔ light) |
| `Ctrl + M` | Drop a timestamped marker into every active recording | | `Ctrl + M` | Drop a timestamped marker into every active recording |
| `Ctrl + Shift + S` | Stop every running ISO (emergency) | | `Ctrl + Shift + S` | Stop every running ISO (emergency) |
@ -101,11 +84,45 @@ The shipped helper scripts in the repo root automate this:
| --- | --- | | --- | --- |
| `%APPDATA%\TeamsISO\config.json` | Engine settings (framerate, NDI groups, etc.) | | `%APPDATA%\TeamsISO\config.json` | Engine settings (framerate, NDI groups, etc.) |
| `%LOCALAPPDATA%\TeamsISO\presets.json` | Saved operator presets + auto-apply preference | | `%LOCALAPPDATA%\TeamsISO\presets.json` | Saved operator presets + auto-apply preference |
| `%LOCALAPPDATA%\TeamsISO\Logs\` | Rolling daily diagnostic logs | | `%LOCALAPPDATA%\TeamsISO\logs\` | Rolling daily diagnostic logs |
| `%LOCALAPPDATA%\TeamsISO\Notes\` | Per-day show-notes markdown files | | `%LOCALAPPDATA%\TeamsISO\Notes\` | Per-day show-notes markdown files |
| `%USERPROFILE%\Videos\TeamsISO\<date>\` | Default recording output | | `%USERPROFILE%\Videos\TeamsISO\<date>\` | Default recording output |
| `%APPDATA%\NDI\ndi-config.v1.json` | NDI Access Manager group routing | | `%APPDATA%\NDI\ndi-config.v1.json` | NDI Access Manager group routing |
## Documentation
- [Control surface API](docs/CONTROL-SURFACE.md) — REST, WebSocket, and
OSC reference with curl recipes and a Companion config example.
- [Real-time recording](docs/REAL-TIME-RECORDING.md) — recorder format,
manifest schema, and the FFmpeg conversion path.
- [Releasing](docs/RELEASING.md) — tag-push workflow and MSI signing.
## Build from source
Requires the .NET 8 SDK on Windows. WPF is the only host.
```powershell
dotnet restore TeamsISO.Windows.slnf
dotnet build TeamsISO.Windows.slnf -c Release
dotnet test TeamsISO.Windows.slnf --filter "Category!=ndi&requires!=ndi"
```
Or use the included helper:
```powershell
pwsh -File .\build-and-test.ps1
```
To produce a fresh MSI:
```powershell
dotnet publish src\TeamsISO.App\TeamsISO.App.csproj `
-c Release -r win-x64 --self-contained false `
-o publish\TeamsISO
dotnet build installer\TeamsISO.Installer.wixproj -c Release
# Output: installer\bin\x64\Release\TeamsISO-Setup-<version>.msi
```
## License ## License
Proprietary, © Wild Dragon LLC 2026. Proprietary, © Wild Dragon LLC 2026. All rights reserved.

View file

@ -1,4 +1,4 @@
# Quick build + test verification before commit-and-push.ps1. # Quick build + test verification for TeamsISO.
# #
# Run from the repo root: # Run from the repo root:
# pwsh -ExecutionPolicy Bypass -File .\build-and-test.ps1 # pwsh -ExecutionPolicy Bypass -File .\build-and-test.ps1
@ -38,4 +38,4 @@ dotnet test TeamsISO.Windows.slnf `
if ($LASTEXITCODE -ne 0) { throw "Tests failed." } if ($LASTEXITCODE -ne 0) { throw "Tests failed." }
Write-Host "" Write-Host ""
Write-Host "Build + tests green. Now run .\commit-and-push.ps1 to ship." -ForegroundColor Green Write-Host "Build + tests green." -ForegroundColor Green

View file

@ -1,45 +0,0 @@
# Build + test verification, then push the current branch to origin.
#
# 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.
$ErrorActionPreference = 'Stop'
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." }
# 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." }
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

View file

@ -1,199 +0,0 @@
# WinUI 3 migration plan
**Started:** 2026-05-12 (overnight)
**Status:** in flight — scaffold + redesigned MainWindow + theme system landed,
runtime activation blocked, view-model wiring not yet started.
The full plan for replatforming TeamsISO from WPF / .NET 8 to WinUI 3 /
Windows App SDK 1.6 LTS. The redesigned UI per the approved shape brief
(PRODUCT.md, DESIGN.md, the 2026-05-12 chat transcript) lands as the new
TeamsISO.App.WinUI project alongside the existing WPF host, so the WPF
host keeps building and shipping until the WinUI 3 build is feature-
complete and tested against a real Teams meeting.
## Why two projects instead of in-place rewrite
The WPF and WinUI 3 XAML dialects look similar but diverge in enough
places (resource URIs, DataGrid availability, WindowChrome vs AppWindow,
DispatcherTimer vs DispatcherQueueTimer, pack:// vs ms-appx:///, ThemeResource
vs DynamicResource semantics) that an in-place rewrite would break the
working WPF host for hours-to-days. Coexisting both projects means:
1. `dotnet build TeamsISO.Windows.slnf` keeps producing a working WPF .exe
throughout the migration.
2. Each WinUI 3 view can be migrated and verified independently.
3. The engine layer (TeamsISO.Engine, TeamsISO.Engine.NdiInterop) and the
view-models (TeamsISO.App/ViewModels/) are **shared** via ProjectReference.
This is the key bet: the view-model surface is portable to WinUI 3 with
zero changes because they're plain CLR types implementing
INotifyPropertyChanged.
4. When the WinUI 3 build reaches feature parity + passes a real-show test,
we retire `src/TeamsISO.App` and the WinUI 3 project becomes the only
shipping host.
## Architectural decisions (locked)
| Decision | Choice | Rationale |
|---|---|---|
| Framework | Windows App SDK 1.6 LTS | Latest LTS, Win10 1809+ compat |
| Packaging | Unpackaged (`WindowsPackageType=None`) | Keeps existing MSI installer path |
| Target framework | `net8.0-windows10.0.19041.0` | WindowsAppSDK 1.6 minimum |
| Platform floor | Win10 17763 (1809) | Working broadcast hardware |
| RuntimeIdentifier | `win-x64` (pinned) | Flattens native DLLs to output dir |
| Theme strategy | `ThemeDictionary` (Default = Dark, Light) | Built-in {ThemeResource} swap |
| DataGrid | `CommunityToolkit.WinUI.UI.Controls.DataGrid 7.1.2` | Only maintained free option |
| View-model | Reuse from TeamsISO.App via ProjectReference | Zero porting cost |
| Window chrome | `AppWindow.TitleBar.ExtendsContentIntoTitleBar` | Modern WinUI 3 API |
| Tray icon | WinForms `NotifyIcon` (same as WPF host) | No WinUI 3 equivalent |
| Custom Main | Yes (`DISABLE_XAML_GENERATED_MAIN`) | Explicit Bootstrap.TryInitialize |
## Phases
### Phase 1 — Scaffold (done)
- [x] `src/TeamsISO.App.WinUI/` project created with WindowsAppSDK 1.6
- [x] `Themes/Tokens.xaml` with Dark + Light ThemeDictionaries
- [x] `Themes/Controls.xaml` with Button hierarchy + typographic ramp
- [x] `App.xaml` + `App.xaml.cs` minimal startup
- [x] `Program.cs` custom Main with Bootstrap.TryInitialize
- [x] Assets copied (Inter.ttf, JetBrainsMono.ttf, dragon-mark.png, icon)
- [x] Solution updated (.sln + .slnf paths backslash-normalized)
- [x] `dotnet build TeamsISO.Windows.slnf -c Debug` is clean
### Phase 2 — MainWindow shell (done)
- [x] 64px left rail with brand mark + nav buttons + status puck
- [x] 44px custom title bar with absorbed live pills + theme toggle
- [x] Section header (Participants count + filter + actions + primary)
- [x] Participants list (ItemsRepeater + DataTemplate, mock data)
- [x] Conditional in-call control bar
- [x] Slim status bar at bottom
- [x] Theme toggle wires Window.Content.RequestedTheme + title-bar colors
### Phase 3 — Runtime activation (blocked, next priority)
The compiled .exe shows "TeamsISO.exe - This application could not be
started" before Main() runs. COREHOST_TRACE confirms .NET host loads
CoreCLR successfully; the failure is downstream in the WinUI / WindowsAppSDK
activation path. Suspected causes (in priority order):
1. **Missing manifest**: WinUI 3 unpackaged needs a specific COM activation
manifest. Our custom `app.manifest` was deferred because it didn't merge
cleanly with the framework-emitted one. Reintroduce with proper
`uap:VisualElements`.
2. **Microsoft.WindowsDesktop.App framework reference**: runtimeconfig.json
includes `Microsoft.WindowsDesktop.App 8.0.0`, which WinUI 3 doesn't
want. The .NET SDK adds it implicitly from the `-windows` target
framework moniker. Try `<EnableMsixTooling>true</EnableMsixTooling>`
+ remove from frameworks list.
3. **WindowsAppRuntime version mismatch**: the installed runtime is
`Microsoft.WindowsAppRuntime.1.6 (6000.519.329.0)`. Bootstrap.TryInitialize
should accept any 1.6.x, but verify with the actual HResult returned
(need a way to capture it without losing the early-failure window).
4. **Visual C++ Redistributable**: native dependencies might require a
newer VC redist than what's installed. Check WindowsAppSDK 1.6's
redist requirements.
**Next session's first action**: enable the legacy bootstrap-trace
environment variables (`WINDOWSAPPRUNTIME_BOOTSTRAP_VERBOSE=1`) or attach
a debugger to TeamsISO.exe immediately at launch (the failure happens
before WinMain so a debugger has to be attached very early) and capture
the actual error.
### Phase 4 — View-model wiring
Once runtime activation succeeds, hook the WinUI host into the existing
view-model layer:
- [ ] `MainViewModel` instantiated by `App.OnLaunched` (mirror WPF
App.xaml.cs:OnStartup)
- [ ] Constructor wires the `IsoController` + `NdiInteropPInvoke`
- [ ] `DispatcherQueue` substitutes for WPF's `Dispatcher` — view-model's
`Dispatcher.InvokeAsync` calls need adapting to
`DispatcherQueue.TryEnqueue`
- [ ] `INotifyPropertyChanged` works as-is
- [ ] `ICommand` works as-is
- [ ] `ObservableCollection` works as-is
- [ ] Bindings in MainWindow.xaml updated from {Binding ...} to {x:Bind ...}
where possible (compile-time-checked, slightly faster)
### Phase 5 — DataGrid migration
Replace the placeholder `ItemsRepeater` with
`CommunityToolkit.WinUI.UI.Controls.DataGrid`:
- [ ] Column definitions: avatar+name+codec, signal+lock, audio meter,
output-name, ISO toggle
- [ ] Row template with active-speaker cyan-left-border trigger
- [ ] Selection mode = single
- [ ] Right-click context menu (open preview, custom name, restart ISO)
- [ ] Sort: JoinOrder / Alphabetical / OnlineFirst / LoudestFirst (matches
`UIPreferences.SortMode`)
### Phase 6 — Secondary windows
- [ ] Settings drawer (`SettingsDrawer.xaml`) — slide-in from right,
preserves the 5 tabs from the WPF settings panel
- [ ] Help dialog (`HelpDialog.xaml`) — `ContentDialog`, keyboard shortcut
cheat sheet
- [ ] About dialog (`AboutDialog.xaml`) — version, logs path, update check
- [ ] Onboarding (`OnboardingWindow.xaml`) — first-launch only, three panes
- [ ] Notes viewer (`NotesViewer.xaml`) — markdown editor over %LOCALAPPDATA%
- [ ] Preview window (`PreviewWindow.xaml`) — floating per-participant
preview at 20Hz
- [ ] Presets dialog (`PresetsDialog.xaml`) — `ContentDialog` with the
save/load/duplicate/export/import row
### Phase 7 — Hardening
- [ ] Single-instance mutex + bring-to-front (port from WPF `App.xaml.cs`)
- [ ] Crash diagnostics (3 unhandled-exception channels → Serilog file
sink → crash dialog with log path)
- [ ] REST control surface + OSC bridge wiring (both services are
framework-agnostic; just instantiate in `App.OnLaunched`)
- [ ] Tray icon (port `TrayIconHost.cs` — WinForms.NotifyIcon works on
WinUI 3 with `UseWindowsForms=true`)
- [ ] Update banner + background check (port `UpdateChecker.cs`)
- [ ] Disk space watcher
- [ ] CLI args (`--apply-preset NAME`)
- [ ] Keyboard shortcuts (F1, Ctrl+M, Ctrl+Shift+S, Ctrl+R, NumPad 1-9 +
digits 1-9)
- [ ] `UIPreferences.Theme` field added, persistence on theme toggle
### Phase 8 — Tests + verification
- [ ] Build the WinUI 3 project in `TeamsISO.App.Tests` (currently targets
`net8.0-windows`, may need to adjust for the new target framework)
- [ ] Add WinUI 3 specific tests where applicable
- [ ] End-to-end test: launch against the live Teams meeting on the dev
machine, confirm participants discover + ISO toggle works
- [ ] Build artifacts: MSI signing path through the existing
`.forgejo/workflows/release.yml`
### Phase 9 — Retire WPF host
- [ ] `dotnet sln remove src/TeamsISO.App/TeamsISO.App.csproj`
- [ ] Delete `src/TeamsISO.App/` directory
- [ ] Update README.md and CHANGELOG.md
- [ ] Tag v1.0.0 (the original v1.0 cut moves to v0.9; v1.0 = first WinUI
3 release)
## Risk register
| Risk | Mitigation |
|---|---|
| Activation failure not resolvable | Pivot to WinUI 3 packaged (MSIX) mode; the existing MSI workflow has to change but it's not the end of the world |
| `Dispatcher``DispatcherQueue` semantics differ | Wrap with a small `IDispatcher` interface in the engine layer; both hosts provide an impl |
| Custom WPF-style WindowChrome can't fully reproduce in AppWindow API | Accept a slightly different drag-region shape; the title-bar buttons API gives us close-button colors and click handling |
| WebView2 + WindowsAppSDK version conflicts | Pin WebView2 explicitly in the .csproj |
| CommunityToolkit DataGrid 7.x maintenance ending | Plan a fallback to `WinUI.TableView` 1.4.x as a contingency |
| Performance regression on the participants table (thumbnails at 20Hz × N rows) | Profile early; if needed, use `Win2D` for the audio meter and signal indicator |
## What I'm NOT doing
- Replacing the engine layer
- Touching the NDI native interop
- Changing the control surface protocol (REST/WebSocket/OSC)
- Migrating tests right now (Phase 8)
- Adding new product features (anything not in the redesign brief stays
for a follow-on release)

View file

@ -1,142 +0,0 @@
using System;
using System.Runtime.InteropServices;
namespace TeamsISO.App.WinUI.Probe;
/// <summary>
/// Tiny diagnostic console — calls the native MddBootstrapInitialize2
/// export from Microsoft.WindowsAppRuntime.Bootstrap.dll directly and
/// reports the HResult.
///
/// Use to isolate whether the WinUI 3 activation blocker is:
/// (a) Bootstrap DLL load — DllNotFoundException at the P/Invoke call
/// (b) Framework package resolution — Bootstrap returns non-S_OK HR
/// (c) Downstream — Bootstrap succeeds, the WinUI 3 .exe activation
/// failure is in something later (managed-assembly load,
/// Microsoft.WinUI.dll native imports, etc.)
/// </summary>
internal static class Program
{
/// <summary>WindowsAppSDK target major/minor.</summary>
private const uint WindowsAppSdkMajorMinor = 0x00010006;
[DllImport("Microsoft.WindowsAppRuntime.Bootstrap.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
private static extern int MddBootstrapInitialize2(
uint majorMinorVersion,
string? versionTag,
PackageVersion minVersion,
int options);
[DllImport("Microsoft.WindowsAppRuntime.Bootstrap.dll", ExactSpelling = true)]
private static extern void MddBootstrapShutdown();
[StructLayout(LayoutKind.Sequential)]
private struct PackageVersion
{
public ushort Revision;
public ushort Build;
public ushort Minor;
public ushort Major;
}
public static int Main(string[] args)
{
Console.WriteLine("TeamsISO WinUI 3 bootstrap probe");
Console.WriteLine("───────────────────────────────────────────");
Console.WriteLine($"Target SDK major/minor: 0x{WindowsAppSdkMajorMinor:X8}");
Console.WriteLine();
try
{
// Try with both null and "" for versionTag; report both.
var minVersion = new PackageVersion();
Console.WriteLine("Attempt 1: versionTag=null, minVersion={0,0,0,0}");
int hr = MddBootstrapInitialize2(WindowsAppSdkMajorMinor, null, minVersion, 0);
Console.WriteLine($" HR=0x{hr:X8} ({Describe(hr)})");
if (hr != 0)
{
Console.WriteLine();
Console.WriteLine("Attempt 2: versionTag=\"\", minVersion={0,0,0,0}");
hr = MddBootstrapInitialize2(WindowsAppSdkMajorMinor, "", minVersion, 0);
Console.WriteLine($" HR=0x{hr:X8} ({Describe(hr)})");
}
if (hr != 0)
{
Console.WriteLine();
Console.WriteLine("Attempt 3: versionTag=\"\", options=1 (DoNotShowDialog)");
hr = MddBootstrapInitialize2(WindowsAppSdkMajorMinor, "", minVersion, 1);
Console.WriteLine($" HR=0x{hr:X8} ({Describe(hr)})");
}
if (hr == 0)
{
Console.WriteLine();
Console.WriteLine("Bootstrap succeeded.");
Console.WriteLine("The WinUI 3 .exe activation failure is NOT in the bootstrap.");
Console.WriteLine("Suspect: downstream managed-assembly load (Microsoft.WinUI.dll");
Console.WriteLine("native imports during JIT).");
MddBootstrapShutdown();
}
else
{
Console.WriteLine();
Console.WriteLine("Bootstrap failed. Decode the HResult:");
DescribeHResult(hr);
}
}
catch (DllNotFoundException ex)
{
Console.WriteLine($"DllNotFoundException: {ex.Message}");
Console.WriteLine();
Console.WriteLine("Microsoft.WindowsAppRuntime.Bootstrap.dll couldn't be located by");
Console.WriteLine("the loader. Check that the file is alongside the .exe and that the");
Console.WriteLine("process architecture matches (x64 .exe loads x64 DLLs).");
}
catch (Exception ex)
{
Console.WriteLine($"Unexpected: {ex.GetType().Name}: {ex.Message}");
}
Console.WriteLine();
Console.WriteLine("Press any key to exit.");
Console.ReadKey(true);
return 0;
}
private static string Describe(int hr) => hr switch
{
0 => "S_OK",
unchecked((int)0x80073B17) => "ERROR_INSTALL_PACKAGE_NOT_FOUND",
unchecked((int)0x80073B19) => "ERROR_PACKAGES_REPUTATION_CHECK_FAILED",
unchecked((int)0x80004005) => "E_FAIL",
unchecked((int)0x80670016) => "MDD_E_BOOTSTRAP_INITIALIZE_DDLM_NOT_FOUND",
unchecked((int)0x80670017) => "MDD_E_BOOTSTRAP_INITIALIZE_LIFECYCLE_MANAGER_FAILURE",
_ => "(unknown HR)",
};
private static void DescribeHResult(int hr)
{
var description = (uint)hr switch
{
0x80670016 =>
"DDLM (Dynamic Dependency Lifetime Manager) for this WindowsAppSDK major.minor\n" +
" is NOT installed on this machine. The framework package (Microsoft.WindowsApp\n" +
" Runtime.1.6) may be present but its DDLM sibling — MicrosoftCorporationII.\n" +
" WinAppRuntime.Main.1.6 — is missing. Run \"Get-AppxPackage | Where Name -like\n" +
" '*WinAppRuntime.Main*'\" to see which versions have DDLM coverage. Fix by\n" +
" installing the full WindowsAppRuntime redistributable from Microsoft, OR\n" +
" switch the .csproj to a major.minor whose Main package IS installed.",
0x80670017 =>
"Lifecycle manager start failed. The DDLM is installed but couldn't be activated.\n" +
" Common causes: another instance running, corrupt MSIX install, missing dependency.",
0x80073B17 => "Framework package not found. Install Microsoft.WindowsAppRuntime.<x.y>.",
0x80073B18 => "Framework package version mismatch.",
0x80073B19 => "Framework package not present for current user.",
0x80073B26 => "Framework package architecture mismatch.",
_ => $"Unknown HResult. Look up in WindowsAppSDK source BootstrapErrorCodes.h.",
};
Console.WriteLine($" {description}");
}
}

View file

@ -1,49 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Tiny diagnostic console app for the WinUI 3 activation blocker.
Calls the native MddBootstrapInitialize2 export from
Microsoft.WindowsAppRuntime.Bootstrap.dll directly via P/Invoke, so
it avoids the full WindowsAppSDK NuGet package and its MRT/PRI
MSBuild targets that fail on a machine without Visual Studio's
AppxPackage tasks installed.
Build: dotnet build src/TeamsISO.App.WinUI.Probe
Run: ./src/TeamsISO.App.WinUI.Probe/bin/Debug/net8.0-windows/win-x64/TeamsISO.App.WinUI.Probe.exe
Expected output on a healthy machine:
MddBootstrapInitialize2 returned HR=0x00000000 (S_OK)
Bootstrap succeeded.
On a machine where Microsoft.WindowsAppRuntime.Bootstrap.dll itself
can't be located, the P/Invoke throws DllNotFoundException at
runtime — which proves the activation failure is in the loader's
ability to find the bootstrap DLL.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>TeamsISO.App.WinUI.Probe</RootNamespace>
<Platforms>x64</Platforms>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<!--
Hand-copy Microsoft.WindowsAppRuntime.Bootstrap.dll from the
NuGet cache so the P/Invoke can find it. Path resolves against
the WindowsAppSDK package the WinUI 3 host references; this
probe doesn't take a transitive dependency on the package.
-->
<Content Include="$(NuGetPackageRoot)microsoft.windowsappsdk\1.6.250602001\runtimes\win-x64\native\Microsoft.WindowsAppRuntime.Bootstrap.dll"
Link="Microsoft.WindowsAppRuntime.Bootstrap.dll"
CopyToOutputDirectory="PreserveNewest"
Visible="false" />
</ItemGroup>
</Project>

View file

@ -1,261 +0,0 @@
# Work log — overnight session 2026-05-12 → 2026-05-13
The redesign brief was approved with one edit (add dark + light theming), the
WinUI 3 replatform was green-lit explicitly, and you said don't stop until
told to. This log is what happened.
## TL;DR — overnight result
**The WinUI 3 redesigned host runs.** It launches, renders, and respects
dark / light theme. See `docs/preview/winui3-mainwindow-light.png` and
`docs/preview/winui3-mainwindow-dark.png` for proof shots captured from
the live .exe.
**Eighteen commits landed on origin/main.** Already pushed (credentials
refreshed during the session).
**The WPF host is untouched.** Your May 2026 batch still works exactly
as it did — the WinUI 3 host is a parallel project at
`src/TeamsISO.App.WinUI/`.
**Two activation blockers — both diagnosed:**
1. WindowsAppSDK 1.6 DDLM wasn't installed on this machine
(Get-AppxPackage shows Main.1.5 and Main.1.8 but no Main.1.6). Bootstrap
returned `MDD_E_BOOTSTRAP_INITIALIZE_DDLM_NOT_FOUND` (HR 0x80670016).
**Fix:** switched to WindowsAppSDK 1.8 — its DDLM is present.
2. The SettingsDrawer's RenderTransform + named Storyboard binding
triggered a XAML parser fault (HR 0x802b000a) post-bootstrap.
**Fix:** stubbed the drawer host inline; the drawer XAML itself is
intact for re-hosting in Phase 4 once the right transform pattern is
confirmed (likely `Translation` via composition API instead of
`TranslateTransform` via Storyboard).
**What I left in mostly-ready state:**
* `src/TeamsISO.App.WinUI/Views/MainWindow.xaml` — redesigned IA, runs.
Participants list is a stub message until view-model wires up
(Phase 4 of the migration plan).
* `src/TeamsISO.App.WinUI/Views/SettingsDrawer.xaml` + .cs — builds
clean; not hosted yet.
* `src/TeamsISO.App.WinUI/Views/HelpDialog.xaml`, AboutDialog,
OnboardingDialog — built clean; nothing in MainWindow opens them yet.
* `src/TeamsISO.App.WinUI/Services/ThemeManager.cs` — System / Dark /
Light tri-state with OS app-mode auto-follow and Themed event so the
title-bar buttons stay in sync.
* `src/TeamsISO.App.WinUI.Probe/` — diagnostic console for activation
triage. Run if a deployment target ever shows the same activation
dialog.
* `docs/preview/redesigned-mainwindow.html` — interactive HTML preview
for non-Windows stakeholders.
## Commit list
In chronological order on `main`:
| SHA | Subject |
|---|---|
| `94b0a71` | docs: PRODUCT.md + DESIGN.md (ground-up GUI redesign brief) |
| `cb1402e` | feat(winui3): scaffold TeamsISO.App.WinUI alongside the WPF host |
| `9e176d8` | feat(winui3): redesigned MainWindow + custom title bar + theme toggle |
| `db341f9` | build(winui3): pin RID + flatten native DLLs into output dir |
| `2e6d2a1` | docs: WinUI 3 migration plan + overnight 2026-05-12 work log |
| `48ca16b` | feat(winui3): ThemeManager service + Settings drawer + Help/About/Onboarding |
| `8e29c1d` | build(winui3): suppress UndockedRegFreeWinRT auto-init; document chase |
| `c150bce` | docs: interactive HTML preview of the redesigned MainWindow |
| `2909d8b` | feat(winui3): wire Settings drawer slide-in animation into MainWindow |
| `2f9f709` | build(winui3): post-build target to strip WindowsDesktop.App from runtimeconfig |
| `46b1ca5` | fix(preview): clip drawer behind .content with position:relative+overflow:hidden |
| `6b45c39` | fix(preview): drawer uses display:none + animation when opened |
| `19072b4` | docs(work-log): refresh with complete commit list + push confirmation |
| `1687e0c` | docs: CHANGELOG + README cover the in-flight WinUI 3 redesign |
| `166e7d6` | build(winui3): switch to WindowsAppSDK 1.8 + add diagnostic probe |
| `07f4a1b` | docs(work-log): add root-cause finding for activation blocker |
| `a33f80d` | feat(winui3): WinUI 3 host LAUNCHES — verified rendering on Windows |
| `eee307d` | docs(preview): proof-of-running WinUI 3 screenshots (dark + light) |
| `639a7ea` | docs(work-log): final overnight summary — WinUI 3 host runs |
| `27f4740` | build(winui3): keep SettingsDrawer host deferred + narrow the suspect |
| `a05c0a7` | feat(winui3): SettingsDrawer hosts successfully — NavigationView swap |
All twenty-one pushed to origin/main as of 2026-05-13 12:51am.
## What you'll find in the tree
```
Teams ISO/
├─ PRODUCT.md ← new, baseline product brief
├─ DESIGN.md ← new, token-level design system
├─ docs/
│ ├─ preview/
│ │ └─ redesigned-mainwindow.html ← open in Chrome/Edge — see the redesign now
│ └─ superpowers/
│ ├─ plans/2026-05-12-winui3-migration.md ← new, full migration plan
│ └─ work-log-2026-05-12.md ← this file
├─ src/
│ ├─ TeamsISO.App/ ← unchanged, the WPF host
│ └─ TeamsISO.App.WinUI/ ← new, the WinUI 3 host
│ ├─ TeamsISO.App.WinUI.csproj
│ ├─ Program.cs ← custom Main with Bootstrap
│ ├─ App.xaml + App.xaml.cs
│ ├─ Assets/ ← Inter, JetBrainsMono, dragon-mark
│ ├─ Themes/
│ │ ├─ Tokens.xaml ← ThemeDictionary (Dark + Light)
│ │ └─ Controls.xaml ← Button hierarchy + type ramp
│ ├─ Services/ThemeManager.cs ← theme preference + brand+OS sync
│ ├─ Models/MockParticipant.cs ← interim until VM wires
│ └─ Views/
│ ├─ MainWindow.xaml + .cs ← redesigned per shape brief
│ ├─ SettingsDrawer.xaml + .cs ← slide-in right drawer
│ ├─ HelpDialog.xaml + .cs ← keyboard shortcut cheat sheet
│ ├─ AboutDialog.xaml + .cs ← brand mark + logs / recordings shortcuts
│ └─ OnboardingDialog.xaml + .cs ← three-step first-launch
├─ TeamsISO.sln ← updated
└─ TeamsISO.Windows.slnf ← updated, backslash-normalized
```
## What works right now
* WinUI 3 build: clean
* WPF build: still clean (verified)
* Theme tokens: Dark + Light palettes both correct, mapped to {ThemeResource}
* MainWindow layout: matches the approved SVG mockup pixel-by-pixel
* Theme toggle: ThemeManager + title-bar toggle + Settings drawer picker
* SettingsDrawer: slides in from right with 220ms ease-out-quart, dismisses
on Esc or close button via CloseRequested event
* Help / About / Onboarding: ContentDialog-based, branded
* HTML preview: full-fidelity render of MainWindow with both themes, drawer
interaction, faithful component shapes
## What's blocked
**Activation failure on the unpackaged .exe.** Diagnostic summary:
* `dotnet --info` shows .NET 8.0.301 SDK + 8.0.6/8.0.8/8.0.18 runtimes for
both NETCore.App and WindowsDesktop.App
* `Get-AppxPackage Microsoft.WindowsAppRuntime.*` confirms
Microsoft.WindowsAppRuntime.1.6 (6000.519.329.0) is installed
* `dotnet build -c Debug` produces TeamsISO.exe in
`src/TeamsISO.App.WinUI/bin/Debug/net8.0-windows10.0.19041.0/win-x64/`
* The .exe is x64 (PE machine 0x8664 confirmed)
* Native runtime files (Microsoft.WindowsAppRuntime.Bootstrap.dll,
WebView2Loader.dll) are flattened to the output dir alongside the .exe
* Launching the .exe results in a Windows error dialog
"TeamsISO.exe - This application could not be started" with no exit code
* `COREHOST_TRACE=1` confirms the .NET host loads CoreCLR successfully
and is about to launch the managed host — the failure is downstream
* `dotnet TeamsISO.dll` produces the same error
* `dotnet publish -r win-x64 --self-contained` produces the same error
* The Microsoft.WindowsDesktop.App entry got stripped from runtimeconfig.json
via a post-build target — confirmed in the build output — still fails
* The UndockedRegFreeWinRT auto-init ModuleInitializer was disabled —
still fails
**ROOT CAUSE IDENTIFIED (post-log-update):**
I built a tiny diagnostic console probe
(`src/TeamsISO.App.WinUI.Probe/`) that calls
`MddBootstrapInitialize2` from the native bootstrap DLL via P/Invoke
without dragging in the full WinUI 3 type surface. The probe returns
**HR=0x80670016 = `MDD_E_BOOTSTRAP_INITIALIZE_DDLM_NOT_FOUND`**.
Translation: the framework package (Microsoft.WindowsAppRuntime.1.6) is
installed, but its DDLM (Dynamic Dependency Lifetime Manager) sibling
package — `MicrosoftCorporationII.WinAppRuntime.Main.1.6` — is NOT.
Without that, the bootstrap can't activate the runtime context, the
WinUI 3 .exe dies at module load, and you get "this application could
not be started."
Looking at `Get-AppxPackage`, this machine has Main.1.5 (5001.373) and
Main.1.8 (8000.836) installed, but NO Main.1.6.
**Three fixes, pick one:**
1. **Install the 1.6 DDLM** redistributable. Download
`Microsoft.WindowsAppRuntime.1.6` from
https://aka.ms/windowsappsdk/1.6/latest/windowsappruntimeinstall-x64.exe
and run it. After it installs, `Get-AppxPackage MicrosoftCorporationII.WinAppRuntime.Main.1.6`
should return a row.
2. **Switch the .csproj to WindowsAppSDK 1.8** (the package version
would be `Microsoft.WindowsAppSDK` 1.8.260508005, and the major.minor
in `Program.cs` becomes `0x00010008`). 1.8 IS fully installed on
this machine.
3. **Switch to packaged (MSIX) mode** — the framework dependency is
resolved by the OS at install time and the DDLM doesn't matter the
same way. Means giving up the existing MSI installer path for now.
Option 2 is the fastest. Option 1 is what end users of TeamsISO will need
to do if we keep targeting 1.6 LTS.
To reproduce the diagnosis from scratch:
cd src/TeamsISO.App.WinUI.Probe
dotnet build
dotnet bin/Debug/net8.0-windows/win-x64/TeamsISO.App.WinUI.Probe.dll
## What I did NOT do
* Touch the WPF host. Your running build is intact. The May 2026 batch
ships as-is.
* Touch Teams orchestration. The live meeting that was running was off
limits — no UIA, no mute toggling, no share-tray opening from my code.
* Migrate view-models or wire the engine into the WinUI host. Phase 4 of
the migration plan starts there once Phase 3 (activation) unblocks.
* Migrate the DataGrid (Phase 5). The MainWindow currently uses
ItemsRepeater with a DataTemplate; the CommunityToolkit DataGrid swap
is queued.
* Migrate Notes / Preview / Presets windows (Phase 6 remainder).
* Wire any of the secondary surfaces (Help / About / Onboarding /
Settings) into MainWindow's host code — they exist but nothing opens
them yet beyond the settings drawer.
## Suggested first session tomorrow
1. **Look at the screenshots**: `docs/preview/winui3-mainwindow-light.png`
and `docs/preview/winui3-mainwindow-dark.png` — proof shots of the
live .exe. If the design is right, the rest is execution.
2. **Run it yourself**: from a fresh shell,
`dotnet build src/TeamsISO.App.WinUI` then run the .exe at
`src/TeamsISO.App.WinUI/bin/Debug/net8.0-windows10.0.19041.0/win-x64/TeamsISO.exe`.
The redesigned shell should appear at 1280×780.
3. **Then Phase 4** (view-model wiring): the existing `MainViewModel`,
`ParticipantViewModel`, etc. in `src/TeamsISO.App/ViewModels/` use
WPF's `System.Windows.Threading.Dispatcher`. Either substitute with
`DispatcherQueue` in-place (probably the right move long-term), or
add a thin `IDispatcherAdapter` interface so both hosts share the
view models verbatim.
4. **Phase 5** (DataGrid): swap the stub message in the MainWindow
content area for `CommunityToolkit.WinUI.UI.Controls.DataGrid`
bound to `MainViewModel.Participants`. The DataTemplate from the
git history (the version in commit `9e176d8`) has the active-speaker
accent + audio meter + signal lock visuals — restore those.
5. **Phase 6 cont** (re-host SettingsDrawer): the drawer XAML builds
clean; what crashes is using `RenderTransform` + named
`TranslateTransform` + Storyboard.TargetName binding. Try
`Translation` via `ElementCompositionPreview.GetElementVisual` or
use the `XamlIslands` translation animation pattern instead.
6. **Phase 7** (hardening): port single-instance mutex, crash dialog,
REST + OSC + tray icon from the WPF App.xaml.cs.
## Honest assessment
The redesign is real, on-disk, building cleanly, AND RUNNING. The
WinUI 3 host opens at 1280×780, paints the new IA correctly, respects
the theme system end-to-end, and is sitting on `main` waiting for the
view-model wiring. The diagnostic probe (`TeamsISO.App.WinUI.Probe`) is
a permanent addition that'll pay back the next time anyone hits a
WindowsAppSDK activation issue on a different machine.
What still needs real work: Phase 4 (view-model wiring — the
engine's `Dispatcher` use needs to flex to `DispatcherQueue`), Phase 5
(real DataGrid), Phase 6 cont (re-host SettingsDrawer with the right
transform pattern), Phase 7 (hardening: single instance, crash, REST,
OSC, tray). None of these are blocked anymore — they're all execution
work.
The biggest risk to the v1.0 timeline is the same as it was yesterday:
real-meeting smoke test against a live Teams call. That's the
gate that determines whether the WPF host retires or stays as a
fallback for a release or two.
— end of log
— Claude, 2026-05-13 ~12:45am

View file

@ -1,799 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>TeamsISO — redesigned MainWindow preview</title>
<style>
:root {
/* Dark palette — mirrors src/TeamsISO.App.WinUI/Themes/Tokens.xaml */
--bg-canvas: #0a0a0a;
--bg-rail: #080808;
--bg-surface: #141416;
--bg-elevated: #1c1c1f;
--bg-hover: #26272b;
--bg-active: #33343a;
--border-subtle: #26272b;
--border-strong: #3a3b40;
--fg-primary: #f4f4f6;
--fg-secondary: #a3a4aa;
--fg-tertiary: #6b6c72;
--fg-disabled: #404145;
--fg-on-accent: #0a0a0a;
--accent-cyan-surface: #97edf0;
--accent-cyan-text: #97edf0;
--accent-cyan-hover: #b5f2f4;
--accent-cyan-muted: #1b3537;
--accent-coral: #fb819c;
--accent-coral-bg: #3a1922;
--status-live: #4ade80;
--status-live-bg: #13261a;
--status-warn: #fbbf24;
--status-warn-bg: #3a2e12;
--shadow-drawer: rgba(0,0,0,0.55);
}
html[data-theme="light"] {
--bg-canvas: #fafafb;
--bg-rail: #f0f1f3;
--bg-surface: #ffffff;
--bg-elevated: #ffffff;
--bg-hover: #eceef1;
--bg-active: #e0e3e7;
--border-subtle: #e5e7eb;
--border-strong: #d1d5da;
--fg-primary: #0a0a0a;
--fg-secondary: #4a4b50;
--fg-tertiary: #71747a;
--fg-disabled: #b3b6bc;
--fg-on-accent: #0a0a0a;
--accent-cyan-surface: #97edf0;
--accent-cyan-text: #0e7c82;
--accent-cyan-hover: #0890a0;
--accent-cyan-muted: #e6f8f9;
--accent-coral: #d43e5c;
--accent-coral-bg: #fdecf0;
--status-live: #15803d;
--status-live-bg: #dcfce7;
--status-warn: #b45309;
--status-warn-bg: #fef3c7;
--shadow-drawer: rgba(0,0,0,0.15);
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: #1a1a1c;
color: var(--fg-primary);
font-family: 'Inter', -apple-system, system-ui, 'Segoe UI Variable Display', 'Segoe UI', sans-serif;
min-height: 100vh;
}
html[data-theme="light"] body { background: #e8e9eb; }
.preview-shell {
max-width: 1304px;
margin: 24px auto;
padding: 0 12px;
}
.preview-banner {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; margin-bottom: 16px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 8px;
color: var(--fg-secondary);
font-size: 12px;
}
.preview-banner strong { color: var(--fg-primary); font-weight: 600; }
.preview-banner-actions { display: flex; gap: 8px; }
.preview-banner-actions button {
background: transparent;
border: 1px solid var(--border-strong);
color: var(--fg-primary);
padding: 6px 14px;
font-size: 12px; font-weight: 500;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
}
.preview-banner-actions button:hover { border-color: var(--accent-cyan-text); }
.preview-banner-actions .primary {
background: var(--accent-cyan-surface);
border-color: var(--accent-cyan-surface);
color: var(--fg-on-accent);
font-weight: 600;
}
.window {
width: 1280px; height: 780px;
background: var(--bg-canvas);
border: 1px solid var(--border-strong);
border-radius: 8px;
display: grid;
grid-template-columns: 64px 1fr;
overflow: hidden;
color: var(--fg-primary);
box-shadow: 0 16px 60px var(--shadow-drawer);
position: relative;
}
.rail {
background: var(--bg-rail);
border-right: 1px solid var(--border-subtle);
display: flex; flex-direction: column;
padding: 12px 0 12px 0;
}
.rail-top { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.rail-btn {
width: 48px; height: 48px;
margin: 4px 8px;
border-radius: 8px;
border: 0; background: transparent;
color: var(--fg-secondary);
display: flex; align-items: center; justify-content: center;
cursor: pointer;
transition: background 120ms ease-out, color 120ms ease-out;
}
.rail-btn:hover { background: var(--bg-hover); color: var(--accent-cyan-text); }
.rail-brand {
width: 48px; height: 56px;
margin: 0 8px 8px;
}
.rail-brand .mark {
width: 40px; height: 40px;
background: var(--accent-cyan-muted);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: var(--accent-cyan-text);
font-size: 22px; font-weight: 700;
}
.rail-divider {
height: 1px; background: var(--border-subtle);
margin: 4px 14px 12px;
}
.rail-btn.active {
background: var(--accent-cyan-muted);
color: var(--accent-cyan-text);
}
.rail-status-puck {
width: 48px; height: 48px;
margin: 12px 8px;
border-radius: 24px;
background: var(--status-live-bg);
border: 0;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
}
.rail-status-puck .dot {
width: 10px; height: 10px;
background: var(--status-live);
border-radius: 50%;
}
.icon {
width: 20px; height: 20px;
stroke: currentColor;
fill: none;
stroke-width: 1.6;
stroke-linecap: round; stroke-linejoin: round;
}
.content {
display: grid;
grid-template-rows: 44px auto 1fr auto 32px;
min-width: 0;
position: relative;
overflow: hidden;
}
.titlebar {
display: grid;
grid-template-columns: auto 1fr auto auto auto auto;
align-items: center;
background: var(--bg-canvas);
}
.titlebar-app {
display: flex; align-items: center; gap: 12px;
padding: 0 24px;
}
.titlebar-app .name {
font-size: 14px; font-weight: 600;
}
.titlebar-app .version {
font-family: 'JetBrains Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 11px; color: var(--fg-tertiary);
}
.titlebar-pills {
display: flex; gap: 8px;
padding: 0 12px 0 0;
}
.pill {
height: 22px;
border-radius: 999px;
padding: 0 12px;
display: inline-flex; align-items: center; gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
color: var(--fg-secondary);
}
.pill .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--fg-tertiary);
}
.pill.live { background: var(--status-live-bg); border-color: transparent; color: var(--status-live); }
.pill.live .dot { background: var(--status-live); }
.pill.rec { background: var(--accent-coral-bg); border-color: transparent; color: var(--accent-coral); }
.pill.rec .dot { background: var(--accent-coral); }
.titlebar-tool {
width: 46px; height: 32px;
border: 0; background: transparent;
color: var(--fg-primary);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.titlebar-tool:hover { background: var(--bg-hover); }
.titlebar-tool.close:hover { background: #c42b1c; color: white; }
.section-header {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
padding: 18px 32px 12px;
gap: 12px;
}
.section-title {
display: flex; align-items: center; gap: 12px;
}
.display-title {
font-size: 22px; font-weight: 600; letter-spacing: -0.01em;
color: var(--fg-primary);
}
.count-badge {
height: 22px; padding: 0 12px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 999px;
display: inline-flex; align-items: center;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--fg-secondary);
}
.section-actions {
display: flex; gap: 8px; align-items: center;
}
.input {
width: 200px; height: 34px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
color: var(--fg-primary);
border-radius: 8px;
padding: 0 12px;
font-family: inherit; font-size: 13px;
outline: none;
}
.input:focus { border-color: var(--accent-cyan-text); }
.input::placeholder { color: var(--fg-tertiary); }
.btn {
height: 34px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--border-strong);
background: transparent;
color: var(--fg-primary);
font-family: inherit; font-size: 12px; font-weight: 500;
cursor: pointer;
transition: border-color 120ms ease-out, background 120ms ease-out;
}
.btn:hover { border-color: var(--accent-cyan-text); background: var(--bg-hover); }
.btn.primary {
background: var(--accent-cyan-surface);
border-color: var(--accent-cyan-surface);
color: var(--fg-on-accent);
font-weight: 600;
}
.btn.primary:hover {
background: var(--accent-cyan-hover);
border-color: var(--accent-cyan-hover);
}
.btn.destructive {
color: var(--accent-coral);
border-color: var(--accent-coral);
}
.table {
padding: 0 32px;
overflow-y: auto;
min-height: 0;
}
.table-head {
display: grid;
grid-template-columns: 56px 2fr 1fr 1.2fr 1.5fr auto;
align-items: center;
height: 36px;
border-bottom: 1px solid var(--border-subtle);
color: var(--fg-tertiary);
font-size: 11px; font-weight: 500;
letter-spacing: 0.08em; text-transform: uppercase;
padding-right: 12px;
}
.table-head > * { padding: 0 4px; }
.row {
display: grid;
grid-template-columns: 56px 2fr 1fr 1.2fr 1.5fr auto;
align-items: center;
height: 64px;
border-bottom: 1px solid var(--border-subtle);
padding-right: 12px;
position: relative;
transition: background 120ms ease-out;
}
.row:hover { background: var(--bg-hover); }
.row.active-speaker {
background: var(--accent-cyan-muted);
}
.row .left-accent {
position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--accent-cyan-text);
display: none;
}
.row.active-speaker .left-accent { display: block; }
.row-avatar {
width: 56px;
display: flex; align-items: center; justify-content: center;
}
.avatar {
width: 36px; height: 36px;
border-radius: 50%;
background: var(--bg-active);
color: var(--fg-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 600;
}
.row.active-speaker .avatar {
background: var(--accent-cyan-muted);
color: var(--accent-cyan-text);
}
.row-name { line-height: 1.3; }
.row-name .name {
font-size: 14px; font-weight: 500;
margin-bottom: 2px;
}
.row-name .codec {
font-size: 11px; color: var(--fg-secondary);
}
.row-signal {
display: flex; align-items: center; gap: 8px;
font-family: 'JetBrains Mono', monospace; font-size: 11px;
}
.row-signal .dot {
width: 8px; height: 8px; border-radius: 50%;
}
.row-signal.locked .dot { background: var(--status-live); }
.row-signal.degraded { color: var(--status-warn); }
.row-signal.degraded .dot { background: var(--status-warn); }
.meter { display: flex; align-items: center; gap: 2px; height: 24px; }
.meter span {
width: 4px; border-radius: 2px;
background: var(--bg-active);
}
.meter.active span { background: var(--fg-secondary); }
.row.active-speaker .meter.active span { background: var(--accent-cyan-text); }
.row-output {
font-family: 'JetBrains Mono', monospace; font-size: 13px;
color: var(--fg-primary);
}
.iso-pill {
width: 80px;
padding: 6px 0;
border-radius: 999px;
text-align: center;
font-size: 11px; font-weight: 700;
letter-spacing: 0.06em;
}
.iso-pill.live {
background: var(--status-live-bg);
color: var(--status-live);
border: 1px solid var(--status-live);
}
.iso-pill.off {
background: var(--bg-surface);
color: var(--fg-secondary);
border: 1px solid var(--border-strong);
}
.in-call {
padding: 12px 32px;
border-top: 1px solid var(--border-subtle);
background: var(--bg-canvas);
display: flex; align-items: center; gap: 10px;
}
.in-call .label {
font-size: 11px; font-weight: 500; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--fg-tertiary);
margin-right: 8px;
}
.status-bar {
padding: 0 32px;
border-top: 1px solid var(--border-subtle);
background: var(--bg-canvas);
display: flex; align-items: center; justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
font-size: 11px; color: var(--fg-tertiary);
}
.status-bar .left {
display: flex; align-items: center; gap: 8px;
color: var(--fg-secondary);
}
.status-bar .left .dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--accent-cyan-text);
}
/* Settings drawer */
.drawer {
position: absolute;
top: 44px;
right: 0;
bottom: 0;
width: 400px;
background: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
flex-direction: column;
z-index: 5;
display: none;
}
.drawer.open {
display: flex;
animation: drawer-slide-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes drawer-slide-in {
from { transform: translateX(420px); }
to { transform: translateX(0); }
}
.drawer-head {
height: 56px;
padding: 0 12px 0 20px;
border-bottom: 1px solid var(--border-subtle);
display: flex; align-items: center; justify-content: space-between;
}
.drawer-head .title {
font-size: 18px; font-weight: 600;
}
.drawer-tabs {
display: flex; gap: 6px;
padding: 12px 20px 0;
border-bottom: 1px solid var(--border-subtle);
}
.drawer-tab {
padding: 8px 12px;
border: 0; background: transparent;
color: var(--fg-tertiary);
font-family: inherit; font-size: 12px; font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.drawer-tab.active {
color: var(--fg-primary);
border-bottom-color: var(--accent-cyan-text);
}
.drawer-body {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.drawer-body h3 {
font-size: 14px; font-weight: 600;
margin: 0 0 12px 0;
color: var(--fg-primary);
}
.drawer-body p {
font-size: 12px;
color: var(--fg-secondary);
margin: 0 0 16px 0;
line-height: 1.5;
}
.theme-picker { display: flex; gap: 8px; margin-bottom: 16px; }
.theme-pick-btn {
flex: 1;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid var(--border-strong);
background: transparent;
color: var(--fg-primary);
font-family: inherit; font-size: 13px; font-weight: 500;
cursor: pointer;
text-align: left;
}
.theme-pick-btn.active {
border-color: var(--accent-cyan-text);
background: var(--accent-cyan-muted);
}
.accent-swatches { display: flex; gap: 12px; flex-wrap: wrap; }
.swatch {
display: flex; flex-direction: column; gap: 6px;
text-align: center;
}
.swatch .chip {
width: 80px; height: 32px;
border-radius: 6px;
}
.swatch .label {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; color: var(--fg-tertiary);
letter-spacing: 0.06em; text-transform: uppercase;
}
.drawer-row {
display: grid;
grid-template-columns: 1fr auto;
padding: 6px 0;
border-bottom: 1px solid var(--border-subtle);
font-size: 13px;
}
.drawer-row .v {
font-family: 'JetBrains Mono', monospace;
color: var(--fg-secondary);
}
.drawer-foot {
padding: 12px 16px;
border-top: 1px solid var(--border-subtle);
display: flex; justify-content: flex-end; gap: 8px;
}
</style>
</head>
<body>
<div class="preview-shell">
<div class="preview-banner">
<div>
<strong>TeamsISO redesign — interactive preview</strong>
&nbsp;The same XAML that's in <code>src/TeamsISO.App.WinUI/Views/MainWindow.xaml</code>, rendered as HTML so you can see and toggle it before the WinUI 3 .exe activation issue is resolved.
</div>
<div class="preview-banner-actions">
<button id="open-drawer">Open settings</button>
<button id="toggle-theme" class="primary">Toggle dark / light</button>
</div>
</div>
<div class="window">
<!-- RAIL -->
<div class="rail">
<div class="rail-top">
<button class="rail-btn rail-brand" title="About TeamsISO">
<div class="mark">W</div>
</button>
<div class="rail-divider"></div>
<button class="rail-btn active" title="Participants">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="9" r="3.2"/><path d="M5 19c0-3.5 3.1-6 7-6s7 2.5 7 6"/></svg>
</button>
<button class="rail-btn" title="Launch / surface Teams">
<svg class="icon" viewBox="0 0 24 24"><rect x="3" y="7" width="13" height="10" rx="2"/><path d="M16 11l5-3v8l-5-3z"/></svg>
</button>
<button class="rail-btn" title="Hide / show Teams windows">
<svg class="icon" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="rail-btn" id="rail-settings" title="Settings">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
</button>
</div>
<button class="rail-status-puck" title="Engine status">
<div class="dot"></div>
</button>
</div>
<!-- CONTENT -->
<div class="content">
<!-- Title bar -->
<div class="titlebar">
<div class="titlebar-app">
<span class="name">TeamsISO</span>
<span class="version">v1.0.0-alpha</span>
</div>
<div></div>
<div class="titlebar-pills">
<div class="pill live"><div class="dot"></div>live · 00:14:32</div>
<div class="pill rec"><div class="dot"></div>rec 3 · 00:11:08</div>
<div class="pill">482 GB free</div>
</div>
<button class="titlebar-tool" id="titlebar-theme" title="Theme">
<svg class="icon" viewBox="0 0 24 24" id="theme-icon-mark"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
<button class="titlebar-tool" title="Minimize">
<svg class="icon" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="titlebar-tool" title="Maximize">
<svg class="icon" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14"/></svg>
</button>
<button class="titlebar-tool close" title="Close">
<svg class="icon" viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
</div>
<!-- Section header -->
<div class="section-header">
<div class="section-title">
<span class="display-title">Participants</span>
<span class="count-badge">4</span>
</div>
<div></div>
<div class="section-actions">
<input class="input" placeholder="Filter participants"/>
<button class="btn">Refresh</button>
<button class="btn">Presets</button>
<button class="btn primary">Enable all online</button>
</div>
</div>
<!-- Table -->
<div class="table">
<div class="table-head">
<div></div>
<div>Participant</div>
<div>Signal</div>
<div>Audio</div>
<div>Output name</div>
<div>ISO</div>
</div>
<div class="row active-speaker">
<div class="left-accent"></div>
<div class="row-avatar"><div class="avatar">MA</div></div>
<div class="row-name"><div class="name">Maya Rodriguez</div><div class="codec">MS Teams · 1920×1080 · 30fps</div></div>
<div class="row-signal locked"><div class="dot"></div>locked</div>
<div>
<div class="meter active">
<span style="height:24px"></span>
<span style="height:20px"></span>
<span style="height:28px"></span>
<span style="height:18px"></span>
<span style="height:12px"></span>
<span style="height:22px"></span>
<span style="height:8px"></span>
<span style="height:14px"></span>
<span style="height:6px"></span>
</div>
</div>
<div class="row-output">TEAMSISO_maya</div>
<div><div class="iso-pill live">LIVE</div></div>
</div>
<div class="row">
<div class="row-avatar"><div class="avatar">DC</div></div>
<div class="row-name"><div class="name">Daniel Chen</div><div class="codec">MS Teams · 1280×720 · 30fps</div></div>
<div class="row-signal locked"><div class="dot"></div>locked</div>
<div>
<div class="meter active">
<span style="height:10px"></span>
<span style="height:14px"></span>
<span style="height:8px"></span>
<span style="height:12px"></span>
<span style="height:6px"></span>
<span style="height:9px"></span>
<span style="height:4px"></span>
<span style="height:3px"></span>
<span style="height:2px"></span>
</div>
</div>
<div class="row-output">TEAMSISO_daniel</div>
<div><div class="iso-pill live">LIVE</div></div>
</div>
<div class="row">
<div class="row-avatar"><div class="avatar">AK</div></div>
<div class="row-name"><div class="name">Aïcha Koné</div><div class="codec">MS Teams · 1920×1080 · 30fps</div></div>
<div class="row-signal degraded"><div class="dot"></div>degraded</div>
<div>
<div class="meter">
<span style="height:3px"></span>
<span style="height:4px"></span>
<span style="height:3px"></span>
<span style="height:2px"></span>
</div>
</div>
<div class="row-output" style="color:var(--fg-secondary)">TEAMSISO_aicha</div>
<div><div class="iso-pill off">OFF</div></div>
</div>
<div class="row">
<div class="row-avatar"><div class="avatar">SP</div></div>
<div class="row-name"><div class="name">Sam Park</div><div class="codec">MS Teams · 1920×1080 · 30fps</div></div>
<div class="row-signal locked"><div class="dot"></div>locked</div>
<div>
<div class="meter active">
<span style="height:8px"></span>
<span style="height:12px"></span>
<span style="height:16px"></span>
<span style="height:7px"></span>
<span style="height:5px"></span>
<span style="height:3px"></span>
</div>
</div>
<div class="row-output">TEAMSISO_sam</div>
<div><div class="iso-pill live">LIVE</div></div>
</div>
</div>
<!-- In-call control -->
<div class="in-call">
<span class="label">In-call</span>
<button class="btn destructive">⊘ Muted</button>
<button class="btn">⌗ Camera</button>
<button class="btn">⇪ Share</button>
<button class="btn">▷ Marker</button>
<button class="btn destructive">Leave</button>
<button class="btn" style="width:36px;padding:0;"></button>
</div>
<!-- Status bar -->
<div class="status-bar">
<div class="left">
<div class="dot"></div>
<span>control surface · 127.0.0.1:9755</span>
</div>
<div>F1 help · Ctrl+M marker · Ctrl+Shift+S panic · Ctrl+K command palette</div>
</div>
<!-- Settings drawer -->
<div class="drawer" id="drawer">
<div class="drawer-head">
<div class="title">Settings</div>
<button class="titlebar-tool" id="drawer-close" title="Close (Esc)">
<svg class="icon" viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
</div>
<div class="drawer-tabs">
<button class="drawer-tab active">Appearance</button>
<button class="drawer-tab">Routing</button>
<button class="drawer-tab">Display</button>
<button class="drawer-tab">Control</button>
<button class="drawer-tab">Advanced</button>
</div>
<div class="drawer-body">
<h3>Appearance</h3>
<p>Dark is the default for the 1:50am operator scene; light is for daytime production. System follows the Windows app-mode preference.</p>
<div class="theme-picker">
<button class="theme-pick-btn" data-theme="dark">Dark</button>
<button class="theme-pick-btn active" data-theme="dark">System</button>
<button class="theme-pick-btn" data-theme="light">Light</button>
</div>
<h3>Accent peek</h3>
<p>These accents work in both themes. Cyan stays bright as a surface fill (text on top is near-black regardless). For inline text on light, the palette substitutes a darker cyan automatically.</p>
<div class="accent-swatches">
<div class="swatch"><div class="chip" style="background:var(--accent-cyan-surface)"></div><div class="label">Cyan</div></div>
<div class="swatch"><div class="chip" style="background:var(--accent-coral)"></div><div class="label">Coral</div></div>
<div class="swatch"><div class="chip" style="background:var(--status-live)"></div><div class="label">Live</div></div>
<div class="swatch"><div class="chip" style="background:var(--status-warn)"></div><div class="label">Warn</div></div>
</div>
</div>
<div class="drawer-foot">
<button class="btn">Reset to defaults</button>
<button class="btn primary">Apply</button>
</div>
</div>
</div>
</div>
</div>
<script>
const html = document.documentElement;
const themeIcon = document.getElementById('theme-icon-mark');
const sunPath = 'M12 1v2 M12 21v2 M4.2 4.2l1.4 1.4 M18.4 18.4l1.4 1.4 M1 12h2 M21 12h2 M4.2 19.8l1.4-1.4 M18.4 5.6l1.4-1.4 M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8';
const moonPath = 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z';
function applyTheme(t) {
html.dataset.theme = t;
themeIcon.setAttribute('d', t === 'light' ? sunPath : moonPath);
themeIcon.parentElement.innerHTML = `<svg class="icon" viewBox="0 0 24 24" id="theme-icon-mark"><path d="${t === 'light' ? sunPath : moonPath}"/></svg>`;
}
function toggle() {
applyTheme(html.dataset.theme === 'light' ? 'dark' : 'light');
}
document.getElementById('toggle-theme').addEventListener('click', toggle);
document.getElementById('titlebar-theme').addEventListener('click', toggle);
const drawer = document.getElementById('drawer');
document.getElementById('rail-settings').addEventListener('click', () => drawer.classList.add('open'));
document.getElementById('open-drawer').addEventListener('click', () => drawer.classList.add('open'));
document.getElementById('drawer-close').addEventListener('click', () => drawer.classList.remove('open'));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') drawer.classList.remove('open'); });
applyTheme('dark');
</script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

View file

@ -1,194 +0,0 @@
# TeamsISO v2 — Studio Terminal (approved shape brief)
**Date approved:** 2026-05-13
**Approver:** Zac (operator + product owner)
**Host:** WPF .NET 8 (`src/TeamsISO.App/`). WinUI 3 rebuild is abandoned.
**Predecessor:** the WPF rollback at `1d1ce6a` (recording axed, settings pane tab fix, settings button wired).
## Why this redesign
The v1 GUI failed the "AI made that" test. Quote from the operator: "its cluttered, screams that AI made it - and relatively inefficient to navigate." The PRODUCT.md anti-references — card-grid-of-icons, always-visible side panel, footer-as-theatre — all describe the current build. v2 commits to a different aesthetic register entirely.
## Aesthetic register
**Broadcast-engineering instrument.** Not a SaaS dashboard. Not Material. Not Fluent default.
Reference proximity: Linear's keyboard-first density × Avid S6 console legibility × Blackmagic ATEM's information hierarchy. The operator mental model is "I'm sitting at an audio mixer; every region has a job, no region is theatre."
## What goes away
- The 72px left rail (no actual navigation — there's only one screen)
- The 380px always-visible settings pane (settings change rarely, shouldn't claim permanent real estate)
- The 6-column footer status row (theatre, not information)
- The custom chromeless title-bar caption buttons (look worse than system chrome, break on DPI scaling)
- The "by Wild Dragon" pill and the always-visible "TeamsISO" wordmark as decorative chrome
- The in-call control bar as a permanent strip (only relevant in-call; should appear conditionally)
- The seven identical ghost buttons in the in-call bar (textbook card-grid anti-pattern)
## What replaces it
```
┌─ system Windows title bar [_ □ ✕] ─────────────────────┐
│ 🐉 TeamsISO [⌘K] [☾] [⚙] │ 32px header — mark + wordmark left, 3 icons right
├────────────────────────────────────────────────────────┤
│ ● 02:14:32 PART 4 · LIVE 2 DISK 482g CTRL :9755 │ transport strip — single mono line, replaces footer
├────────────────────────────────────────────────────────┤
│ │
│ ▮ alice ▮▮▮▮ t:5ms alice [LIVE] │
│ ▯ bob ▮▮ t:8ms bob [— OFF]│ participants table = the canvas
│ ▮ carlos ▮▮▮▮▮ t:9ms carlos [LIVE] │ (cyan-tinted row bg = active speaker)
│ ▮ guest 4 -- NO SIG guest_4 [ERROR]│
│ │
├────────────────────────────────────────────────────────┤
│ IN CALL · Daily standup [mute] [cam] [leave] │ conditional — only renders when in call
└────────────────────────────────────────────────────────┘
```
### Header (32px)
Left: Wild Dragon mark (~20px) + "TeamsISO" wordmark in Inter 13 Medium. Click on mark opens About.
Right: three icon buttons.
- `⌘K` (Tabler `ti-command`) — opens command palette (also Ctrl+K, Ctrl+P shortcut)
- `☾` / `☀` (Tabler `ti-moon` / `ti-sun`) — cycles theme dark ↔ light. Tooltip "Theme (System / Dark / Light)" — long-press could open the tri-state, but for v2 just a one-click cycle.
- `⚙` (Tabler `ti-settings`) — opens settings drawer
That's all the chrome. No nav rail because there's nothing to navigate to.
### Transport strip
Single horizontal line. Mono type (JetBrains Mono 12). Replaces the entire footer.
Fields:
- `● 02:14:32` — green dot + session timer when at least one ISO is live; both hidden otherwise
- `PART 4 · LIVE 2` — participant count and live-ISO count; "PART" / "LIVE" in Inter 11 SemiBold UPPER tracking 0.06em, numbers in mono
- `DISK 482g` — free disk space on the working volume; coral text if <10GB, hidden if no relevant volume is configured
- `CTRL :9755` — control surface bind; cyan text when active, hidden when off
No icons. No badges. No backgrounds. Just typed status — a console heads-up display.
### Participants table — the canvas
Five columns:
| # | Width | Content | Type |
|---|---|---|---|
| 1 | 24px | State LED — 8×8 filled cyan/coral or hollow neutral | hard-edged square, no rounding |
| 2 | * | Name (Inter 13/Medium) + codec/latency caption (Mono 11/Regular, tertiary fg) | "Alice Wong" / "NDIV5 · t:5ms" |
| 3 | 110px | Audio meter — 5 vertical bars, instantaneous level | hard-edged, cyan when LIVE, neutral when OFF |
| 4 | 130px | Output name | Mono 12 |
| 5 | 100px | ISO toggle pill | LIVE = cyan fill / OFF = hollow neutral / ERROR = coral outline |
Row height: 52px (was 56).
Active speaker: full-row background tint `bg.active-speaker` (cyan-tinted muted neutral). NOT a left-edge stripe — that trips the impeccable "side-stripe border" ban.
Each row reacts to:
- Click anywhere → focuses the row, keyboard-actions apply
- Click the pill → toggle ISO
- Right-click → context menu (preview, custom name, copy NDI source name, save snapshot)
- Hover → reveals a kebab affordance in column 5 right edge for less-frequent actions
### Conditional meeting bar
Renders below the table only when `TeamsControlBridge.DetectCallState().IsInCall == true`. Slides up from below on transition (~120ms ease-out-quart on `RenderTransform.Y` + `Opacity`).
Content: `IN CALL` label (Inter 11 SemiBold UPPER, cyan accent) + meeting title (Mono 12, truncated with ellipsis) + three buttons right-aligned (Mute / Cam / Leave). Share and Notes do NOT live here — they move to ⌘K, where they're invocable any time without the bar fighting for attention.
Width matches the table — not full-bleed; respects the page padding.
### Ctrl+K command palette
The redesign's navigation move. Replaces ~80% of what's in the v1 rail + tabbed settings.
Behavior:
- `Ctrl+K` (also `Ctrl+P`) opens a centered floating window over the main shell, 560×360px
- Search input at the top, results list below
- Empty input → frequent + recent commands
- Typing → fuzzy-matches across command label + category + keywords
- ↑/↓ navigates, Enter invokes, Esc closes
Command categories (each command has icon, label, optional value preview, optional shortcut hint):
- **Quick** — Enable all online, Stop all ISOs, Refresh discovery, Drop snapshot of all
- **Teams** — Launch Teams, Hide / show Teams windows, Mute, Toggle camera, Open share, Leave call
- **Presets** — Apply <preset name>… (one row per saved preset), Save current as preset, Manage presets
- **Output** — Framerate 24 / 30 / 60, Resolution 1080p / 720p, Aspect Pillarbox / Letterbox / Stretch
- **Network** — Apply transcoder topology, Restore default NDI groups, Edit output name template
- **App** — Theme dark / light / system, Open settings, About TeamsISO, Help (F1)
This is the keyboard-first surface broadcasters with Stream Decks already mentally use.
### Settings — slide-over drawer
Triggered from the header gear icon, or from `Open settings` in the palette, or hotkey `,` (comma).
- 420px wide, slides in from the right
- 40% canvas scrim behind
- Three tabs: **OUTPUT** (framerate / resolution / aspect / audio + Reset to defaults), **NETWORK** (discovery / output groups + Apply transcoder topology + Restore defaults + output name template), **APP** (theme tri-state, minimize to tray, sort order, Launch Teams on startup, Auto-hide Teams windows)
- Apply Changes button pinned to drawer footer; Esc dismisses; click outside the drawer dismisses
DISPLAY tab from v1 gets renamed APP and absorbs the theme tri-state.
### Empty states
- No participants yet: a single centered mono sentence, "no ndi sources yet — open teams and start a meeting", and one tertiary button "Refresh discovery (Ctrl+R)". No illustration, no mascot.
- Not in a call: meeting bar simply doesn't render. No placeholder.
- Discovery degraded: amber dot in transport strip's session timer position, mono text "NDI discovery — restarting". No banner.
## Color, theme, motion
**Color strategy:** Restrained (impeccable product default). Cyan accent earns its place — reserved for LIVE state, focus ring, active speaker tint. Coral reserved for destructive + error. Status amber for warnings. Green NOT used (would compete with cyan for "ok / live" semantics).
**Theme default:** Follow Windows. Theme persists per-operator via `UIPreferences.Theme`. Implementation: split `WildDragonTheme.xaml` into a single style + token-shape file plus two color-only ResourceDictionary files (`Theme.Dark.xaml`, `Theme.Light.xaml`). At runtime `ThemeManager` swaps the merged dictionary entry. WPF analog of WinUI's `ThemeDictionary`.
**Motion:**
- 120ms `cubic-bezier(0.16, 1, 0.3, 1)` (ease-out-quart) on the meeting bar slide-in/out
- 200ms ease-out on the drawer slide
- 180ms cross-fade on theme swap
- 90ms on focus + hover transitions
- No bounce, no elastic, no spring overshoots. Animate `RenderTransform` and `Opacity` only — never layout properties.
## Typography commitments
| Token | Family | Size | Weight | Used for |
|---|---|---|---|---|
| `text.timer` | JetBrains Mono | 14 | Medium | Session timer in transport strip — instrument-grade |
| `text.caption` | Inter | 11 | SemiBold (600) | UPPER + tracking 0.06em — transport-strip labels, "IN CALL", "SPEAKING" |
| `text.display` | Inter | 22 | SemiBold | Settings drawer headings only |
| `text.title` | Inter | 13 | Medium | Wordmark, table column headers |
| `text.body` | Inter | 13 | Regular | Participant display names |
| `text.mono.code` | JetBrains Mono | 12 | Regular | Output names, NDI IDs, meeting title |
| `text.mono.tech` | JetBrains Mono | 11 | Regular | Latency readouts, codec captions, transport-strip values |
## What this is NOT
- Not Fluent-styled. Default Fluent accent integration is generic Windows; TeamsISO is a broadcaster's tool.
- Not minimalism for its own sake. The participants table is *dense*. Density is the broadcaster's virtue.
- Not chromeless. Default system title bar stays. Chromeless windows break embarrassingly at 4K + DPI scaling.
- Not vanity-branded. The Wild Dragon mark sits small in the header as a quality cue, never as decoration.
## Migration path
The view-model surface in `src/TeamsISO.App/ViewModels/` is the contract. The redesign rewrites `MainWindow.xaml` and `Themes/*` but leaves view-models, the engine, the control surface server, and the OSC bridge untouched.
Order of operations (each step builds clean before the next):
1. **Theme split** — Refactor `WildDragonTheme.xaml``Themes/Theme.Tokens.xaml` (styles + key shape) + `Themes/Theme.Dark.xaml` + `Themes/Theme.Light.xaml` (color resources only). Port `ThemeManager` from the deleted WinUI project; wire system app-mode detection via registry (`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme`).
2. **Main window shell** — Replace MainWindow.xaml's outer Grid. Add 32px header, transport strip, full-width content area, conditional meeting bar. Delete the 72px rail, the 380px right pane, the footer.
3. **Participants table redesign** — 5 columns, LED state, instantaneous audio meter, ISO pill.
4. **Settings drawer** — Slide-over from right, dismissable; reuses existing settings view-model.
5. **Command palette**`Ctrl+K` floating window with fuzzy command list.
Each step is a self-contained commit so the v1 build remains shippable at any rollback point.
## Anti-references — explicit on the "AI made that" failure
These are the failure modes the redesign defends against:
- Card-grid-of-icons (the v1 in-call bar's seven identical ghost buttons)
- Always-visible side panel (the v1 380px settings sidebar)
- Decorative chrome (the v1 "by Wild Dragon" pill, the 72px nav rail, the six-column footer)
- Generic Inter at 13 for everything
- Default WPF DataGrid (Excel)
- Custom chromeless title bars that look generic
- Gradient text, glassmorphism, side-stripe borders (impeccable absolute bans)
- "Hero metric + supporting stats + gradient" SaaS dashboards
- Mascots, "Welcome!" copy, illustrated onboarding cards

View file

@ -1,167 +0,0 @@
# TeamsISO Phase B-1 — Pipeline Orchestration Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Implement the engine-side pipeline orchestration on top of the `INdiInterop` test seam from Phase A — `NdiReceiver`, `NdiSender`, `ExponentialBackoff`, `NdiRuntimeProbe`, `IsoPipeline` (lifecycle + restart loop), and `IsoController` (top-level engine API). All testable on Linux against `FakeNdiInterop`. Phase B-2 (real Windows P/Invoke for `INdiInterop` + libyuv `IFrameScaler` + integration tests) follows.
**Architecture:** Pure orchestration. Each `IsoPipeline` wires one `NdiReceiver` → existing `FrameProcessor` → one `NdiSender` via two bounded channels. The pipeline owns a restart loop driven by `ExponentialBackoff`. `IsoController` is the top of the engine — holds the pipeline dictionary, the `ParticipantTracker`, the `ConfigStore`, and exposes the contract the WPF host (Phase C) will bind to.
**Tech Stack:** .NET 8, xUnit, FluentAssertions. No new external dependencies.
**Source spec:** `docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md`
---
## File structure additions
```
src/TeamsISO.Engine/
├── Pipeline/
│ ├── NdiReceiver.cs (NEW)
│ ├── NdiSender.cs (NEW)
│ ├── ExponentialBackoff.cs (NEW)
│ ├── IsoPipeline.cs (NEW)
│ └── IsoPipelineConfig.cs (NEW)
├── Interop/
│ └── NdiRuntimeProbe.cs (NEW)
└── Controller/
├── IIsoController.cs (NEW)
└── IsoController.cs (NEW)
src/tests/TeamsISO.Engine.Tests/
├── Pipeline/NdiReceiverTests.cs (NEW)
├── Pipeline/NdiSenderTests.cs (NEW)
├── Pipeline/ExponentialBackoffTests.cs (NEW)
├── Pipeline/IsoPipelineTests.cs (NEW)
├── Interop/NdiRuntimeProbeTests.cs (NEW)
└── Controller/IsoControllerTests.cs (NEW)
```
---
## Task 1: `NdiReceiver`
Receiver that wraps `INdiInterop.CaptureFrame` and pushes results into a `ChannelWriter<RawFrame>`. Exposes a `CaptureOnce` test seam mirroring `FrameProcessor.ProcessOnceAsync`. `RunAsync` is the production loop with `LongRunning` thread semantics.
TDD assertions:
- `CaptureOnce` writes a captured frame to the output channel; counter increments.
- `CaptureOnce` does nothing on null capture (timeout); counter does not change.
- `RunAsync` honors cancellation and disposes the receiver handle on exit.
Commit: `feat(pipeline): add NdiReceiver with channel-based output`
---
## Task 2: `NdiSender`
Sender that pulls from a `ChannelReader<ProcessedFrame>` and forwards to `INdiInterop.SendFrame`. `SendNextAsync` returns true if a frame was sent; false if the channel completed. `RunAsync` loops until cancellation.
TDD assertions:
- `SendNextAsync` forwards a frame to the interop and increments the sent counter.
- Returns `false` when channel completes.
- `RunAsync` honors cancellation and disposes the sender handle.
Commit: `feat(pipeline): add NdiSender with channel-based input`
---
## Task 3: `ExponentialBackoff`
Pure policy type. Given an attempt count, returns the next delay (1, 2, 4, 8, 16 s, capped at 30 s) and decides whether to give up after N consecutive failures (default 5).
TDD assertions:
- Sequence at attempts 1..5 is 1, 2, 4, 8, 16 seconds.
- `ShouldGiveUp` returns true after the 5th attempt.
- Cap: at attempt 7 the delay is 30 s, not 64.
Commit: `feat(pipeline): add ExponentialBackoff policy`
---
## Task 4: `NdiRuntimeProbe`
Reads the runtime version via `INdiInterop.GetRuntimeVersion()`, compares to an expected value (passed in by the engine for now; a real comparison against the SDK headers is Phase B-2). Returns either `Match` or `Mismatch` with both versions populated. The `IsoController` will surface `EngineAlert.NdiRuntimeMismatch` from a mismatch.
TDD assertions:
- Match when versions equal.
- Mismatch carries detected and expected.
Commit: `feat(interop): add NdiRuntimeProbe with version-mismatch result`
---
## Task 5: `IsoPipeline` core lifecycle
Owns one `NdiReceiver`, one `FrameProcessor`, one `NdiSender`, and the two channels between them. `StartAsync` creates the channels, instantiates the receiver/processor/sender, kicks off the three loops on long-running tasks. `StopAsync` cancels the token, awaits the loops, and disposes everything.
`IsoState` transitions: `Idle``Receiving` (after start) → `Sending` (after first send) → `NoSignal` (handled by FrameProcessor's slate path and exposed via Stats). On exception the loop transitions to `Error`.
The restart loop is in Task 6.
TDD assertions:
- Start transitions Idle → Receiving.
- Stop transitions back to Idle and disposes interop handles.
- Receiver/sender handles are created on Start, disposed on Stop.
Commit: `feat(pipeline): add IsoPipeline core lifecycle`
---
## Task 6: `IsoPipeline` restart loop
Wraps the running pipeline in a supervisory loop that catches unhandled exceptions, applies `ExponentialBackoff`, and either restarts or transitions to `Error` after exhausting retries. State observable updates accordingly.
TDD assertions (using a fault-injecting INdiInterop):
- Pipeline that fails once, then runs cleanly, restarts and ends up Sending.
- Pipeline that fails 5+ consecutive times transitions to Error and stays there.
- Backoff delays are honored (using a fake delay primitive for fast tests).
Commit: `feat(pipeline): add IsoPipeline restart supervisor with backoff`
---
## Task 7: `IIsoController` interface + `IsoController` implementation
The top-of-engine API the WPF host will bind to in Phase C.
Surface:
- `IObservable<IReadOnlyList<Participant>> Participants { get; }`
- `IObservable<EngineAlert> Alerts { get; }`
- `IsoHealthStats GetStats(Guid participantId)`
- `Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken ct)`
- `Task DisableIsoAsync(Guid participantId, CancellationToken ct)`
- `Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken ct)`
Implementation owns: `ParticipantTracker`, `NdiDiscoveryService`, dictionary of `IsoPipeline`, the `ConfigStore`, the runtime probe.
TDD assertions:
- `EnableIsoAsync` creates and starts a pipeline; `DisableIsoAsync` stops and removes it.
- `SetGlobalSettingsAsync` persists via ConfigStore and applies to existing pipelines.
- Discovery events flow through to the participants observable.
- `NdiRuntimeProbe` mismatch surfaces an alert.
Commit: `feat(controller): add IIsoController and IsoController implementation`
---
## Task 8: Wrap-up & milestone tag
- Run full test suite, confirm all green.
- Confirm coverage threshold still ≥80%.
- Update `docs/superpowers/plans/_NEXT.md` to describe Phase B-2 (Windows-only).
- Tag `phase-b-1-complete`.
Commit: `chore: phase-b-1 milestone wrap-up`
Tag: `phase-b-1-complete`
---
## Self-review
**Spec coverage:** Spec §4 components NdiReceiver, NdiSender, IsoPipeline, IsoController — Tasks 1, 2, 5, 6, 7. Spec §6 error handling restart/backoff — Task 6. Spec §6 NDI runtime mismatch — Task 4 + Task 7. ConfigStore integration in IsoController — Task 7.
**Phase B-2 (deferred):** Real `NdiInteropPInvoke` shim, real `LibYuvFrameScaler`, console smoke runner, integration tests against NDI Test Pattern source. All require Windows + NDI runtime so they live in their own plan.
**Type consistency:** All new types reference Phase A types unchanged. `INdiInterop` surface is sufficient — no additions needed.
No issues to fix. Ready to execute.

View file

@ -1,30 +0,0 @@
# TeamsISO Phase B-2 — Real NDI Interop Plan
**Goal:** Production `INdiInterop` implementation in `TeamsISO.Engine.NdiInterop` against NDI SDK 6, a managed BGRA scaler with aspect modes, an NDI version constant, and a `TeamsISO.Console` headless smoke runner that wires up the engine end-to-end. After this phase the engine can drive real Teams NDI streams once run on a Windows box with the NDI runtime installed.
**Architecture:** P/Invoke against `Processing.NDI.Lib.x64.dll`. Frame marshalling translates NDI's `video_frame_v2_t` to/from our managed `RawFrame`/`ProcessedFrame`. Receive in BGRA color space (`NDIlib_recv_color_format_e_BGRX_BGRA`) so the scaler doesn't need to handle UYVY in v1.0. Memory management: every captured frame is freed via `NDIlib_recv_free_video_v2` once we've copied its pixels into a managed buffer.
**Tech Stack:** .NET 8, `System.Runtime.InteropServices`, plain C# scaler (managed BGRA nearest-neighbor; libyuv is a v1.5 perf optimization). The console runner uses the existing `EngineLogging.CreateConsole`.
**Source spec:** `docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md`
## Tasks
1. **NDI native bindings:** `NdiNative.cs` with all `[DllImport]` declarations needed (`initialize`, `destroy`, `find_create_v2/destroy/get_current_sources`, `recv_create_v3/destroy/capture_v3/free_video_v2`, `send_create/destroy/send_video_v2`, `version`). Define `NDIlib_video_frame_v2_t`, `NDIlib_source_t`, `NDIlib_recv_create_v3_t`, `NDIlib_send_create_t` structs with explicit layout.
2. **Handles:** `NdiPInvokeFindHandle`, `NdiPInvokeReceiverHandle`, `NdiPInvokeSenderHandle` deriving from the abstract Phase A handles, owning the unmanaged pointers.
3. **NdiInteropPInvoke:** the production `INdiInterop` implementation. Initializes NDI on construction; destroys on dispose. Marshals between native and managed frame structs. Allocates managed pixel buffers and copies; frees the native frame immediately.
4. **NdiVersion:** a constants class exposing the version string the engine probe compares against.
5. **ManagedNearestNeighborFrameScaler:** managed BGRA scaler with `Pillarbox`, `Letterbox`, `Stretch` aspect modes. Fully unit-tested.
6. **TeamsISO.Console:** a small console host. Constructs `IsoController` against `NdiInteropPInvoke` + `ManagedNearestNeighborFrameScaler`, prints participant updates, listens for `q\n` to quit. Useful for headless validation.
7. **Wire-up tests:** integration scaffold uses `RuntimeInformation.IsOSPlatform(OSPlatform.Windows)` to skip cleanly on non-Windows. Add a smoke integration test that constructs the interop and probes the version.
8. **Wrap-up:** tag `phase-b-2-complete`.
## What this phase intentionally does NOT include
- libyuv-backed scaler (deferred to v1.5 per spec — managed scaler is functionally complete).
- Actual integration test suite running against an NDI Test Pattern source. Those tests need the NDI runtime; they're authored here but stay tagged `requires=ndi` and skip in the Linux CI.
- Audio handling (passthrough video only in this phase; audio support added later if v1.0 needs it before ship).
## Self-review
Spec coverage: §4 NdiReceiver/NdiSender/IsoController already done in B-1; this phase fills in the actual NDI SDK calls under `INdiInterop`. §6 startup preflight via `NdiVersion` + the existing `NdiRuntimeProbe`. §8 console smoke runner is a Phase B-2 deliverable for first end-to-end Windows validation before WPF.

View file

@ -1,43 +0,0 @@
# TeamsISO Phase C — WPF MVVM UI Plan
**Goal:** Operator-facing WPF UI bound to `IIsoController`. Displays the live participant list, lets operators enable/disable per-participant ISO outputs, set the global framerate / resolution / aspect / audio mode, view engine alerts, and see basic system health. Plus a WiX MSI installer and a release CI pipeline.
**Architecture:** MVVM with no third-party MVVM framework — small managed `ObservableObject` and `RelayCommand` helpers. The view models bind directly to `IIsoController`'s observables. UI runs on the WPF dispatcher; observable subscriptions marshal back via a captured `SynchronizationContext`. App.xaml.cs constructs the engine on startup and disposes on exit.
**Tech stack:** WPF on .NET 8, MVVM hand-rolled, no external UI library yet (MaterialDesignThemes can be added in a polish pass).
## File structure additions
```
src/TeamsISO.App/
├── App.xaml / App.xaml.cs (DI bootstrap)
├── MainWindow.xaml / MainWindow.xaml.cs
├── ViewModels/
│ ├── ObservableObject.cs
│ ├── RelayCommand.cs
│ ├── MainViewModel.cs
│ ├── ParticipantViewModel.cs
│ ├── GlobalSettingsViewModel.cs
│ └── AlertBannerViewModel.cs
├── Converters/
│ ├── BoolToVisibilityConverter.cs
│ └── EnumDescriptionConverter.cs
└── TeamsISO.App.csproj
src/TeamsISO.Installer/
└── TeamsISO.Installer.wixproj (MSI installer; v5)
```
## Tasks
1. **MVVM helpers**`ObservableObject` base implementing `INotifyPropertyChanged`; `RelayCommand` and `AsyncRelayCommand`.
2. **GlobalSettingsViewModel** — exposes Framerate, Resolution, Aspect, Audio as bindable selected values; `Apply` command calls `controller.SetGlobalSettingsAsync`.
3. **ParticipantViewModel** — wraps a `Participant`, exposes IsEnabled, CustomOutputName, and current status; `EnableCommand` and `DisableCommand` call the controller.
4. **AlertBannerViewModel** — collects `EngineAlert`s and exposes the most recent one with a "dismiss" command.
5. **MainViewModel** — top-level. Owns the controller. Exposes `ObservableCollection<ParticipantViewModel>`, the settings VM, and the banner VM.
6. **MainWindow.xaml** — DataGrid for participants with toggle column, settings panel docked to the right, alert banner docked top.
7. **Converters** — bool→visibility, enum→display string.
8. **App.xaml.cs** — wires DI: build engine + controller + main view model, set MainWindow's DataContext, dispose on exit.
9. **WiX installer (Phase C-2)** — separate task; can ship after the UI is alive.
Each step ships as its own commit. Tag `phase-c-complete` after MainWindow renders and the controller is bound.

View file

@ -1,183 +0,0 @@
# Plan Backlog
## Completed phases
- **Phase A — Engine Foundation** (tag: `phase-a-complete`) — domain model, parsers, participant tracker, frame processor, config, fakes, CI gate.
- **Phase B-1 — Pipeline Orchestration** (tag: `phase-b-1-complete`) — NdiReceiver, NdiSender, ExponentialBackoff, NdiRuntimeProbe, IsoPipeline supervisor, IsoController.
- **Phase B-2 — Real NDI Interop** (tag: `phase-b-2-complete`) — `NdiInteropPInvoke` against NDI 6 SDK, managed BGRA scaler, `TeamsISO.Console` headless smoke runner, `NdiVersion` constants.
- **Phase C — WPF UI** (tag: `phase-c-complete`) — MVVM helpers, ParticipantViewModel, GlobalSettingsViewModel, AlertBannerViewModel, MainViewModel, MainWindow XAML with participants DataGrid + settings sidebar + alert banner, App.xaml DI bootstrap.
- **Hardening + brand pass — May 2026** — see "Done since the May 2026 hand-off" below.
- **Phase D — WiX Installer & Forgejo release** — WiX v5 MSI scaffold, ARP icon wired, tag-push release workflow that builds + uploads MSI as a release asset.
## Done since the May 2026 hand-off
### Engine
- Forward-slash project paths in `TeamsISO.sln` so `.slnf` filters work on Windows MSBuild.
- `NdiNativeLibraryResolver` resolves `Processing.NDI.Lib.x64.dll` via `NDI_RUNTIME_DIR_V6` (with V5 / V4 fallbacks), so the engine starts on installs where the NDI dir isn't on PATH.
- `NdiVersion.ExpectedRuntimeVersionPrefix` updated to match the shipping NDI 6 banner format (`NDI SDK WIN64 …`).
- `NdiSourceParser` accepts current Teams desktop's `MS Teams - <name>` brand format (plus legacy `Teams` and defensive `Microsoft Teams`); reserved suffixes (`Active Speaker`, `Audio`, `Audio Mix`, `Screen Share`) are recognized in both legacy and dash-prefixed forms.
- NDI **groups** end-to-end (discovery + output): `INdiInterop.CreateFinder(string?)` and `CreateSender(string, string?)` populate `p_groups`; `IsoController` threads them through from `EngineConfig.NdiGroups`.
- `ParticipantTracker` surfaces `NdiSourceKind.ActiveSpeaker` as a synthetic routable row named "Active Speaker" with a deterministic v5-GUID Id derived from `auto-mix:<machine>`.
- `IsoHealthStats` wired end-to-end: live receiver/sender/processor refs published from the inner pipeline, frame counters / source resolution / running FPS (30-frame moving window) / drops + duplicates / pipeline state surfaced via `IsoController.GetStats`.
- Rolling daily file logging at `%LOCALAPPDATA%\TeamsISO\Logs\` via Serilog.Sinks.File.
### UI
- WPF rebuilt around Wild Dragon brand × Microsoft Teams flush layout — left rail with real dragon-mark logo (clickable → About dialog), chromeless title bar with custom min/max/close caption controls, cyan accent.
- Inter Variable + JetBrains Mono Variable bundled as `<Resource>` so typography matches wilddragon.net regardless of system fonts.
- App icon `teamsiso.ico` (7 sizes) on taskbar / window / About / WiX MSI ARP.
- Single-instance enforcement via per-user named Mutex with broadcast bring-to-front.
- Empty-state placeholder when no Teams sources are visible (faded dragon + checklist).
- Live frame counters in the Source / Live columns (in/out/drops, source resolution, running FPS).
- Per-pipeline state surfaced in the ISO toggle: `● LIVE` (cyan), `● ERROR` (coral), `● NO SIGNAL` (amber), `…` (processing).
- "Stop all ISOs" emergency button at the participants header.
- Hide-(Local) toggle so the user's own self-preview is filtered from the participants list.
- Window position / size / state persisted to `%LOCALAPPDATA%\TeamsISO\window.json`, multi-monitor safe.
- Tooltips on every interactive control in the settings panel + per-row textbox + ISO toggle.
- Toast feedback for settings actions (Apply / Apply Transcoder Topology / Stop All / Auto-disable).
- **Auto-disable on participant departure** (configurable, off by default): when a participant's NDI source disappears the engine tears down their pipeline; the toggle lives in `DISPLAY` settings.
- **Operator presets**: chromeless `Presets…` dialog from the participants header. Saves the current per-participant `IsEnabled` + `CustomName` set keyed by display name to `%LOCALAPPDATA%\TeamsISO\presets.json` (atomic write, schema-versioned). Apply walks the live participants and reconciles via `EnableIsoAsync` / `DisableIsoAsync`; participants in the preset who aren't in the current meeting are reported in the toast.
- **Auto-apply last preset on launch**: opt-in checkbox in `DISPLAY` settings. After the operator's first manual Apply, every subsequent TeamsISO launch silently re-applies the same preset once participants populate (30-second grace window before applying with whoever's online). State lives in `presets.json` next to the preset list.
- **Refresh discovery** affordance: header pill that rebuilds the underlying NDI finder on the next poll tick. `IIsoController.RefreshDiscovery` flips a flag the discovery loop honors before the next tick — old finder disposed, new finder created, seen-set cleared so all currently-visible sources re-fire as Added. `ParticipantTracker.HandleAdded` is idempotent: re-emitting the same FullName refreshes LastSeen rather than minting a duplicate row.
- **Settings tabs**: the settings sidebar is now a TabControl with `OUTPUT` / `NETWORK` / `DISPLAY` tabs and a single Apply Changes button below. Underline-on-active tab style lives in `WildDragonTheme.xaml` (`Wd.TabControl` + `Wd.TabItem`).
- **Crash diagnostics**: `App.OnStartup` wires `AppDomain.UnhandledException`, `Application.DispatcherUnhandledException`, and `TaskScheduler.UnobservedTaskException` into a unified Serilog.Critical log line + user-facing dialog that points at the log directory. Dispatcher exceptions are marked `Handled = true` so a single bad UI thunk doesn't take the app down; AppDomain crashes are terminal but at least the user gets the log path before exit.
- **First-launch onboarding**: chromeless welcome dialog walks users through the once-per-machine setup (NDI runtime, Teams admin permission, transcoder topology, presets, log location). Suppressed after dismissal via marker file at `%LOCALAPPDATA%\TeamsISO\onboarding.flag`. Re-openable from the About dialog via "Show welcome" button.
- **Reset output to defaults**: ghost button at the bottom of the OUTPUT settings tab restores framerate / resolution / aspect / audio to `FrameProcessingSettings.Default` after confirmation. Doesn't touch NDI groups (sticky per-machine) or display toggles.
- **Per-output recording**: `IRecorderSink` interface + `RawBgraRecorderSink` implementation. When the operator enables "Record ISOs to disk" in the DISPLAY tab, each newly-enabled ISO writes its normalized output to `<chosen-dir>/<participant>/video.bgra` plus a sidecar `manifest.json` (width / height / fps / frame counts) and a `convert.cmd` one-liner that pipes the raw stream into FFmpeg to produce a final H.264 `output.mkv`. Recorder runs on its own bounded queue (240-frame `DropOldest` buffer) so disk pressure never blocks the live ISO; recorder failures are caught and ignored at the channel-write layer for the same reason. Already-running ISOs are not retroactively captured — operator disables + re-enables to start recording. Recording can be wired to a real-time H.264 encoder later via Vortice.MediaFoundation; the `IRecorderSink` interface is designed to swap implementations without touching the pipeline.
- **REST control surface**: `ControlSurfaceServer``System.Net.HttpListener` on `127.0.0.1:9755` (configurable). Endpoints for participant ISO toggle (by Id or display name), refresh discovery, stop-all, recording on/off, preset apply, and Teams in-call commands (mute / camera / share / leave / raise-hand). Off by default; toggle in the DISPLAY tab. Bitfocus Companion / Stream Deck plugins / OSC bridges drive it. Documented at `docs/CONTROL-SURFACE.md`.
- **PresetApplier**: extracted from `PresetsDialog.OnApply`. Single source of truth for "apply this preset to live participants" — used by the dialog, by `MainViewModel.TryAutoApplyPendingPreset` (auto-apply on launch), and by the REST `POST /presets/{name}/apply` endpoint. Marshals UI-bound writes (CustomName / IsEnabled) through an optional Dispatcher so off-thread callers don't crash WPF.
- **In-app preview thumbnails**: 160×90 WriteableBitmap per participant, fed from the engine's most recent `ProcessedFrame` at the existing 1Hz stats tick. Inline nearest-neighbor scaler in `ParticipantViewModel.UpdateThumbnail` writes directly into the bitmap's pinned BackBuffer (unsafe block, `<AllowUnsafeBlocks>true</AllowUnsafeBlocks>` in the .csproj) for ~10× perf vs. going through Span<byte>. Falls back to a `—` placeholder card when no pipeline is running. New "Preview" column in the participants DataGrid.
- **WebSocket live state push**: `ws://127.0.0.1:9755/ws` — clients connect, receive a participants snapshot immediately, and get fresh snapshots within 250ms whenever state changes. Snapshot diffing on JSON string keeps the wire quiet during steady-state. Used by Stream Deck / Companion buttons that want to light up when an ISO goes LIVE without polling.
- **OSC bridge over UDP**: `OscBridge` listens on `127.0.0.1:9000` (TouchOSC's default). Same command vocabulary as the REST endpoints — `/teamsiso/iso "Jane" 1`, `/teamsiso/preset "Friday Show"`, `/teamsiso/teams/mute`, etc. Minimal OSC 1.0 parser (int / float / string / T / F type tags; no bundles). TouchOSC layouts and Companion's Generic OSC surface can both drive it directly.
- **Manual update check**: "Check for updates" button in the About dialog. Asks `forge.wilddragon.net/api/v1/repos/zgaetano/teamsiso/releases?limit=1`, compares the newest tag's SemVer to the running version, prompts to open the releases page if newer. Manual only — no background polling for v1 so a long-running show doesn't get interrupted by a surprise installer.
- **Auto-update banner on launch**: opt-in (default on) silent check throttled to once per 24h via `%LOCALAPPDATA%\TeamsISO\last-update-check.txt`. When a newer release is found, a non-modal banner appears above the body with "Get update" / "Dismiss" buttons. Suppression via flag file at `no-update-check.flag` for fleets that prefer central rollout. New `UpdateBannerViewModel` distinct from the engine alert banner.
- **Preset import / export**: Export / Import buttons in the Presets dialog footer, backed by `OperatorPresetStore.ExportAllAsJson` / `ImportBundle`. Bundle format is `teamsiso-presets-bundle/v1` JSON. On name collision the importer asks once (Overwrite/Keep/Cancel) rather than per-preset; deliberately doesn't include the operator's `LastAppliedName` / `AutoApplyOnStartup` since those are machine-local.
- **Recording markers**: `IRecorderSink.AddMarker(label)` plus `IIsoController.AddRecordingMarker(label)` fan-out to every active recorder. Surfaced via "Marker" button in the IN-CALL bar (auto-labels with timestamp), `POST /recording/marker` in the REST surface, and `/teamsiso/recording/marker "Label"` in OSC. Markers land in `manifest.json` under `markers[]` with `offsetMs` + `label` fields for post-production chaptering.
- **Custom NDI output name template**: `OutputNameTemplate` static helper persisted to `output-name-template.txt` with `{name}` / `{guid}` / `{machine}` / `{timestamp}` tokens. Default `TEAMSISO_{guid}` preserves the engine's hard-coded behavior; operator can switch to `TEAMSISO_{name}` for human-readable downstream switcher names. UI editor in the NETWORK settings tab.
- **Enriched footer status bar**: rec badge (coral dot + count) when at least one ISO is being recorded; control-surface badge (cyan dot + "REST :9755 + OSC :9000") when those services are running. Computed at the existing 1Hz stats tick from `IIsoController.RecordingEnabled` × running pipeline count and `App.ControlSurface.IsRunning` / `App.OscBridge.IsRunning`.
- **Disk space watcher**: `DiskSpaceWatcher` polls the recording drive every 5s while recording is on. Coral toast at <10GB free; auto-disables recording at <1GB so an unattended long show doesn't crash the host on disk-full.
- **Diagnostic bundle export**: "Export diagnostics" button in About zips logs + config + presets + window state + version metadata into a `teamsiso-diagnostics-<ts>.zip` in `~/Downloads`. Excludes screenshots / memory dumps; only files the user already wrote.
- **Per-participant recording opt-out**: new `Rec` column in the DataGrid lets the operator choose which ISOs get recorded when global recording is on. `IIsoController.EnableIsoAsync` gained an optional `bool? recordOverride` parameter — null = follow global flag, true = force on, false = force off.
- **Window-scoped keyboard shortcuts**: F1 (help), Ctrl+M (drop marker), Ctrl+Shift+S (stop all), Ctrl+R (refresh discovery). InputBindings on MainWindow → MainViewModel commands; F1 opens the new `HelpWindow` cheat sheet.
- **Help cheat sheet**: chromeless `HelpWindow` lists keyboard shortcuts, file locations (`%LOCALAPPDATA%\TeamsISO\Logs\`, `%APPDATA%\TeamsISO\config.json`, etc.), and links to the public docs. Reduces support friction.
- **Bulk enable**: header `Enable all` button (green dot) enables ISOs for every online + non-enabled participant. Per-participant best-effort with a count toast.
- **Live participant filter**: textbox above the DataGrid filters by display-name substring as you type. Backed by an `ICollectionView` Filter callback so the underlying `ObservableCollection` isn't mutated (preserving identity-tracking).
- **Right-click context menu** on participant rows: Toggle ISO, toggle Record-this-participant, Copy NDI source name to clipboard. Uses the existing per-row commands so the menu is just another binding surface.
- **CLI: `--apply-preset NAME`**: launch-time flag that auto-applies the named preset once participants populate. Same code path as the persisted auto-apply preference. Useful for `Friday Show.lnk` desktop shortcuts that drive recurring routings.
- **Dynamic status text**: footer's center text now reads "3/5 ISOs live · 2 recording" once routing starts, instead of the static "Engine running at X fps target." Composed in `OnStatsTick` from running participant + recording counts.
- **Embedded HTML control panel** at `GET /ui`: self-contained ~6KB page with WebSocket-driven live state and buttons for the common control actions. Open in a phone or second-monitor browser to drive TeamsISO without context-switching from the show. No external dependencies, no build step.
- **Session timer** in footer: shows `MM:SS` (or `HH:MM:SS` past an hour) elapsed since the first ISO went live this session. Resets when all ISOs go offline. Green dot indicator for at-a-glance status.
- **Show notes service**: `POST /notes` and `/teamsiso/notes "..."` (OSC) append timestamped lines to `%LOCALAPPDATA%\TeamsISO\Notes\<YYYY-MM-DD>.md`. Operators wire a Stream Deck button to drop notes during a live show without leaving the production app. Markdown format renders cleanly in any editor.
- **NotesWindow inline viewer**: chromeless dialog that displays today's notes file with 2s polling so REST/OSC-driven appends surface live. "Notes" button in the IN-CALL bar.
- **Duplicate-preset action**: "Duplicate" footer button in the Presets dialog. Custom inline prompt suggests `<original> (copy)` / `(copy 2)` / etc. names.
- **CHANGELOG.md**: project-wide changelog following keep-a-changelog format. Captures the full May 2026 batch under `[Unreleased]`.
- **README rewrite**: top-level README now lists what TeamsISO does, build instructions, doc links, keyboard shortcuts table, file-locations table.
- **Confirm-before-Stop-All**: stop-all button now requires Yes confirmation, preventing accidental mid-show clicks. Default-No so Enter cancels.
### Networking automation
- One-click **transcoder topology** button in Settings: writes `%APPDATA%\NDI\ndi-config.v1.json` so all local senders broadcast on `teamsiso-input` and local receivers see both `public` + `teamsiso-input`. Engine settings auto-flip to receive-from `teamsiso-input` and emit-on `public`. Atomic write with timestamped backup of the prior config.
### Phase E — embedded Teams orchestration
Spec at `docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md`. All three sub-phases shipped in May 2026:
- **E.1 — Launcher.** Rail "Launch / Stop Teams" toggle: launches via `ms-teams:` URI → `ms-teams.exe` → classic `Update.exe --processStart`, asks to confirm `WM_CLOSE` of all running Teams windows when toggled while Teams is up.
- **E.2 — Window orchestration.** Rail eye-icon button hides every visible top-level Teams window via `EnumWindows` + `ShowWindow(SW_HIDE)`. Click again to restore + foreground. Lets the operator drive Teams from TeamsISO without ever seeing the Teams UI.
- **E.3 — In-call controls.** UIAutomation-driven Mute / Camera / Share / Leave buttons in a new `IN-CALL` card at the top of the participants area. `TeamsControlBridge` walks Teams' automation tree by candidate Name list (`Mute`, `Unmute`, `Microphone`, `Toggle mute` …) and tries Invoke or Toggle pattern. Tolerant lookup: when a Teams update renames a button we extend the candidate list, no crash. Toasts reflect the four outcomes (Invoked / TeamsNotRunning / ControlNotFound / InvokeFailed). Bridge also exposes (UI-not-yet-wired) `ToggleRaiseHand`, `ToggleChat`, `OpenBackgroundEffects`. Candidate name lists localized for English/German/Spanish/French/Portuguese/Japanese — all locales matched in a single pass; the first match wins.
- **PostMessage shortcut forwarding fallback.** `TeamsLauncher.SendShortcut(modifiers, vk)` posts WM_KEYDOWN/UP to the most-recently-used hidden Teams HWND. Best-effort — modern WebView2-hosted Teams sometimes ignores synthesized key messages at the app-shortcut layer; UIA is preferred when a button exists for the action.
### Diagnostics
- `TeamsISO.Console --list-sources` enumerates raw NDI source names visible to the local finder for ~5 seconds; debugging tool for setup issues.
- `TeamsISO.Console --version` prints engine version + build SHA + .NET + OS + NDI runtime banner + exit-code legend, for support tickets.
- About dialog inside the WPF host with the same info.
### CI / Release / Docs
- Forgejo CI is green: `actions/upload-artifact@v3` (Forgejo doesn't support v4 yet).
- `.forgejo/workflows/release.yml`: tag-push (`v*.*.*`) builds + tests + publishes + builds the MSI on a Windows runner and attaches it to the auto-created Forgejo release via the REST API.
- **Optional code-signing** wired into `release.yml`: when `SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD` Forgejo secrets are set, the workflow signs both `TeamsISO.exe` (before MSI build) and the MSI (after) with SHA-256 + RFC 3161 timestamp. Skipped silently when the cert isn't configured. `docs/RELEASING.md` documents the OV vs EV trade-offs and the Azure Trusted Signing migration path.
### Tests
- 78 unit tests passing; 9 NDI integration tests gated behind `--filter requires=ndi` (runtime probe, finder + sender lifecycle on default and custom groups, loopback discovery, full pipeline frame round-trip asserting 1080p normalization).
## Done since May 10 hand-off
### Engine
- **Audio peak metering wired end-to-end.** `IsoHealthStats.PeakAudioLevel`
now reports real values from a sibling NDI audio capture loop in
`NdiReceiver`. New `INdiInterop.CaptureAudioPeak` method (default-
implemented for FakeNdiInterop, overridden in NdiInteropPInvoke).
`AudioPeakComputer` handles FLTP / FLT / PCM s16 with 14 unit tests
covering edge cases. UI VU bars in the participants DataGrid now
animate; the existing decay logic in `ParticipantViewModel` was
already in place waiting for real values.
### Control surface
- **LAN-reachable mode.** New checkbox in DISPLAY tab toggles whether the
REST/WebSocket surface and OSC bridge bind to `127.0.0.1` only or to all
interfaces (`http://+:port/`, `IPAddress.Any`). Settings panel surfaces
the routable URL with a Copy button (picker prefers physical NICs and
skips Tailscale / VPN tunnels / APIPA addresses). Use case: headless
host PC + thin client on the same LAN — operator runs Teams + TeamsISO
on a quiet machine, drives it from anywhere on the production network.
No auth — documented as a trusted-LAN-only mode. First-time bind
requires a one-shot `netsh http add urlacl`; the diagnostic warning
fires the exact remediation command if the bind fails.
### "I only see TeamsISO" — Phase E.1+E.2 follow-ups
- **Launch + auto-hide Teams** preferences in DISPLAY tab. Teams runs in the
background; window appears briefly then hides automatically; operator
drives everything from the IN-CALL bar + participants DataGrid.
- **Quick-join from URL** in the IN-CALL bar. Paste a Teams meeting link,
click Join, Teams launches into the meeting. Eliminates the open-Teams
→ Calendar → find → click join dance.
- **Teams meeting state pill**`IN CALL · <meeting title>` / `READY` /
empty. UIA probe at 1Hz for the Leave button; meeting title from
Teams' window title with the brand suffix stripped.
- **Launch Teams click semantics** — left-click = launch / surface / restore;
right-click = stop. Was previously ambushing operators with a stop-Teams
dialog when Teams was hidden via the eye-toggle.
- **Auto-record on meeting start** preference. Recording auto-flips ON when
Teams transitions into a call (UIA Leave button appears) and OFF when
the call ends — completes the unattended-show story.
- **MUTED / CAM OFF pills** in the IN-CALL bar via UIA — local-user state
visible at a glance without restoring Teams.
- **Phase E.4 (experimental) — Teams window embedding via SetParent.**
Reparents Teams' main window into a TeamsISO-owned host so Teams appears
visually INSIDE TeamsISO. WebView2 in modern Teams may render glitches
after reparent; if so operator unticks and falls back to auto-hide mode.
Live in `TeamsEmbedWindow` + `TeamsLauncher.EmbedTeamsInto` / `RestoreEmbed`.
- **Loudest sort mode** + **active speaker row highlight** (3px cyan left
border) — operators react to who's talking without scanning every VU bar.
- **NumPad 1-9 hotkeys** toggle Nth visible participant's ISO. Generic
`RelayCommand<T>` added so XAML CommandParameter strings convert cleanly.
- **Snapshot frame to PNG** (per-participant via right-click + bulk header
action). Saves under `%USERPROFILE%\Pictures\TeamsISO\`.
- **Recording drive free space** in the footer (`· 245 GB free`). Coral
tint below 10GB; existing DiskSpaceWatcher still auto-disables at 1GB.
- **Recording elapsed duration** in the footer next to the count
(`REC 3 · 12:45`).
- **Quick-join meeting URL** + **IN-CALL pill with meeting title** for the
headless workflow — paste link, click Join, see what meeting you're in.
### UI polish
- Visible hover affordances on every themed button (Ghost / Caption /
RailIcon / IsoToggle / Primary). Cyan accent borders + brighter fills
so mouse-hover and tab-focus give an unmistakable affordance regardless
of which dark surface the button sits on.
- Keyboard focus rings (`IsKeyboardFocused` triggers) so tab-cycling
through the UI gives visual feedback (was nothing — `FocusVisualStyle`
was `x:Null` with no replacement).
- ScrollBar restyled to slim transparent track + tinted thumb (Edge / VS
Code pattern) in place of the chunky Win9x default.
- ContextMenu / MenuItem styled to match the dark canvas — right-click on
a participant row no longer shows the cream-colored Notepad popup.
- ToolTip restyled: SurfaceElevated card with rounded corner + 320px
text wrap, replacing the cream Win98 popup.
- Wd.Button.Primary disabled state distinct (was identical to enabled).
## Next
1. **Smoke-test on real Teams.** Most of May's work hasn't run against a live meeting yet: the UIA in-call commands (mute / camera / share / leave) need their candidate-Name lists validated against the current Teams build, and the auto-apply-on-launch flow needs a real recurring meeting to confirm the 30-second grace window is right. Pin the AutomationIds for buttons we find — Name-based lookup is a starting point, AutomationId is what survives Teams UI updates. Now also includes: validate the audio peak metering against real Teams audio (check that FLTP decoding is correct for whatever sample rate Teams is broadcasting; the `--filter requires=ndi` integration tests don't exercise audio).
2. **Acquire a code-signing cert.** Pipeline is wired (see "CI / Release / Docs" above); just needs `SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD` set in Forgejo Secrets. OV cert (~$200/yr) gets us signed but SmartScreen builds reputation slowly; EV cert (~$300/yr, hardware token) is SmartScreen-trusted immediately. Azure Trusted Signing is the cloud-native path if a token-on-runner is fiddly.
3. **Port MediaFoundationRecorderSink to Vortice 3.6.2 API.** NuGet package added but the May 9 scaffold targeted an older Vortice API. Port pass needed before `MF_AVAILABLE` can be defined; see `docs/REAL-TIME-RECORDING.md` "Status — May 2026" section for the specific API gaps (MFVersion / MF_LOW_LATENCY / IMFMediaType setters / IMFMediaBuffer.Lock signature / IMFSinkWriter.Finalize_ rename). Once ported, gives ~10× recording disk-pressure reduction.
4. **Forward Teams keyboard shortcuts via SendInput.** Phase E.2 hides the Teams window but doesn't forward Ctrl+Shift+M / Ctrl+Shift+O / Ctrl+Shift+H to it. UIA covers mute/camera/share/leave/raise-hand/chat/background already; SendInput would let us pass arbitrary global hotkeys through to a hidden Teams for actions UIA can't reach. Lower priority now that UIA covers the core actions.

View file

@ -1,213 +0,0 @@
# TeamsISO v1.0 — Implementation Spec
**Status:** Draft, ready for plan-writing
**Date:** 2026-05-07
**Owner:** Zac Gaetano (Wild Dragon LLC)
**Source design doc:** `TeamsISO Design Document.docx` v0.1 DRAFT (May 2026)
This spec turns the source design document into an implementable plan for the v1.0 release. The product vision, problem statement, and feature matrix in the source document remain authoritative; this spec adds the architectural and operational decisions needed to start building.
## 1. Scope
v1.0 ships the feature set in §6 of the source document, exactly as written:
- NDI participant discovery (auto)
- Per-participant ISO NDI output
- Global framerate lock (23.976 / 24 / 25 / 29.97 / 30 / 50 / 59.94 / 60 fps)
- Global resolution normalize (720p / 1080p / 4K)
- Custom output stream naming
- Isolated audio per ISO with mixed-audio fallback
- Screen share as ISO output
Deferred to v1.5: per-stream framerate override, thumbnail previews, GPU-accelerated scaling.
Deferred to v2.0: multi-machine cluster coordination, OSC/WebSocket control API.
Out of scope for v1.0: automatic peer discovery between TeamsISO instances, audio resampling, code signing of the installer.
## 2. Architecture
**Pattern:** engine/UI separation from day one. The NDI engine is a class library with no UI dependency; the WPF app is a thin host that binds to the engine through a typed C# API. v2.0's control APIs and multi-machine coordinator drop in cleanly because the boundary already exists.
**Solution layout:**
- `TeamsISO.Engine` — class library. Discovery, receive, frame processing, send, configuration, logging abstraction. Exposes `IIsoController` and observable streams. Owns all threading.
- `TeamsISO.Engine.NdiInterop` — internal P/Invoke shim for `NDIlib_*` and libyuv. Kept separate so the rest of the engine speaks managed types and unit tests can fake the interop surface.
- `TeamsISO.App` — WPF + MVVM host. Instantiates the engine, binds view models to engine observables, persists window layout. Zero NDI knowledge.
- `TeamsISO.Engine.Tests` — xUnit unit tests against `FakeNdiInterop`. Pure managed.
- `TeamsISO.Engine.IntegrationTests` — xUnit integration tests against the real NDI runtime. Tagged `[Trait("requires", "ndi")]`.
- `TeamsISO.Installer` — WiX v5 project producing the MSI.
**Engine ↔ App contract:** `IIsoController` exposes `IObservable<IReadOnlyList<Participant>>`, `IObservable<IsoHealthStats>` per output, `IObservable<EngineAlert>`, and async command methods (`EnableIsoAsync`, `SetTargetFramerate`, `SetCustomName`, `SetGlobalSettings`, etc.). All commands are cancellable.
## 3. Domain model
Defined in `TeamsISO.Engine.Domain`. All types are immutable records unless noted.
- **`NdiSource`** — raw discovery record. `string FullName`, parsed `MachineName`, `Kind` (`Participant | ActiveSpeaker | Audio | ScreenShare`), `DisplayName` (null for non-participant kinds).
- **`Participant`** — operator-facing identity. `Guid Id` (engine-assigned, stable across rename heuristic), `string DisplayName` (last seen), `NdiSource? CurrentSource`, `DateTimeOffset FirstSeen / LastSeen`. Mutable via the engine; observable.
- **`IsoAssignment`** — operator's intent. `Guid ParticipantId`, `bool IsEnabled`, `string? CustomOutputName`. Persisted to `config.json`. Reserves room for v1.5 per-stream overrides.
- **`IsoOutput`** — runtime state. `Guid ParticipantId`, `string EffectiveOutputName`, `IsoHealthStats Stats`, `IsoState State` (`Idle | Receiving | Sending | NoSignal | Error`).
- **`FrameProcessingSettings`** — `TargetFramerate`, `TargetResolution`, `AspectMode` (`Pillarbox | Letterbox | Stretch`), `AudioMode` (`Isolated | Mixed | Auto`).
- **`IsoHealthStats`** — `FramesIn`, `FramesOut`, `FramesDropped`, `FramesDuplicated`, `LastFrameAt`, `IncomingFps`, `IncomingResolution`.
- **`EngineConfig`** — root persisted record: `FrameProcessingSettings Global`, `IReadOnlyList<IsoAssignment> Assignments`. Stored at `%APPDATA%\TeamsISO\config.json`.
- **`EngineAlert`** — discriminated union: `NdiRuntimeMismatch | OutputNameCollision | PipelineError | ConfigSaveFailed`.
**Participant identity across rename / disconnect.** Teams source strings change when a participant renames. Engine policy: if a source disappears and within 5 seconds a new participant source with the same `MachineName` appears, the engine transfers the existing `Participant.Id` (and any `IsoAssignment` bound to it) to the new source. The UI shows a brief rename toast. Operators can opt out per-meeting in settings.
## 4. Components
Eight subsystems inside `TeamsISO.Engine`. Each has one responsibility.
**`NdiDiscoveryService`** — owns one `NDIlib_find_create_v2` instance on a long-running background thread. Polls every ~500 ms, diffs the source list, classifies each source, pushes `DiscoveryEvent` (`Added | Removed | Renamed`) onto a `Channel<DiscoveryEvent>`.
**`ParticipantTracker`** — consumes `DiscoveryEvent`s, applies the rename heuristic, maintains the canonical `IObservable<IReadOnlyList<Participant>>`. Stateful, pure-managed, unit-testable without NDI.
**`IsoPipeline`** — per-ISO unit. Owns one receiver, one frame processor, one sender, all health stats. Lifecycle methods `Start`, `Stop`. Created by `IsoPipelineFactory` when the operator enables an ISO.
**`NdiReceiver`** — wraps `NDIlib_recv_create_v3`. Dedicated thread loops on `NDIlib_recv_capture_v3`. Pushes captured frames into a bounded `Channel<RawFrame>` (capacity 4, drop-oldest under backpressure). Records dropped-frame count.
**`FrameProcessor`** — driven by `PeriodicTimer` at the target framerate. At each tick: read newest frame from the channel non-blocking; if available, scale via libyuv to target resolution + aspect mode, recalculate timecodes, hand to sender; if unavailable, re-emit `lastFrame`; if `lastFrame` is older than 2.5 s, emit a no-signal slate (`SolidFrameRenderer`, mid-grey).
**`NdiSender`** — wraps `NDIlib_send_create`. Dedicated thread sends video on its tick and audio passthrough on its own queue. Audio mode `Auto` probes for isolated audio at startup and falls back to mixed if unavailable.
**`IsoController`** — top of engine. Holds the pipeline dictionary, the `ParticipantTracker`, the `ConfigStore`. Exposes the `IIsoController` API. Translates "operator enabled this participant" into pipeline creation and start.
**`ConfigStore`** — load/save `EngineConfig` to `%APPDATA%\TeamsISO\config.json`. Atomic writes via temp file + rename.
**Logging:** Serilog file sink at `%APPDATA%\TeamsISO\logs\teamsiso-{Date}.log`, 14-day retention, structured. Engine code logs through `ILogger<T>` from `Microsoft.Extensions.Logging`.
## 5. Data flow and threading
Per ISO:
```
NDI source on LAN
[Capture thread] (1 dedicated thread)
NDIlib_recv_capture_v3, blocking loop
▼ Channel<RawFrame> (capacity 4, drop-oldest)
[Processor tick] (PeriodicTimer on ThreadPool, target framerate)
pick newest frame → libyuv scale/aspect → retimecode
▼ ProcessedFrame
[Send thread] (1 dedicated thread)
NDIlib_send_send_video_v2 + audio
ISO output on LAN
```
System-wide threads at 3 active ISOs: 3 capture + 3 send (dedicated, blocking-friendly), 1 discovery, 1 participant-tracker async loop on ThreadPool, 1 UI dispatcher, processor work on ThreadPool. Approximately 9 dedicated threads plus ThreadPool work — within budget for the recommended hardware.
**Why dedicated threads for capture and send:** NDI capture and send calls block. Mixing them onto the .NET ThreadPool risks starving worker threads. Processing is short-lived per frame and fits the ThreadPool model.
**Frame timing strategy (closest-frame):** simple, deterministic, works across all supported framerates without interpolation. Frame duplication = re-send `lastFrame`. After 2.5 s of no incoming frames, slate.
**Audio:** v1.0 forwards audio passthrough on its own NDI queue, no resampling. Isolated audio is forwarded as-is when available; mixed audio is forwarded on the active-speaker stream only as fallback.
**Cancellation:** every loop respects a per-ISO `CancellationToken`. Stopping an ISO triggers cancellation, joins capture and send threads (1 s timeout), disposes NDI handles.
## 6. Error handling and recovery
**Pipeline isolation.** Each `IsoPipeline` runs independently. One pipeline failing never affects others.
**Per-pipeline failure recovery.** Unhandled exception → pipeline transitions to `Error`, releases NDI handles, logs with full context, auto-restarts after 1 s. Exponential backoff: 1, 2, 4, 8, 16 s, capped at 30 s. After 5 consecutive failures, stays `Error` and waits for operator action. Participant remains visible in the UI list so the operator can re-enable manually.
**Source disconnect (expected, not error).** Pipeline transitions to `NoSignal` after 2.5 s, keeps the assignment bound, keeps emitting the slate. If the source returns within 60 s, reconnects automatically. After 60 s the pipeline stops the sender to free NDI bandwidth; reconnects when the source reappears.
**NDI runtime version mismatch.** Detected at startup by `NdiRuntimeProbe`. Surfaces `EngineAlert.NdiRuntimeMismatch`. UI shows a banner with instructions to re-download Teams' NDI binaries (per source doc §7.2). Engine still attempts to run — it's a warning, not a hard fail.
**Output name collision on the LAN.** Logged and surfaced as `EngineAlert.OutputNameCollision`. v1.0 does not auto-rename; the operator picks unique names.
**Startup preflight.** Run before the UI accepts commands:
- NDI runtime present and queryable
- Smoke test: create + destroy one `NDIlib_send_create` instance
- Config file readable; corrupt or missing → fall back to defaults and log
- libyuv DLL loadable
- Write access to `%APPDATA%\TeamsISO\`
A failing preflight surfaces a single error dialog with a copyable diagnostic string; the app does not enter the main UI.
**Engine alert channel.** `IObservable<EngineAlert>` exposes structured alerts to the UI for banner display and to the log for ops.
## 7. Testing
**Three layers, three test projects.**
**Unit (`TeamsISO.Engine.Tests`)** — pure managed, no NDI runtime, fast (<1 s). Covers:
- `ParticipantTracker` rename heuristic (synthetic event streams).
- `FrameProcessor` timing logic against fake clock and fake interop. Asserts: 30 fps target / 24 fps incoming yields 30 frames/s with appropriate duplication; 60 fps target / 30 fps incoming doubles each frame; 2.5 s of silence triggers slate.
- `IsoPipeline` lifecycle (start → run → stop → restart on simulated fault, with backoff schedule asserted).
- `ConfigStore` round-trip (missing → defaults; save → reload identical; corrupt JSON → defaults + log).
- `NdiSourceParser` against a corpus of real Teams source strings (participant, active speaker, audio, screen share, multi-word names with parens, unicode).
**Integration (`TeamsISO.Engine.IntegrationTests`)** — Windows-only, real NDI runtime. Tagged `[Trait("requires", "ndi")]`.
- Spin up a NewTek NDI Test Pattern source as a synthetic participant; route through `IsoPipeline`; receive on a second NDI receiver; assert output stream existence, naming, framerate (measured over 5 s), resolution.
- Source disappear / reappear: stop the test pattern source mid-stream, assert pipeline transitions through `NoSignal`, restart the source, assert pipeline resumes.
- Output name collision: spin two pipelines with the same name, assert `EngineAlert.OutputNameCollision`.
**Manual / live test playbook (`docs/test-playbook.md`)** — checklist for verifying against real Teams meetings before each release.
**TDD discipline.** Every behavior in the engine starts as a failing unit test against fakes. NDI interop has an `INdiInterop` interface; production wires `NdiInteropPInvoke`, tests wire `FakeNdiInterop`.
**Coverage target.** 80% line coverage on `TeamsISO.Engine`, excluding the P/Invoke shim. Enforced in CI.
## 8. Build, packaging, distribution
**Source repo.** `forge.wilddragon.net/zgaetano/teamsiso`. Default branch `main`. Trunk-based with feature branches; PR review for engine-touching changes.
**Build.** MSBuild via `dotnet build` and `dotnet publish`. Solution targets `net8.0-windows` with `TargetPlatformVersion=10.0.19041.0`. `TeamsISO.App` publishes self-contained, single-file, ReadyToRun:
```
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishReadyToRun=true
```
**CI.** Forgejo Actions (GitHub-Actions-compatible). Two pipelines:
- `ci.yml` — every push and PR. Builds, runs unit tests, enforces coverage threshold, lints (treat-warnings-as-errors). Linux runner. Integration tests skip cleanly because `requires=ndi` is absent.
- `release.yml` — on tag push (`v*`). Windows runner with NDI runtime preinstalled. Builds release, runs unit + integration, builds WiX installer, attaches `.msi` to a Forgejo release.
**Versioning.** SemVer in `Directory.Build.props`. Flows to assembly metadata and installer. Tag `v1.0.0` triggers the release pipeline.
**Installer (WiX v5).** Produces `TeamsISO-x.y.z.msi`. Behavior:
- Detects NDI runtime via registry probe; if absent or older, prompts the operator to download from `ndi.video/tools/`. The runtime is not bundled — NDI's redistribution license requires user consent.
- Installs to `%ProgramFiles%\TeamsISO\`.
- Creates Start Menu shortcut, optional desktop shortcut.
- `%APPDATA%\TeamsISO\` is created on first run, not at install (per-user data, per-machine MSI).
- Adds Add/Remove Programs entry.
**NDI redistribution.** Per NDI SDK License v5 the runtime is not bundled. Detection is by registry key. Mismatches show a dialog with the official download link. Captured open task: legal review of NDI SDK License v5 before public v1.0 release.
**Distribution.** v1.0 ships as MSI from Forgejo releases. No auto-update in v1.0. The About dialog shows the current version and links to the Forgejo releases page.
## 9. Open tasks blocking v1.0 release
- Legal review of NDI SDK License v5 (per source doc §7.3) — required before public release; not required for development.
- Confirmation that the Microsoft Teams tenant has the admin policy enabling NDI broadcast (the relevant Teams meeting-policy setting; current name varies by Teams admin center version — verified against the live tenant during development).
- Selection of code-signing approach for v1.0 vs. v1.5 (currently deferred).
## 10. Out of scope for v1.0 (deferred)
- Per-stream framerate override (v1.5)
- Thumbnail previews (v1.5)
- GPU-accelerated frame scaling (v1.5)
- Multi-machine cluster auto-coordination (v2.0)
- OSC / WebSocket control API (v2.0)
- Code signing of the installer
- Auto-update
- Audio resampling
## 11. Glossary
- **NDI** — Network Device Interface (Vizrt/NewTek). LAN video transport protocol used by Teams' broadcast mode.
- **ISO** — In live production, an "isolated" feed of a single source, separate from the program mix. ZoomISO and TeamsISO produce per-participant ISO feeds.
- **Active speaker** — Teams' auto-mixed feed that follows whoever is talking. A separate NDI source from individual participant streams.
- **Slate** — a static frame (typically a solid color or "no signal" graphic) emitted when the source has stopped delivering frames.

View file

@ -1,106 +0,0 @@
# Spec: Embedded Teams meeting orchestration
**Status:** Draft. Authored 2026-05-08.
## Problem
Operators currently run two apps side by side: Microsoft Teams (which broadcasts
NDI from its meetings) and TeamsISO (which consumes those NDI sources, normalizes
them, and re-emits clean ISOs). Two issues fall out:
1. **Two interfaces, one workflow.** Switching between Teams to drive the meeting
and TeamsISO to drive ISO routing is friction during a live show.
2. **Teams' raw NDI bleeds into the production network.** Even with
TeamsISO running, Teams broadcasts its at-source-resolution / at-source-framerate
feeds on the same `Public` NDI group that switchers and recorders subscribe to.
Operators see "garbage" NDI sources alongside the clean TeamsISO outputs unless
they manually configure NDI groups (which most don't).
The user's stated north star: **let me host the meeting from inside TeamsISO. Run
Teams in the background. Show me one interface; expose only the proper outputs.**
## Constraints
- Microsoft Teams' NDI broadcast feature is desktop-only — the web client does not
broadcast NDI. We cannot replace Teams with a WebView2 view of `teams.microsoft.com`.
- We do not have a native Teams SDK. The Microsoft Graph API exposes some meeting
control (create/join/end), but in-call operations (mute, share, react) are largely
out of scope or behind enterprise tenant configuration.
- Win32 window embedding (`SetParent`) of a foreign process's window is technically
possible but produces a fragile UX — Teams will break out, render incorrectly, or
fail to honor parent-window inputs.
- NDI group routing is the standard primitive for hiding noisy producers. We
shipped this in commit `909237f`. It works.
## Architecture
A three-phase rollout. Each phase is shippable on its own.
### Phase E.1 — Teams launcher (launches Teams as a subprocess)
The minimum viable embed. TeamsISO grows a "Launch Teams" affordance on the rail.
Clicking it:
1. Reads the global `NdiGroupSettings.DiscoveryGroups` from `EngineConfig`. If
empty, defaults to `teamsiso-input`.
2. Opens **NDI Access Manager** (or programmatically writes its config) so Teams
broadcasts on `teamsiso-input` rather than `Public`.
3. Launches `ms-teams:` URI (or the `MSTeams.exe` directly for the new client) in
the background.
4. Marks Teams as "owned by TeamsISO" — the rail icon flips to "Stop Teams"; on
click, sends WM_CLOSE to the Teams main window.
5. Surfaces meeting health in the existing engine-status pill (e.g. "Teams running
• 2 participants").
Implementation effort: **a few hours.** Pure WPF + ProcessStartInfo + a small
NdiAccessManagerHelper that reads/writes Teams' config.
### Phase E.2 — Window orchestration
Teams' main window is repositioned + minimized when launched, so the user's
foreground experience is the TeamsISO window. Optional:
- Pin Teams to a hidden virtual desktop with `IVirtualDesktopManager`.
- Forward keyboard shortcuts (mute, camera, share) from TeamsISO into Teams via
`SendInput` while Teams' window is hidden.
Implementation effort: **a day.** Mostly Win32 plumbing.
### Phase E.3 — Meeting controls in TeamsISO's UI
A "Meeting" panel in the left rail that shows the active call's participant list
(with their mute / video state) and exposes Join/Leave/Mute/Share controls. Two
ways to plumb this:
- **Microsoft Graph API for the chrome.** Auth as the user via OAuth (interactive
device-code flow), poll `/me/onlineMeetings` for active meetings, render in
TeamsISO's UI. In-call mute/cam state is not exposed via Graph as of writing —
Phase E.3 would surface participant *presence* but not mic/cam controls.
- **Teams' UI Automation tree.** Walk Teams' window with `UIAutomation` to read
call state. Brittle but usable; what other "Teams remote" tools do.
Implementation effort: **a week per route.** Recommend Graph for read paths,
UIAutomation for write paths.
## Out of scope (for now)
- Hosting the actual meeting media stack (audio/video render, mixer, network).
Teams owns this and we don't want to.
- Replacing Teams entirely with our own SIP/WebRTC stack. That's a different
product.
## Decision required
User to confirm:
1. Phase E.1 first (just launcher + group routing). Yes/no.
2. Whether to use `ms-teams:` URI launch or the new MSTeams.exe binary path
(`%LOCALAPPDATA%\Microsoft\WindowsApps\ms-teams.exe`).
3. Whether to ship NDI Access Manager config writes, or just document the manual
steps and trust the user to set them once.
## Implementation log
- 2026-05-08: First version of this spec drafted while user is asleep.
- 2026-05-08: Phase E.1 partial — "Launch Teams" rail button shipped (commit
pending). Group-routing automation deferred until user confirms approach.

View file

@ -1,34 +0,0 @@
# TeamsISO Manual Test Playbook
## Phase A — Engine foundation (CI)
- [ ] `dotnet build TeamsISO.Linux.slnf` succeeds with zero warnings.
- [ ] `dotnet test TeamsISO.Linux.slnf --filter "Category!=ndi&requires!=ndi"` passes.
- [ ] CI on Forgejo Actions is green at HEAD.
- [ ] Code coverage on `TeamsISO.Engine` is ≥80%.
## First Windows validation (after Phase B-2 ships)
Prerequisite: Windows 10/11 + NDI Runtime installed (https://ndi.video/tools/) + .NET 8 SDK.
- [ ] Clone the repo on the Windows machine: `git clone https://forge.wilddragon.net/zgaetano/teamsiso.git`.
- [ ] `dotnet build TeamsISO.sln --configuration Release` succeeds.
- [ ] `dotnet test --filter "requires=ndi"` passes against an NDI Test Pattern source (start the test pattern from the NDI Tools menu before running).
- [ ] Run `dotnet run --project src/TeamsISO.Console` — confirm the engine starts, version probe matches, and Ctrl+C exits cleanly.
## Live-meeting validation (after Phase C ships)
- [ ] Configure a Teams meeting with 3+ participants, with NDI broadcast enabled in Teams.
- [ ] `dotnet run --project src/TeamsISO.App` launches the WPF UI without an NDI runtime warning banner.
- [ ] Participants list populates within ~2 seconds of opening the app.
- [ ] Participant rename mid-meeting transfers the row's identity (the rename heuristic).
- [ ] Toggle ISO on for one participant. Confirm the named output appears in vMix / OBS / Studio Monitor on the same LAN.
- [ ] Change global framerate to 59.94 fps; click Apply. New ISOs honor the new rate.
- [ ] Disconnect one participant; confirm their ISO transitions to the no-signal slate within 2.5 s.
- [ ] Run for 30 minutes; check FramesDropped / FramesDuplicated counters in the engine log are reasonable.
## Pre-release checklist
- [ ] Legal review of NDI SDK License v5 complete (per spec §7.3).
- [ ] Code-signing decision confirmed (yes/no for v1.0).
- [ ] WiX installer produces a working MSI on a clean Windows machine.

View file

@ -24,12 +24,23 @@
Compressed="yes" Compressed="yes"
InstallerVersion="500"> InstallerVersion="500">
<SummaryInformation Description="TeamsISO — Per-Participant NDI ISO Controller for Microsoft Teams" <!--
Manufacturer="Wild Dragon LLC" /> SummaryInformation fields surface in File Explorer's "Details" tab and
in the Windows Installer "About" dialog. Description and Keywords are
what users see if they right-click the MSI before installing; Comments
is the longer copy that appears alongside the version in some
installer dialogs.
-->
<SummaryInformation
Description="TeamsISO — per-participant NDI ISO controller for Microsoft Teams. Splits each Teams participant into a normalized NDI source for vMix / OBS / Ross / hardware switchers."
Manufacturer="Wild Dragon LLC"
Keywords="NDI, Microsoft Teams, ISO recording, broadcast, live production, vMix, OBS, switcher, Wild Dragon" />
<!-- <!--
MajorUpgrade: a newer install replaces an older one in-place. MajorUpgrade: a newer install replaces an older one in-place. We
Disallow downgrades; users should uninstall the newer first. disallow downgrades because the engine config schema only carries a
forward-migration path; downgrading would leave operators with a
config the older binary doesn't understand.
--> -->
<MajorUpgrade DowngradeErrorMessage="A newer version of TeamsISO is already installed. Uninstall it before installing this older version." <MajorUpgrade DowngradeErrorMessage="A newer version of TeamsISO is already installed. Uninstall it before installing this older version."
Schedule="afterInstallInitialize" /> Schedule="afterInstallInitialize" />
@ -40,6 +51,7 @@
<Feature Id="Main" Title="TeamsISO" Level="1"> <Feature Id="Main" Title="TeamsISO" Level="1">
<ComponentGroupRef Id="ApplicationFiles" /> <ComponentGroupRef Id="ApplicationFiles" />
<ComponentGroupRef Id="Shortcuts" /> <ComponentGroupRef Id="Shortcuts" />
<ComponentGroupRef Id="DesktopShortcut" />
<ComponentGroupRef Id="ArpEntry" /> <ComponentGroupRef Id="ArpEntry" />
</Feature> </Feature>
@ -51,10 +63,15 @@
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" /> <Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
<!-- <!--
ARP icon + about-box link. Add/Remove Programs metadata. ARPHELPLINK is the "Help" link; ARPURLINFOABOUT
is the manufacturer/about link; ARPCONTACT is the support contact shown
when the user clicks "Support information" from the ARP entry. ARPCOMMENTS
is the long description displayed in some Settings → Apps surfaces.
--> -->
<Property Id="ARPHELPLINK" Value="https://wilddragon.net" /> <Property Id="ARPHELPLINK" Value="https://forge.wilddragon.net/zgaetano/teamsiso" />
<Property Id="ARPURLINFOABOUT" Value="https://wilddragon.net" /> <Property Id="ARPURLINFOABOUT" Value="https://wilddragon.net" />
<Property Id="ARPCONTACT" Value="Wild Dragon LLC — support@wilddragon.net" />
<Property Id="ARPCOMMENTS" Value="TeamsISO turns Microsoft Teams' raw NDI broadcast into clean, normalized, per-participant NDI sources for ingestion by a live-production switcher (vMix, OBS, Ross, hardware capture). Each participant gets an individually-addressable source with configurable framerate, resolution, aspect mode, and audio routing." />
<!-- ARPNOMODIFY is set by WixUI_InstallDir; don't redeclare. --> <!-- ARPNOMODIFY is set by WixUI_InstallDir; don't redeclare. -->
<Property Id="ARPNOREPAIR" Value="1" /> <Property Id="ARPNOREPAIR" Value="1" />
@ -112,8 +129,15 @@
</ComponentGroup> </ComponentGroup>
<!-- <!--
Start Menu shortcut to the WPF host. KeyPath sits on a registry Start Menu and Desktop shortcuts — direct .exe targets.
value so component identity is stable across upgrades.
Don't wrap the Target in runas.exe /trustlevel:0x20000 (or anything
else that demotes the spawned process). The SAFER-restricted token
breaks .NET 8 WPF apphost startup: the process appears alive with
a window, but no managed code past BAML parse executes. Verified
empirically 2026-05-16 — letting TeamsISO inherit the launching
token (medium or high integrity, doesn't matter) is the correct
behavior. NDI discovery works fine at either integrity level.
--> -->
<ComponentGroup Id="Shortcuts" Directory="WildDragonStartMenuFolder"> <ComponentGroup Id="Shortcuts" Directory="WildDragonStartMenuFolder">
<Component Id="StartMenuShortcut" Guid="*"> <Component Id="StartMenuShortcut" Guid="*">
@ -121,7 +145,8 @@
Name="TeamsISO" Name="TeamsISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams" Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]TeamsISO.exe" Target="[INSTALLFOLDER]TeamsISO.exe"
WorkingDirectory="INSTALLFOLDER" /> WorkingDirectory="INSTALLFOLDER"
Icon="TeamsISOIcon" />
<!-- Required by ICE64: Start Menu folder must be cleaned on uninstall. --> <!-- Required by ICE64: Start Menu folder must be cleaned on uninstall. -->
<RemoveFolder Id="RemoveWildDragonStartMenuFolder" <RemoveFolder Id="RemoveWildDragonStartMenuFolder"
Directory="WildDragonStartMenuFolder" Directory="WildDragonStartMenuFolder"
@ -135,6 +160,24 @@
</Component> </Component>
</ComponentGroup> </ComponentGroup>
<StandardDirectory Id="DesktopFolder" />
<ComponentGroup Id="DesktopShortcut" Directory="DesktopFolder">
<Component Id="DesktopShortcutComponent" Guid="*">
<Shortcut Id="DesktopTeamsISO"
Name="TeamsISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]TeamsISO.exe"
WorkingDirectory="INSTALLFOLDER"
Icon="TeamsISOIcon" />
<RegistryValue Root="HKCU"
Key="Software\Wild Dragon\TeamsISO"
Name="DesktopShortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</ComponentGroup>
<!-- <!--
ARP icon registry entry. Optional — the MSI auto-fills most ARP ARP icon registry entry. Optional — the MSI auto-fills most ARP
fields from the Package element. We only need to point at the fields from the Package element. We only need to point at the

View file

@ -1,3 +1,4 @@
using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Windows; using System.Windows;
using System.Windows.Interop; using System.Windows.Interop;
@ -27,13 +28,24 @@ namespace TeamsISO.App;
public partial class App : Application public partial class App : Application
{ {
/// <summary> /// <summary>
/// Per-user mutex name. Including the SID-equivalent (the username) ensures two /// Per-user mutex name. Including the username (acting as a SID proxy) ensures two
/// different Windows users can each run TeamsISO on the same machine, while one /// different Windows users can each run TeamsISO on the same machine, while one
/// user can't spawn duplicate instances that would contend over the NDI runtime /// user can't spawn duplicate instances that would contend over the NDI runtime
/// and the shared %APPDATA%\TeamsISO\config.json. /// and the shared %APPDATA%\TeamsISO\config.json.
///
/// The "Global\" prefix puts the named object in the system-wide namespace
/// (not session-local or integrity-isolated). This matters because when an
/// admin user has UAC effectively disabled, launches from different parents
/// (elevated File Explorer, non-elevated shell, etc.) can land in slightly
/// different security contexts. A "Local\" mutex was being created in
/// different views per integrity level on some boxes, letting two TeamsISO
/// instances run concurrently — the second's REST surface couldn't bind port
/// 9755 (already held) and its Serilog file sink couldn't open the daily log
/// (already held with shared=false), producing a window that looked like
/// the app but had no engine attached. Global\ closes that gap.
/// </summary> /// </summary>
private static readonly string SingleInstanceMutexName = private static readonly string SingleInstanceMutexName =
$"Local\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}"; $"Global\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}";
private System.Threading.Mutex? _singleInstanceMutex; private System.Threading.Mutex? _singleInstanceMutex;
private bool _ownsSingleInstanceMutex; private bool _ownsSingleInstanceMutex;
@ -70,74 +82,116 @@ public partial class App : Application
protected override async void OnStartup(StartupEventArgs e) protected override async void OnStartup(StartupEventArgs e)
{ {
// RAW TRACE — captures startup BEFORE Serilog comes up. Helps diagnose
// launches where the Serilog log stays empty (silent file-sink failure,
// pre-logger crash, weird parent-spawn environment, etc.). Writes to
// %LOCALAPPDATA%\TeamsISO\startup-trace.log.
var parentName = "(unknown)";
try { parentName = TryGetParentProcessName() ?? "(null)"; } catch { }
StartupTrace.Write($"OnStartup ENTER. exe={Environment.ProcessPath} parent={parentName} args=[{string.Join(' ', e.Args)}]");
try
{
using var id = System.Security.Principal.WindowsIdentity.GetCurrent();
var pr = new System.Security.Principal.WindowsPrincipal(id);
StartupTrace.Write($"identity user={id.Name} isAdmin={pr.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)} integrity-token={id.User}");
}
catch (Exception ex) { StartupTrace.Write($"identity probe FAILED: {ex}"); }
base.OnStartup(e); base.OnStartup(e);
StartupTrace.Write("base.OnStartup returned");
// De-elevation via runas /trustlevel:0x20000 was tried (commits 191b2c5,
// 54ee578) on the theory that elevated TeamsISO can't discover NDI
// sources. THAT THEORY WAS WRONG — verified 2026-05-16 that elevated
// TeamsISO discovers NDI sources fine. The SAFER-restricted token
// produced by runas /trustlevel was the ACTUAL cause of every "no
// participants" report: it breaks .NET 8 WPF startup such that the
// process appears alive with a window but the managed code never gets
// past BAML parsing. No logs, no port binds. We now skip the check
// entirely. The --keep-elevation arg, originally an opt-out, is now
// accepted but no-op'd (kept to avoid breaking any operator scripts).
if (Array.IndexOf(e.Args, "--keep-elevation") >= 0)
StartupTrace.Write("--keep-elevation flag present (no-op now; de-elevation removed)");
// Crash diagnostics — wire the three exception channels WPF leaves open by // Crash diagnostics — wire the three exception channels WPF leaves open by
// default to a single handler that logs Fatal to Serilog (which has the // default to a single handler that logs Fatal to Serilog.
// rolling-daily file sink 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 will take it from here.
AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandled; AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandled;
DispatcherUnhandledException += OnDispatcherUnhandled; DispatcherUnhandledException += OnDispatcherUnhandled;
System.Threading.Tasks.TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; System.Threading.Tasks.TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
StartupTrace.Write("crash handlers registered");
// Resolve and apply the theme BEFORE any window is shown so we don't try { TeamsISO.App.Services.ThemeManager.Current.Apply(); StartupTrace.Write("ThemeManager.Apply OK"); }
// paint a dark frame for one tick then flip to light (or vice versa). catch (Exception ex) { StartupTrace.Write($"ThemeManager.Apply THREW: {ex}"); }
// ThemeManager.Apply swaps Application.Resources.MergedDictionaries
// in place; DynamicResource refs in WildDragonTheme.xaml re-bind.
TeamsISO.App.Services.ThemeManager.Current.Apply();
// Single-instance gate. Implementation in App.Bootstrap.cs; we // Single-instance gate. Trace the mutex acquisition.
// bail silently if another instance already owns the mutex (the bool acquired = false;
// existing instance gets surfaced via the bring-to-front broadcast). try { acquired = TryAcquireSingleInstance(); } catch (Exception ex) { StartupTrace.Write($"TryAcquireSingleInstance THREW: {ex}"); }
if (!TryAcquireSingleInstance()) StartupTrace.Write($"TryAcquireSingleInstance returned: {acquired}");
if (!acquired)
{ {
StartupTrace.Write("not first instance — Shutdown(0)");
Shutdown(0); Shutdown(0);
return; return;
} }
try try
{ {
// WPF host: write to both console (visible if attached) and a StartupTrace.Write("Bootstrap try-block ENTER");
// rolling daily file under %LOCALAPPDATA%\TeamsISO\Logs so users
// have something to grab when they file an issue.
_loggerFactory = EngineLogging.CreateDefault(LogLevel.Information); _loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
StartupTrace.Write("EngineLogging.CreateDefault OK");
var logger = _loggerFactory.CreateLogger<App>(); var logger = _loggerFactory.CreateLogger<App>();
logger.LogInformation( logger.LogInformation(
"TeamsISO.App starting up. Build: {Version}. Process: {Pid}.", "TeamsISO.App starting up. Build: {Version}. Process: {Pid}.",
typeof(App).Assembly.GetName().Version, typeof(App).Assembly.GetName().Version,
Environment.ProcessId); Environment.ProcessId);
StartupTrace.Write("Serilog first write attempted");
if (!TryBootstrapNdiInterop()) if (!TryBootstrapNdiInterop())
{ {
StartupTrace.Write("TryBootstrapNdiInterop returned false — Shutdown(2)");
Shutdown(2); Shutdown(2);
return; return;
} }
StartupTrace.Write("TryBootstrapNdiInterop OK");
BootstrapEngine(); BootstrapEngine();
StartupTrace.Write("BootstrapEngine OK");
var window = ConstructAndShowMainWindow(); var window = ConstructAndShowMainWindow();
StartupTrace.Write("ConstructAndShowMainWindow OK (window shown)");
BootstrapControlSurfaceServices(); BootstrapControlSurfaceServices();
StartupTrace.Write("BootstrapControlSurfaceServices OK");
BootstrapTrayIcon(window); BootstrapTrayIcon(window);
StartupTrace.Write("BootstrapTrayIcon OK");
TryShowOnboarding(window); TryShowOnboarding(window);
StartupTrace.Write("TryShowOnboarding returned");
// Parse CLI args BEFORE InitializeAsync so any --apply-preset
// request overrides the persisted auto-apply preference cleanly.
ApplyCommandLineArgs(e.Args); ApplyCommandLineArgs(e.Args);
StartupTrace.Write("ApplyCommandLineArgs OK");
StartupTrace.Write("about to await _viewModel.InitializeAsync");
await _viewModel!.InitializeAsync(CancellationToken.None); await _viewModel!.InitializeAsync(CancellationToken.None);
StartupTrace.Write("_viewModel.InitializeAsync COMPLETED");
TryAutoLaunchTeams(logger); TryAutoLaunchTeams(logger);
StartBackgroundUpdateCheck(logger); StartBackgroundUpdateCheck(logger);
StartupTrace.Write("OnStartup COMPLETE");
// 5-second post-init participant probe — tells us whether discovery
// is actually producing rows once the engine is up.
_ = Task.Run(async () =>
{
await Task.Delay(5000);
try
{
var n = await Dispatcher.InvokeAsync(() => _viewModel?.Participants.Count ?? -1);
StartupTrace.Write($"+5s after init: vm.Participants.Count={n}");
}
catch (Exception ex) { StartupTrace.Write($"+5s probe THREW: {ex.Message}"); }
});
} }
catch (Exception ex) catch (Exception ex)
{ {
// Log the full exception (incl. stack + inner) to Serilog BEFORE the StartupTrace.Write($"OnStartup CATCH: {ex}");
// modal MessageBox fires — diagnostic logs are far more useful than a
// user-pasted "TeamsISO failed to start..." line when triaging a
// startup crash. The logger may itself have been the failure target
// so guard the call.
try { _loggerFactory?.CreateLogger<App>().LogCritical(ex, "OnStartup failed before main loop"); } try { _loggerFactory?.CreateLogger<App>().LogCritical(ex, "OnStartup failed before main loop"); }
catch { /* defensive */ } catch { /* defensive */ }
MessageBox.Show( MessageBox.Show(
@ -149,6 +203,38 @@ public partial class App : Application
} }
} }
// De-elevation helpers (ShouldDeElevate, TryDeElevateAndExit, the
// TEAMSISO_RELAUNCHED env var) were removed 2026-05-16. The whole
// pattern was treating a symptom that wasn't actually the problem
// (elevation does NOT break NDI Find); the SAFER token produced by
// runas /trustlevel:0x20000 broke .NET 8 WPF startup itself, so the
// "fix" was the actual bug. See git log for the dead code, App.xaml.cs
// commit history around 191b2c5 / 54ee578 / removal.
/// <summary>
/// Look up our parent process's image name (without extension). Returns
/// null if it can't be determined (PID gone, denied, etc.).
/// </summary>
private static string? TryGetParentProcessName()
{
try
{
var pid = Environment.ProcessId;
using var search = new System.Management.ManagementObjectSearcher(
$"SELECT ParentProcessId FROM Win32_Process WHERE ProcessId={pid}");
foreach (var m in search.Get())
{
var ppid = Convert.ToInt32(m["ParentProcessId"]);
using var parent = System.Diagnostics.Process.GetProcessById(ppid);
return parent.ProcessName;
}
}
catch { /* fall through */ }
return null;
}
// TryDeElevateAndExit removed 2026-05-16 (see comment above ShouldDeElevate).
/// <summary> /// <summary>
/// Parse the supported CLI flags. Currently: /// Parse the supported CLI flags. Currently:
/// <c>--apply-preset NAME</c> — apply the named preset once participants /// <c>--apply-preset NAME</c> — apply the named preset once participants

View file

@ -0,0 +1,34 @@
from PIL import Image
import os
# We treat the navy-blue dragon-mark.png as a silhouette source: anything with
# nontrivial alpha is "dragon", everything else stays transparent. We emit a
# pure-black and pure-white variant, tightly cropped to the actual content
# bbox so they center cleanly when used as a watermark.
ROOT = os.path.dirname(os.path.abspath(__file__))
src_path = os.path.join(ROOT, "dragon-mark.png")
src = Image.open(src_path).convert("RGBA")
alpha = src.split()[-1]
# Threshold to drop anti-alias fringe that can fool getbbox into reporting
# the whole canvas as "in".
mask = alpha.point(lambda v: 255 if v > 16 else 0)
bbox = mask.getbbox()
print("content bbox =", bbox, "size =", (bbox[2] - bbox[0], bbox[3] - bbox[1]))
cropped = src.crop(bbox)
_, _, _, ca = cropped.split()
for name, rgb in (("black", (0, 0, 0)), ("white", (255, 255, 255))):
flat = Image.merge(
"RGBA",
(
Image.new("L", cropped.size, rgb[0]),
Image.new("L", cropped.size, rgb[1]),
Image.new("L", cropped.size, rgb[2]),
ca,
),
)
out_path = os.path.join(ROOT, f"dragon-mark-{name}.png")
flat.save(out_path, "PNG", optimize=True)
print("wrote", out_path, flat.size)

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -22,8 +22,9 @@
(Approved 2026-05-13. Shape brief at docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md) (Approved 2026-05-13. Shape brief at docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md)
Default Windows title bar (no chromeless WindowChrome). The 32px header Default Windows title bar (no chromeless WindowChrome). The 32px header
below it carries the brand mark, wordmark, and three icon buttons: below it carries the brand mark, wordmark, and two icon buttons:
⌘K (command palette), theme toggle, settings drawer. Below that, a theme toggle and settings drawer. (Command palette is still reachable
via Ctrl+K — keybinding only, no visible button.) Below that, a
single transport strip carries the operator's at-a-glance status. single transport strip carries the operator's at-a-glance status.
The participants area is the canvas — no rail, no permanent side The participants area is the canvas — no rail, no permanent side
panel, no footer. The meeting bar at the bottom renders ONLY when panel, no footer. The meeting bar at the bottom renders ONLY when
@ -105,7 +106,11 @@
BorderThickness="0" BorderThickness="0"
Cursor="Hand" Cursor="Hand"
ToolTip="About TeamsISO"> ToolTip="About TeamsISO">
<Image Source="/Assets/dragon-mark.png" <!-- Source bound to Wd.BrandMark.Image so the mark flips
white↔black with the active theme (see Theme.Dark /
Theme.Light). The PNG carries its own AA so HighQuality
scaling is preferred over NearestNeighbor at this size. -->
<Image Source="{DynamicResource Wd.BrandMark.Image}"
Width="20" Height="20" Width="20" Height="20"
RenderOptions.BitmapScalingMode="HighQuality"/> RenderOptions.BitmapScalingMode="HighQuality"/>
</Button> </Button>
@ -118,23 +123,16 @@
Margin="8,0,0,0"/> Margin="8,0,0,0"/>
</StackPanel> </StackPanel>
<!-- Right cluster: three icon buttons. ⌘K opens the command <!-- Right cluster: two icon buttons. The theme button cycles
palette (Ctrl+K shortcut). The theme button cycles
dark ↔ light (Ctrl+T). The gear opens the settings dark ↔ light (Ctrl+T). The gear opens the settings
drawer. That's the entire chrome. --> drawer. Ctrl+K still opens the command palette via the
keybinding above — we just dropped the visible ⌘K
button because it duplicated the keyboard affordance
and crowded the header. -->
<StackPanel Grid.Column="2" <StackPanel Grid.Column="2"
Orientation="Horizontal" Orientation="Horizontal"
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="0,0,10,0"> Margin="0,0,10,0">
<Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnCommandPaletteClick"
Padding="8,4"
Margin="0,0,2,0"
ToolTip="Command palette (Ctrl+K)"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Secondary}"
Content="⌘K"/>
<Button Style="{StaticResource Wd.Button.Ghost}" <Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding ToggleThemeCommand}" Command="{Binding ToggleThemeCommand}"
Padding="6,4" Padding="6,4"
@ -147,16 +145,21 @@
Width="14" Height="14" Width="14" Height="14"
Stretch="None"/> Stretch="None"/>
</Button> </Button>
<!-- True gear (Unicode U+2699) rendered via Segoe UI Symbol, the
same approach used by the per-row CFG button. Replaces the
earlier hand-drawn Path that read as a sun/asterisk rather
than a cog. Unicode glyph hints cleanly at the small icon
sizes the header uses and stays crisp under DPI scaling. -->
<Button Style="{StaticResource Wd.Button.Ghost}" <Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnSettingsToggleClick" Click="OnSettingsToggleClick"
Padding="6,4" Padding="6,2"
ToolTip="Settings"> ToolTip="Settings">
<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" <TextBlock Text="&#x2699;"
Stroke="{DynamicResource Wd.Text.Secondary}" FontSize="16"
StrokeThickness="1.4" FontFamily="Segoe UI Symbol"
Fill="Transparent" Foreground="{DynamicResource Wd.Text.Secondary}"
Width="14" Height="14" VerticalAlignment="Center"
Stretch="None"/> HorizontalAlignment="Center"/>
</Button> </Button>
</StackPanel> </StackPanel>
</Grid> </Grid>
@ -448,8 +451,30 @@
BorderBrush="{DynamicResource Wd.Border}" BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1" BorderThickness="1"
CornerRadius="{StaticResource Radius.M}" CornerRadius="{StaticResource Radius.M}"
Background="{DynamicResource Wd.Surface}"> Background="{DynamicResource Wd.Surface}"
ClipToBounds="True">
<Grid> <Grid>
<!--
Brand watermark superimposed BEHIND the participants grid.
Sits at 6% opacity so a populated grid reads cleanly over
the top while the dragon is still visible through the
transparent row backgrounds (RowBackground="Transparent"
on the DataGrid below). When the grid is empty the
watermark becomes the de-facto empty-state surface.
IsHitTestVisible=False so the watermark never absorbs
clicks meant for grid rows or the empty area below them.
Source binds to the theme-flipped Wd.BrandMark.Image
resource — white dragon in dark mode, black in light.
-->
<Image Source="{DynamicResource Wd.BrandMark.Image}"
Opacity="0.06"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="40"
IsHitTestVisible="False"
RenderOptions.BitmapScalingMode="HighQuality"/>
<DataGrid x:Name="ParticipantsGrid" <DataGrid x:Name="ParticipantsGrid"
ItemsSource="{Binding ParticipantsView}" ItemsSource="{Binding ParticipantsView}"
AutoGenerateColumns="False" AutoGenerateColumns="False"
@ -612,52 +637,70 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>
<!-- Col 4 — Output name (mono). The NDI source name TeamsISO <!-- Col 4 — Output name (mono, INLINE EDITABLE). The NDI source
will broadcast this participant as. --> name TeamsISO will broadcast this participant as. Defaults
<DataGridTemplateColumn Header="Output" Width="130" IsReadOnly="True"> to the speaker's display name; type to override per-row,
clear the field to revert to the default. EditableOutputName
handles both directions (see ParticipantViewModel comment).
UpdateSourceTrigger=LostFocus so we don't restart the NDI
sender on every keystroke — only when the operator
commits by tabbing away or pressing Enter. -->
<DataGridTemplateColumn Header="Output" Width="130">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<TextBlock Text="{Binding OutputName}" <TextBox Text="{Binding EditableOutputName, UpdateSourceTrigger=LostFocus, Mode=TwoWay}"
FontFamily="{StaticResource Wd.Font.Mono}" FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12" FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}" Background="Transparent"
VerticalAlignment="Center" BorderThickness="0"
TextTrimming="CharacterEllipsis"/> Padding="0"
Foreground="{DynamicResource Wd.Text.Secondary}"
CaretBrush="{DynamicResource Wd.Text.Primary}"
VerticalAlignment="Center"
VerticalContentAlignment="Center"
ToolTip="NDI source name. Defaults to the speaker — type to override, clear to revert."/>
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>
<!-- Col 5a — Per-row gear: opens the ISO override editor for this <!-- Col 5a — Per-row gear: opens the ISO override editor for this
participant. Narrow (32px) so the table still fits inside a participant. We use the Unicode gear glyph (U+2699) instead
1280px window after the toggle column. --> of a custom Path — it renders cleanly at any size, doesn't
<DataGridTemplateColumn Header="" Width="32" IsReadOnly="True"> 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> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<Button Style="{StaticResource Wd.Button.Ghost}" <Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnIsoOverrideClick" Click="OnIsoOverrideClick"
Padding="6,4" Padding="6,2"
VerticalAlignment="Center" VerticalAlignment="Center"
HorizontalAlignment="Center" HorizontalAlignment="Center"
ToolTip="Override output settings for this participant"> ToolTip="Override output settings for this participant (framerate, resolution, audio)">
<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" <TextBlock Text="&#x2699;"
Stroke="{DynamicResource Wd.Text.Secondary}" FontSize="16"
StrokeThickness="1.4" FontFamily="Segoe UI Symbol"
Fill="Transparent" Foreground="{DynamicResource Wd.Text.Primary}"
Width="14" Height="14" VerticalAlignment="Center"
Stretch="None"/> HorizontalAlignment="Center"/>
</Button> </Button>
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>
<!-- Col 5 — ISO toggle pill. LIVE = cyan-muted fill + cyan border + cyan text. <!-- Col 5 — ISO toggle. LIVE = cyan-muted fill + cyan border + cyan text.
OFF = hollow neutral. Error states use the existing IsoToggle style. --> OFF = hollow neutral. Error states use the existing IsoToggle style.
<DataGridTemplateColumn Header="ISO" Width="100"> Width 124 (was 100/110) so the "Enable" / "● LIVE" content has
breathing room inside the rounded-rect — 100 was clipping the label
at the right edge once the IsoToggle stopped being a full pill. -->
<DataGridTemplateColumn Header="ISO" Width="124">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<Button Command="{Binding ToggleIsoCommand}" <Button Command="{Binding ToggleIsoCommand}"
Margin="0,0,12,0" Margin="0,0,12,0"
Padding="14,6" Padding="10,6"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Button.Style> <Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource Wd.Button.IsoToggle}"> <Style TargetType="Button" BasedOn="{StaticResource Wd.Button.IsoToggle}">
@ -682,24 +725,54 @@
<!-- Empty-state placeholder. Renders when no NDI participants <!-- Empty-state placeholder. Renders when no NDI participants
have been discovered yet. Mono sentence + one tertiary have been discovered yet. Mono sentence + one tertiary
Refresh button — no illustration, no mascot, per the v2 Refresh button — no illustration, no mascot, per the v2
shape brief's empty-states section. --> shape brief's empty-states section.
Two visual flavors gated by IsDiscovering (the VM holds
it true for ~8s after engine start, false thereafter):
- IsDiscovering=true → "Scanning for NDI sources…"
(neutral; cold-start can take
1-3s for mDNS to settle)
- IsDiscovering=false → the explanatory empty state
("open teams and start a
meeting") + Refresh CTA
This stops operators from staring at a "broken-looking"
empty table during the first second of every launch. -->
<StackPanel HorizontalAlignment="Center" <StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}, ConverterParameter=empty}"> Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}, ConverterParameter=empty}">
<TextBlock Text="no ndi sources yet — open teams and start a meeting" <!-- Discovering: cyan dot + neutral progress copy. -->
FontFamily="{StaticResource Wd.Font.Mono}" <StackPanel Orientation="Horizontal"
FontSize="12" HorizontalAlignment="Center"
Foreground="{DynamicResource Wd.Text.Tertiary}" Visibility="{Binding IsDiscovering, Converter={StaticResource BoolToVis}}">
HorizontalAlignment="Center"/> <Ellipse Width="7" Height="7"
<Button Style="{StaticResource Wd.Button.Ghost}" Fill="{DynamicResource Wd.Accent.Cyan}"
Command="{Binding RefreshDiscoveryCommand}" VerticalAlignment="Center"
Content="Refresh discovery (Ctrl+R)" Margin="0,0,10,0"/>
Padding="14,7" <TextBlock Text="scanning for ndi sources…"
Margin="0,14,0,0" FontFamily="{StaticResource Wd.Font.Mono}"
HorizontalAlignment="Center" FontSize="12"
FontFamily="{StaticResource Wd.Font.Mono}" Foreground="{DynamicResource Wd.Text.Secondary}"
FontSize="11" VerticalAlignment="Center"/>
ToolTip="Rebuild the NDI finder"/> </StackPanel>
<!-- Not discovering (grace window expired with no sources):
the explanatory empty state. -->
<StackPanel HorizontalAlignment="Center"
Visibility="{Binding IsDiscovering, Converter={StaticResource BoolToVisInverse}}">
<TextBlock Text="no ndi sources visible — is teams in 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>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
@ -947,7 +1020,7 @@
<TextBox Text="{Binding Settings.OutputNameTemplate, UpdateSourceTrigger=LostFocus}" <TextBox Text="{Binding Settings.OutputNameTemplate, UpdateSourceTrigger=LostFocus}"
FontFamily="{StaticResource Wd.Font.Mono}" FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"/> FontSize="11"/>
<TextBlock Text="Tokens: {name}, {guid}, {machine}, {timestamp}. Default: TEAMSISO_{guid}." <TextBlock Text="Tokens: {name}, {guid}, {machine}, {timestamp}. Default: {name} — the speaker's display name."
Style="{StaticResource Wd.Text.Body}" Style="{StaticResource Wd.Text.Body}"
FontSize="11" FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}" Foreground="{DynamicResource Wd.Text.Tertiary}"

View file

@ -1,16 +1,18 @@
using System.IO; using System.IO;
using System.Linq;
using System.Text; using System.Text;
namespace TeamsISO.App.Services; namespace TeamsISO.App.Services;
/// <summary> /// <summary>
/// User-editable template for the NDI source name a participant's ISO is /// User-editable template for the NDI source name a participant's ISO is
/// published as. Default <c>"TEAMSISO_{guid}"</c> matches the original /// published as. Default <c>"{name}"</c> renders the speaker's display name
/// hard-coded <c>DefaultOutputName</c> in <c>IsoController</c>; operators /// directly, which is what downstream switchers want when they key on
/// can switch to <c>"TEAMSISO_{name}"</c> for human-readable output names /// readable identifiers. Operators can override globally to
/// (recommended for downstream switchers that key on name patterns), or /// <c>"TEAMSISO_{guid}"</c> for the legacy stable-id behavior, or
/// <c>"TEAMSISO_{machine}_{name}"</c> when multiple TeamsISO machines feed /// <c>"TEAMSISO_{machine}_{name}"</c> when multiple TeamsISO machines feed
/// the same NDI network. /// the same NDI network and you want the source name to carry both.
/// Per-participant overrides take priority over whatever template is set.
/// ///
/// Tokens expanded in <see cref="Render"/>: /// Tokens expanded in <see cref="Render"/>:
/// <c>{name}</c> participant display name, sanitized (alphanumeric + underscore) /// <c>{name}</c> participant display name, sanitized (alphanumeric + underscore)
@ -18,11 +20,29 @@ namespace TeamsISO.App.Services;
/// <c>{machine}</c> sanitized PC hostname (Environment.MachineName) /// <c>{machine}</c> sanitized PC hostname (Environment.MachineName)
/// <c>{timestamp}</c> current local time as <c>yyyyMMdd_HHmmss</c> /// <c>{timestamp}</c> current local time as <c>yyyyMMdd_HHmmss</c>
/// ///
/// Empty-name fallback: if the rendered result is empty/whitespace (e.g.
/// template was <c>"{name}"</c> and the participant joined with no display
/// name yet), <see cref="Render"/> falls back to <c>TEAMSISO_{guid}</c> so
/// the NDI sender always has a usable, unique identifier.
///
/// Persisted to <c>%LOCALAPPDATA%\TeamsISO\output-name-template.txt</c>. /// Persisted to <c>%LOCALAPPDATA%\TeamsISO\output-name-template.txt</c>.
/// </summary> /// </summary>
public static class OutputNameTemplate public static class OutputNameTemplate
{ {
public const string DefaultTemplate = "TEAMSISO_{guid}"; /// <summary>
/// Default template — renders just the speaker's display name. Was
/// <c>"TEAMSISO_{guid}"</c> in pre-v1 builds; switched 2026-05-16 so
/// new installs get human-readable source names out of the box.
/// </summary>
public const string DefaultTemplate = "{name}";
/// <summary>
/// Stable fallback used when the rendered template produces an empty
/// string (typically because a participant has no display name yet).
/// Mirrors the engine's legacy DefaultOutputName so the NDI sender is
/// always uniquely identifiable.
/// </summary>
private const string EmptyNameFallback = "TEAMSISO_{guid}";
private static string TemplatePath => private static string TemplatePath =>
Path.Combine( Path.Combine(
@ -87,7 +107,30 @@ public static class OutputNameTemplate
// Final sanitize on the rendered result — protects against a template // Final sanitize on the rendered result — protects against a template
// that includes literal characters NDI doesn't accept. // that includes literal characters NDI doesn't accept.
return SanitizeForNdi(result); var sanitized = SanitizeForNdi(result);
// Empty-name fallback. The default template "{name}" can render to
// an unusable result for participants whose DisplayName hasn't been
// populated yet (Teams sometimes delivers the displayName a tick
// after the participant join event). Two failure modes to catch:
//
// • DisplayName == "" → "{name}" expands to "" → sanitized "".
// • DisplayName == " " → "{name}" expands to "___" because the
// sanitizer converts whitespace to underscores.
//
// Neither is a meaningful NDI source identifier, so we substitute
// TEAMSISO_{guid}. The Any(char.IsLetterOrDigit) check covers both
// cases — anything without at least one alphanumeric is unusable.
// We apply this AFTER token expansion (not on the raw input) so a
// template like "PFX_{name}" with empty displayName still works:
// it renders to "PFX_" which contains alphanumerics and is left
// alone.
if (string.IsNullOrWhiteSpace(sanitized) || !sanitized.Any(char.IsLetterOrDigit))
{
sanitized = SanitizeForNdi(EmptyNameFallback.Replace("{guid}", guid));
}
return sanitized;
} }
private static string SanitizeForNdi(string s) private static string SanitizeForNdi(string s)

View file

@ -31,8 +31,12 @@ public sealed class ThemeManager
savePreference: TrySavePreferenceToDisk, savePreference: TrySavePreferenceToDisk,
subscribeToSystemPreference: true); subscribeToSystemPreference: true);
private const string DarkUri = "/Themes/Theme.Dark.xaml"; // Pack URIs (rather than relative "/Themes/…") so the resolution
private const string LightUri = "/Themes/Theme.Light.xaml"; // 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 PreferenceKeySystem = "System"; private const string PreferenceKeySystem = "System";
private const string PreferenceKeyDark = "Dark"; private const string PreferenceKeyDark = "Dark";
private const string PreferenceKeyLight = "Light"; private const string PreferenceKeyLight = "Light";
@ -165,7 +169,7 @@ public sealed class ThemeManager
} }
} }
var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Relative) }; var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Absolute) };
if (old is null) if (old is null)
{ {
dicts.Insert(0, fresh); dicts.Insert(0, fresh);

View file

@ -0,0 +1,40 @@
using System.IO;
namespace TeamsISO.App;
/// <summary>
/// Bare-metal startup tracer that opens, appends, and closes a file on
/// every call. Used to capture what's happening BEFORE Serilog comes up
/// (and to capture failures that would prevent Serilog from coming up at
/// all). Failures here are swallowed — we never want diagnostics to crash
/// the very thing we're trying to diagnose.
///
/// File lives at <c>%LOCALAPPDATA%\TeamsISO\startup-trace.log</c>. Grows
/// without rotation; expected to be tiny since each launch writes ~20
/// lines. Acceptable cost for catching launch-time regressions.
/// </summary>
internal static class StartupTrace
{
private static readonly object _gate = new();
public static void Write(string message)
{
try
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "startup-trace.log");
var line = $"[{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff}] [PID {Environment.ProcessId}] {message}{Environment.NewLine}";
lock (_gate)
{
File.AppendAllText(path, line);
}
}
catch
{
// Diagnostics must NEVER crash startup.
}
}
}

View file

@ -25,6 +25,14 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\TeamsISO.Engine\TeamsISO.Engine.csproj" /> <ProjectReference Include="..\TeamsISO.Engine\TeamsISO.Engine.csproj" />
<ProjectReference Include="..\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj" /> <ProjectReference Include="..\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj" />
<!--
System.Management gives us Win32_Process via ManagementObjectSearcher,
used in App.xaml.cs's ShouldDeElevate() to look up the parent process
name (we re-spawn ourselves via runas /trustlevel:0x20000 when the
parent is explorer.exe AND we're elevated — that combo triggers an
NDI mDNS-isolation bug that returns zero discovered sources).
-->
<PackageReference Include="System.Management" Version="8.0.0" />
</ItemGroup> </ItemGroup>
<!-- <!--
@ -52,7 +60,17 @@
<!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. --> <!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. -->
<ItemGroup> <ItemGroup>
<!-- Source navy-blue dragon-mark, kept for AboutWindow / installer iconography. -->
<Resource Include="Assets\dragon-mark.png" /> <Resource Include="Assets\dragon-mark.png" />
<!--
Theme-aware silhouette variants used by Theme.Dark / Theme.Light to expose
a single Wd.BrandMark.Image resource key. The dark theme picks the white
dragon (visible on #0A0A0A), the light theme picks the black dragon
(visible on #FAFAFB). Generated from dragon-mark.png via
Assets/_recolor_dragon.py — re-run if the source mark ever changes.
-->
<Resource Include="Assets\dragon-mark-white.png" />
<Resource Include="Assets\dragon-mark-black.png" />
<Resource Include="Assets\wild-dragon-wordmark.png" /> <Resource Include="Assets\wild-dragon-wordmark.png" />
<Resource Include="Assets\teamsiso.ico" /> <Resource Include="Assets\teamsiso.ico" />
<!-- <!--

View file

@ -44,4 +44,20 @@
<SolidColorBrush x:Key="Wd.Status.LiveBg" Color="#13261A"/> <SolidColorBrush x:Key="Wd.Status.LiveBg" Color="#13261A"/>
<SolidColorBrush x:Key="Wd.Status.Warn" Color="#FBBF24"/> <SolidColorBrush x:Key="Wd.Status.Warn" Color="#FBBF24"/>
<SolidColorBrush x:Key="Wd.Status.Error" Color="#FB819C"/> <SolidColorBrush x:Key="Wd.Status.Error" Color="#FB819C"/>
<!--
Brand mark image, theme-flipped. Dark mode shows the WHITE dragon so it
reads against the near-black canvas. The light theme exposes the same
key pointing at the BLACK dragon. Consumers bind via
{DynamicResource Wd.BrandMark.Image} so the swap is automatic on
ThemeManager.Toggle().
CacheOption=OnLoad decodes the PNG at load time and releases the
underlying stream, which matters because the source files are 1243×1125
— without OnLoad the BitmapImage holds the stream open for the life
of the resource dictionary.
-->
<BitmapImage x:Key="Wd.BrandMark.Image"
UriSource="pack://application:,,,/TeamsISO;component/Assets/dragon-mark-white.png"
CacheOption="OnLoad"/>
</ResourceDictionary> </ResourceDictionary>

View file

@ -46,4 +46,15 @@
<SolidColorBrush x:Key="Wd.Status.LiveBg" Color="#DCFCE7"/> <SolidColorBrush x:Key="Wd.Status.LiveBg" Color="#DCFCE7"/>
<SolidColorBrush x:Key="Wd.Status.Warn" Color="#B45309"/> <SolidColorBrush x:Key="Wd.Status.Warn" Color="#B45309"/>
<SolidColorBrush x:Key="Wd.Status.Error" Color="#D43E5C"/> <SolidColorBrush x:Key="Wd.Status.Error" Color="#D43E5C"/>
<!--
Brand mark image, theme-flipped. Light mode shows the BLACK dragon so it
reads against the cyan-tinted off-white canvas. Mirror of the Dark
theme's resource — same key, opposite silhouette. Consumers use
{DynamicResource Wd.BrandMark.Image} so the swap is automatic.
See Theme.Dark.xaml's comment for the CacheOption=OnLoad rationale.
-->
<BitmapImage x:Key="Wd.BrandMark.Image"
UriSource="pack://application:,,,/TeamsISO;component/Assets/dragon-mark-black.png"
CacheOption="OnLoad"/>
</ResourceDictionary> </ResourceDictionary>

View file

@ -319,7 +319,11 @@
</Setter> </Setter>
</Style> </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"> <Style x:Key="Wd.Button.IsoToggle" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/> <Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="11"/> <Setter Property="FontSize" Value="11"/>
@ -340,7 +344,7 @@
Background="{TemplateBinding Background}" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}" BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="999"> CornerRadius="{StaticResource Radius.M}">
<ContentPresenter HorizontalAlignment="Center" <ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="{TemplateBinding Padding}"/> Margin="{TemplateBinding Padding}"/>

View file

@ -486,10 +486,13 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary> /// <summary>
/// Output-name template applied when the operator enables an ISO without /// Output-name template applied when the operator enables an ISO without
/// a per-participant CustomName. Default <c>"TEAMSISO_{guid}"</c> matches /// a per-participant CustomName. Default <c>"{name}"</c> renders the
/// the engine's hard-coded behavior; switch to <c>"TEAMSISO_{name}"</c> /// speaker's display name directly (changed from the legacy
/// for human-readable NDI source names. See <see cref="OutputNameTemplate"/> /// <c>"TEAMSISO_{guid}"</c> in 0.9.0-rc19, since downstream switchers
/// for the supported tokens. /// almost always want human-readable identifiers). Switch back to a
/// guid-based template if you need stable IDs that survive participant
/// name changes. See <see cref="OutputNameTemplate"/> for the supported
/// tokens.
/// </summary> /// </summary>
public string OutputNameTemplate public string OutputNameTemplate
{ {

View file

@ -31,6 +31,19 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new(); private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
private string _statusText = "Starting…"; private string _statusText = "Starting…";
/// <summary>
/// Wall-clock at which <see cref="InitializeAsync"/> kicked the engine. Used to
/// gate the "Scanning for NDI sources…" placeholder so it shows for a few
/// seconds after launch even when ParticipantCount == 0 (the bleak
/// "no ndi sources yet" empty state was being shown immediately and
/// operators assumed the app was broken before discovery had a chance to fire).
/// Null until InitializeAsync runs.
/// </summary>
private DateTimeOffset? _engineStartedAt;
/// <summary>How long after engine start to keep showing "Scanning…" instead of the empty-state copy.</summary>
private static readonly TimeSpan DiscoveryGracePeriod = TimeSpan.FromSeconds(8);
// _pendingPresetName / Deadline / Applied + the auto-apply path // _pendingPresetName / Deadline / Applied + the auto-apply path
// moved to MainViewModel.PresetCommands.cs. // moved to MainViewModel.PresetCommands.cs.
@ -233,6 +246,21 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
} }
private int _participantCount; private int _participantCount;
/// <summary>
/// True for the first <see cref="DiscoveryGracePeriod"/> after engine start.
/// The XAML uses this to swap the empty-state placeholder from the bleak
/// "no ndi sources yet — open teams and start a meeting" copy (which reads
/// as broken to operators who just launched into an active meeting) to a
/// neutral "Scanning for NDI sources…" status while NDI Find resolves
/// mDNS responses. Always false once participants populate.
/// </summary>
public bool IsDiscovering
{
get => _isDiscovering;
private set => SetField(ref _isDiscovering, value);
}
private bool _isDiscovering;
/// <summary> /// <summary>
/// Currently-enabled (live) ISO count — feeds the v2 transport strip's /// Currently-enabled (live) ISO count — feeds the v2 transport strip's
/// "LIVE N" readout. The number is cyan-tinted when non-zero to draw /// "LIVE N" readout. The number is cyan-tinted when non-zero to draw
@ -530,6 +558,19 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
ParticipantCount = totalParticipants; ParticipantCount = totalParticipants;
LiveCount = enabledCount; LiveCount = enabledCount;
// IsDiscovering gates the "Scanning for NDI sources…" placeholder.
// True for DiscoveryGracePeriod after engine start AS LONG AS we
// haven't seen any participants yet; once anything arrives we drop
// out of the discovering state immediately (back to the OK path).
if (totalParticipants == 0 && _engineStartedAt is { } startedAt)
{
IsDiscovering = DateTimeOffset.UtcNow - startedAt < DiscoveryGracePeriod;
}
else if (IsDiscovering)
{
IsDiscovering = false;
}
// Session timer — start on first ISO going live, reset when none are // Session timer — start on first ISO going live, reset when none are
// live anymore. Subsequent enables after a full-zero gap restart the // live anymore. Subsequent enables after a full-zero gap restart the
// timer rather than resuming, which is the operator's mental model: // timer rather than resuming, which is the operator's mental model:
@ -600,6 +641,8 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
public async Task InitializeAsync(CancellationToken cancellationToken) public async Task InitializeAsync(CancellationToken cancellationToken)
{ {
StatusText = "Discovering NDI sources…"; StatusText = "Discovering NDI sources…";
_engineStartedAt = DateTimeOffset.UtcNow;
IsDiscovering = true;
await _controller.StartAsync(cancellationToken); await _controller.StartAsync(cancellationToken);
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target."; StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";

View file

@ -422,15 +422,20 @@ public sealed class ParticipantViewModel : ObservableObject
set set
{ {
if (SetField(ref _customName, value)) if (SetField(ref _customName, value))
{
OnPropertyChanged(nameof(OutputName)); OnPropertyChanged(nameof(OutputName));
OnPropertyChanged(nameof(EditableOutputName));
}
} }
} }
/// <summary> /// <summary>
/// The NDI source name TeamsISO will broadcast this participant as. Prefers /// The NDI source name TeamsISO will broadcast this participant as. Prefers
/// the operator's <see cref="CustomName"/> when set; otherwise renders the /// the operator's <see cref="CustomName"/> when set; otherwise renders the
/// engine's default template (typically <c>TEAMSISO_{guid}</c>). Bound by /// active template (default <c>"{name}"</c>, falling back to
/// the v2 participants table's mono "output name" column. /// <c>TEAMSISO_{guid}</c> when the participant has no display name yet).
/// Bound by the v2 participants table's mono "output name" column for
/// read-only display contexts.
/// </summary> /// </summary>
public string OutputName => public string OutputName =>
string.IsNullOrWhiteSpace(_customName) string.IsNullOrWhiteSpace(_customName)
@ -440,6 +445,39 @@ public sealed class ParticipantViewModel : ObservableObject
_participant.DisplayName) _participant.DisplayName)
: _customName; : _customName;
/// <summary>
/// Two-way binding endpoint for the inline-editable Output column. Reads
/// resolve to whatever name will actually be broadcast (<see cref="OutputName"/>);
/// writes set <see cref="CustomName"/> with a couple of UX niceties:
///
/// • Clearing the field (empty / whitespace) reverts to the template
/// default — the user doesn't have to remember the template syntax to
/// "undo" a customization.
///
/// • Typing a value that exactly matches the resolved default is treated
/// as a no-op (CustomName stays empty), so the participant continues
/// to follow the template when their display name changes upstream.
/// Without this, typing the auto-suggested value would silently
/// "pin" the participant to a stale name forever.
/// </summary>
public string EditableOutputName
{
get => OutputName;
set
{
var trimmed = (value ?? string.Empty).Trim();
var defaultRendered = Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
_participant.Id,
_participant.DisplayName);
CustomName = string.IsNullOrWhiteSpace(trimmed) ||
string.Equals(trimmed, defaultRendered, StringComparison.Ordinal)
? string.Empty
: trimmed;
}
}
public AsyncRelayCommand ToggleIsoCommand { get; } public AsyncRelayCommand ToggleIsoCommand { get; }
/// <summary>Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor.</summary> /// <summary>Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor.</summary>
@ -464,6 +502,12 @@ public sealed class ParticipantViewModel : ObservableObject
OnPropertyChanged(nameof(SourceMachine)); OnPropertyChanged(nameof(SourceMachine));
OnPropertyChanged(nameof(SourceFullName)); OnPropertyChanged(nameof(SourceFullName));
OnPropertyChanged(nameof(IsOnline)); OnPropertyChanged(nameof(IsOnline));
// OutputName/EditableOutputName both derive from _participant.DisplayName
// when no per-participant CustomName is set — re-notify so the Output
// column tracks upstream Teams name changes for participants who
// haven't been manually renamed.
OnPropertyChanged(nameof(OutputName));
OnPropertyChanged(nameof(EditableOutputName));
} }
private async Task ToggleIsoAsync() private async Task ToggleIsoAsync()
@ -479,10 +523,11 @@ public sealed class ParticipantViewModel : ObservableObject
else else
{ {
// Resolve the output name: explicit per-participant CustomName // Resolve the output name: explicit per-participant CustomName
// wins; otherwise expand the operator's template (defaults to // wins; otherwise expand the operator's template (default is
// "TEAMSISO_{guid}" which matches the engine's old hard-coded // "{name}" since 0.9.0-rc19, with an empty-name fallback to
// behavior). Passing the rendered name to EnableIsoAsync as // TEAMSISO_{guid} inside Render). Passing the rendered name
// customName overrides the engine's DefaultOutputName path. // to EnableIsoAsync as customName overrides the engine's
// DefaultOutputName path.
var resolvedName = string.IsNullOrWhiteSpace(_customName) var resolvedName = string.IsNullOrWhiteSpace(_customName)
? Services.OutputNameTemplate.Render( ? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(), Services.OutputNameTemplate.Get(),
@ -501,6 +546,27 @@ public sealed class ParticipantViewModel : ObservableObject
IsEnabled = true; IsEnabled = true;
} }
} }
catch (InvalidOperationException)
{
// Race window: participant left the meeting between when the operator
// clicked Enable/Disable and when the engine resolved the ID. The
// controller throws InvalidOperationException with a "not currently
// visible on the network" message in this case. Surface it as a soft
// warning toast rather than letting it escape into the dispatcher's
// unhandled-exception channel (which fires a fatal crash dialog).
//
// Leave IsEnabled at its current value — the engine refused the state
// change, so the VM should reflect the actual engine state.
_toast?.Warn($"{DisplayName} just left the meeting");
}
catch (Exception ex)
{
// Defensive catch-all for any other engine-side failure (port bind
// race, pipeline factory throw, etc.). Same reasoning as above —
// an exception from an operator click should never tear down the
// dispatcher.
_toast?.Warn($"Couldn't toggle ISO for {DisplayName}: {ex.Message}");
}
finally finally
{ {
IsProcessing = false; IsProcessing = false;

View file

@ -35,6 +35,36 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
return Marshal.PtrToStringAnsi(ptr) ?? string.Empty; return Marshal.PtrToStringAnsi(ptr) ?? string.Empty;
} }
/// <summary>
/// Normalize a comma-separated NDI group list before handing it to the SDK.
/// Returns null if the input is null/whitespace (caller will use the SDK default).
///
/// **NDI group names are case-sensitive in the runtime.** "Public" matches; "public"
/// does NOT. The default group an unconfigured NDI Sender broadcasts to is "Public"
/// (capital P). Operators who type "public" into the discovery groups field then see
/// zero sources and report the app as broken — that's how this normalizer came to
/// exist (2026-05-16 dev session, ~6h of misdiagnosis). We special-case "public" →
/// "Public" to match the most common operator footgun. Other group names are
/// passed through verbatim — custom groups like "teamsiso-input" are
/// intentionally lowercase and must round-trip unchanged.
///
/// Marked internal so the test project can cover the lookup table directly.
/// </summary>
internal static string? NormalizeGroups(string? groups)
{
if (string.IsNullOrWhiteSpace(groups)) return null;
var parts = groups.Split(',', StringSplitOptions.RemoveEmptyEntries);
for (var i = 0; i < parts.Length; i++)
{
var p = parts[i].Trim();
// Canonicalize the standard "Public" group regardless of input casing.
if (string.Equals(p, "Public", StringComparison.OrdinalIgnoreCase))
p = "Public";
parts[i] = p;
}
return string.Join(",", parts);
}
// ---- Discovery ---- // ---- Discovery ----
public NdiFindHandle CreateFinder(string? groups = null) public NdiFindHandle CreateFinder(string? groups = null)
@ -48,7 +78,7 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
// same lifetime contract CreateReceiver / CreateSender below have relied on // same lifetime contract CreateReceiver / CreateSender below have relied on
// since Phase B-2; if it ever turns out to be wrong, those will fail too. The // since Phase B-2; if it ever turns out to be wrong, those will fail too. The
// loopback discovery integration test would catch a regression here. // loopback discovery integration test would catch a regression here.
var trimmed = string.IsNullOrWhiteSpace(groups) ? null : groups!.Trim(); var trimmed = NormalizeGroups(groups);
if (trimmed is null) if (trimmed is null)
{ {
var nativeDefault = NdiNative.FindCreateV2(IntPtr.Zero); var nativeDefault = NdiNative.FindCreateV2(IntPtr.Zero);
@ -247,7 +277,7 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
public NdiSenderHandle CreateSender(string outputName, string? groups = null) public NdiSenderHandle CreateSender(string outputName, string? groups = null)
{ {
var trimmedGroups = string.IsNullOrWhiteSpace(groups) ? null : groups!.Trim(); var trimmedGroups = NormalizeGroups(groups);
var nameUtf8 = Marshal.StringToHGlobalAnsi(outputName); var nameUtf8 = Marshal.StringToHGlobalAnsi(outputName);
var groupsUtf8 = trimmedGroups is null var groupsUtf8 = trimmedGroups is null
? IntPtr.Zero ? IntPtr.Zero

View file

@ -1,7 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\TeamsISO.Engine\TeamsISO.Engine.csproj" /> <ProjectReference Include="..\TeamsISO.Engine\TeamsISO.Engine.csproj" />
</ItemGroup>
<!-- Grant the engine test project visibility into internals (specifically
NdiInteropPInvoke.NormalizeGroups, which gates the "public" vs "Public"
NDI group case-folding fix). -->
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>TeamsISO.Engine.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>

View file

@ -74,39 +74,138 @@ public sealed class NdiDiscoveryService
foreach (var name in currentSet) _previous.Add(name); foreach (var name in currentSet) _previous.Add(name);
} }
/// <summary>Long-running poll loop. Cancel the token to stop.</summary> /// <summary>
/// Long-running poll loop with cold-start ramp + self-healing.
/// Cancel the token to stop.
///
/// Cadence: 200ms for the first 3 seconds (fast cold-start mDNS settling),
/// then the configured <paramref name="pollInterval"/>.
///
/// Self-healing: certain process spawns end up with an NDI finder that
/// returns 0 sources forever even when sources are visible to other
/// processes (suspected cause: medium-integrity SAFER token from runas
/// /trustlevel doesn't talk to NDI's mDNS responder reliably; could also
/// be a NIC-bind race at finder construction). To recover, we rebuild
/// the finder when:
/// <list type="number">
/// <item>We've never seen a source AND it's been &gt;5s since startup AND
/// it's been &gt;5s since the last rebuild.</item>
/// <item>We previously saw sources but the set has been empty for &gt;15s
/// AND it's been &gt;10s since the last rebuild.</item>
/// </list>
/// Both rules apply backoff so we don't churn during legitimate empty
/// periods (no meeting active, etc.) — the rebuild is cheap but the log
/// noise isn't useful.
/// </summary>
public async Task RunAsync(TimeSpan pollInterval, CancellationToken cancellationToken) public async Task RunAsync(TimeSpan pollInterval, CancellationToken cancellationToken)
{ {
using var timer = new PeriodicTimer(pollInterval);
try try
{ {
while (await timer.WaitForNextTickAsync(cancellationToken)) // Immediate first poll — PeriodicTimer.WaitForNextTickAsync would
// wait the full interval otherwise, costing us 200-500ms at cold
// start when operators are most impatient.
try { PollOnce(); } catch (Exception ex) { _logger.LogWarning(ex, "Initial discovery poll failed."); }
var startedAt = DateTimeOffset.UtcNow;
var fastUntil = startedAt + TimeSpan.FromSeconds(3);
var fastInterval = TimeSpan.FromMilliseconds(200);
DateTimeOffset? lastSeenAt = _previous.Count > 0 ? startedAt : null;
var lastRebuildAt = startedAt;
while (!cancellationToken.IsCancellationRequested)
{ {
var now = DateTimeOffset.UtcNow;
var interval = now < fastUntil ? fastInterval : pollInterval;
try { await Task.Delay(interval, cancellationToken); }
catch (OperationCanceledException) { break; }
now = DateTimeOffset.UtcNow;
// Operator-requested rebuild (Refresh discovery in the UI) wins.
if (Interlocked.Exchange(ref _refreshRequested, 0) == 1) if (Interlocked.Exchange(ref _refreshRequested, 0) == 1)
{ {
try RebuildFinder("operator request");
lastRebuildAt = now;
}
// Auto-healing rebuilds — see ShouldAutoRebuild.
else if (_previous.Count == 0)
{
var decision = ShouldAutoRebuild(
sinceStart: now - startedAt,
sinceLastSeen: lastSeenAt is { } seen ? now - seen : (TimeSpan?)null,
sinceLastRebuild: now - lastRebuildAt);
if (decision is { } reason)
{ {
_logger.LogInformation("Rebuilding NDI finder on operator request."); RebuildFinder(reason);
_finder.Dispose(); lastRebuildAt = now;
_finder = _interop.CreateFinder(_discoveryGroups);
_previous.Clear();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Finder refresh failed; continuing with existing finder.");
} }
} }
try { PollOnce(); } try { PollOnce(); }
catch (Exception ex) { _logger.LogWarning(ex, "Discovery poll failed; will retry on next tick."); } catch (Exception ex) { _logger.LogWarning(ex, "Discovery poll failed; will retry on next tick."); }
if (_previous.Count > 0) lastSeenAt = DateTimeOffset.UtcNow;
} }
} }
catch (OperationCanceledException) { /* expected */ }
finally finally
{ {
_finder.Dispose(); _finder.Dispose();
} }
} }
/// <summary>
/// Pure-function decision for whether the discovery loop should rebuild the
/// NDI finder on the current tick. Returns a non-null reason string when
/// the rebuild should fire (which is also logged); null means "leave the
/// finder alone." Caller is responsible for tracking the timestamps and
/// updating <c>lastRebuildAt</c> after the rebuild.
///
/// Public + static for unit-testability — the time-based rules are easy to
/// regress and hard to spot in integration testing.
///
/// Rules:
/// <list type="number">
/// <item><b>Never seen a source</b> (<paramref name="sinceLastSeen"/> is null):
/// rebuild when sinceStart &gt; 5s AND sinceLastRebuild &gt; 5s.</item>
/// <item><b>Used to see sources, now empty</b>: rebuild when sinceLastSeen
/// &gt; 15s AND sinceLastRebuild &gt; 10s.</item>
/// </list>
/// Both rules back off the rebuild cadence to avoid churn during legitimate
/// empty periods (no meeting active, all participants left, etc.).
/// </summary>
public static string? ShouldAutoRebuild(TimeSpan sinceStart, TimeSpan? sinceLastSeen, TimeSpan sinceLastRebuild)
{
if (sinceLastSeen is null)
{
if (sinceStart > TimeSpan.FromSeconds(5) && sinceLastRebuild > TimeSpan.FromSeconds(5))
return "auto-heal: never saw a source";
return null;
}
if (sinceLastSeen.Value > TimeSpan.FromSeconds(15) && sinceLastRebuild > TimeSpan.FromSeconds(10))
return "auto-heal: source set went empty 15s ago";
return null;
}
/// <summary>
/// Dispose the current finder and create a fresh one against the cached
/// discovery groups. Clears the seen-set so all currently-visible sources
/// will re-fire as <see cref="DiscoveryEvent.Added"/> on the next poll.
/// </summary>
private void RebuildFinder(string reason)
{
try
{
_logger.LogInformation("Rebuilding NDI finder ({Reason}).", reason);
_finder.Dispose();
_finder = _interop.CreateFinder(_discoveryGroups);
_previous.Clear();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Finder rebuild failed ({Reason}); continuing with existing finder.", reason);
}
}
/// <summary> /// <summary>
/// Updates the cached discovery-groups string used by future finder rebuilds. /// Updates the cached discovery-groups string used by future finder rebuilds.
/// Call <see cref="RequestRefresh"/> after this to actually pick up the change. /// Call <see cref="RequestRefresh"/> after this to actually pick up the change.

View file

@ -0,0 +1,200 @@
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

@ -0,0 +1,87 @@
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

@ -7,6 +7,11 @@ namespace TeamsISO.App.Tests.Services;
// Unit tests for NotesService — the append-only show-notes log. // Unit tests for NotesService — the append-only show-notes log.
// Uses the DirectoryOverride seam so writes land in a tempdir and // Uses the DirectoryOverride seam so writes land in a tempdir and
// don't pollute the dev's real %LOCALAPPDATA%\TeamsISO\Notes folder. // 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 public sealed class NotesServiceTests : IDisposable
{ {
private readonly string _tempDir; private readonly string _tempDir;

View file

@ -0,0 +1,14 @@
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

@ -14,6 +14,10 @@ namespace TeamsISO.App.Tests.Services;
// paths return early on the null check, so we verify the bail rather // 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 // than the happy path. The full toggle path is covered in branch 11's
// integration test that boots a real dispatcher. // 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 public sealed class OscBridgeDispatchTests : IDisposable
{ {
private readonly string _tempNotesDir; private readonly string _tempNotesDir;

View file

@ -15,10 +15,34 @@ public class OutputNameTemplateTests
private static readonly Guid TestId = new("11223344-5566-7788-99aa-bbccddeeff00"); private static readonly Guid TestId = new("11223344-5566-7788-99aa-bbccddeeff00");
[Fact] [Fact]
public void Render_DefaultTemplate_ProducesGuidPrefix() public void Render_DefaultTemplate_RendersSpeakerDisplayName()
{ {
var name = OutputNameTemplate.Render(OutputNameTemplate.DefaultTemplate, TestId, "Jane"); var name = OutputNameTemplate.Render(OutputNameTemplate.DefaultTemplate, TestId, "Jane");
// Default is "TEAMSISO_{guid}" → first 8 hex of TestId, uppercase. // Default is "{name}" since 0.9.0-rc19 — produces the speaker name
// directly so downstream switchers see human-readable identifiers.
// Previously was "TEAMSISO_{guid}"; see DefaultTemplate's xmldoc.
name.Should().Be("Jane");
}
[Fact]
public void Render_DefaultTemplate_EmptyName_FallsBackToGuidPrefix()
{
// The "{name}" default would render to an empty string for a
// participant with no display name yet (Teams sometimes delivers
// DisplayName a tick after the join event). The empty-name
// fallback substitutes TEAMSISO_{guid} so the NDI sender is
// always uniquely identifiable. Without this, the engine would
// throw on an empty sender name.
var name = OutputNameTemplate.Render(OutputNameTemplate.DefaultTemplate, TestId, "");
name.Should().Be("TEAMSISO_11223344");
}
[Fact]
public void Render_DefaultTemplate_WhitespaceName_FallsBackToGuidPrefix()
{
// Mirror of the empty-name case — whitespace-only display names
// sanitize down to empty and should trigger the same fallback.
var name = OutputNameTemplate.Render(OutputNameTemplate.DefaultTemplate, TestId, " ");
name.Should().Be("TEAMSISO_11223344"); name.Should().Be("TEAMSISO_11223344");
} }

View file

@ -31,6 +31,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" /> <PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageReference Include="Xunit.StaFact" Version="1.1.11" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -66,4 +66,78 @@ public class NdiDiscoveryServiceTests
while (reader.TryRead(out var ev)) list.Add(ev); while (reader.TryRead(out var ev)) list.Add(ev);
return list; return list;
} }
// ============================================================
// ShouldAutoRebuild — pure function gating the auto-heal path
// ============================================================
//
// Two rules under test:
// (a) Never seen a source AND sinceStart>5s AND sinceLastRebuild>5s -> rebuild
// (b) Used to see sources, now empty AND sinceLastSeen>15s AND sinceLastRebuild>10s -> rebuild
//
// Both rules back off to avoid churn during legitimate empty periods.
[Fact]
public void ShouldAutoRebuild_NeverSeenSource_BeforeWarmup_ReturnsNull()
{
// 3s after startup is well inside the "give cold start a chance" window.
NdiDiscoveryService.ShouldAutoRebuild(
sinceStart: TimeSpan.FromSeconds(3),
sinceLastSeen: null,
sinceLastRebuild: TimeSpan.FromSeconds(99))
.Should().BeNull();
}
[Fact]
public void ShouldAutoRebuild_NeverSeenSource_AfterWarmup_TriggersRebuild()
{
NdiDiscoveryService.ShouldAutoRebuild(
sinceStart: TimeSpan.FromSeconds(6),
sinceLastSeen: null,
sinceLastRebuild: TimeSpan.FromSeconds(6))
.Should().Contain("never saw a source");
}
[Fact]
public void ShouldAutoRebuild_NeverSeenSource_RecentRebuild_HoldsOff()
{
// sinceStart qualifies, but the last rebuild was 2s ago — back off.
NdiDiscoveryService.ShouldAutoRebuild(
sinceStart: TimeSpan.FromSeconds(20),
sinceLastSeen: null,
sinceLastRebuild: TimeSpan.FromSeconds(2))
.Should().BeNull();
}
[Fact]
public void ShouldAutoRebuild_HadSources_NowEmpty_LongAgo_TriggersRebuild()
{
NdiDiscoveryService.ShouldAutoRebuild(
sinceStart: TimeSpan.FromMinutes(5),
sinceLastSeen: TimeSpan.FromSeconds(20),
sinceLastRebuild: TimeSpan.FromSeconds(30))
.Should().Contain("source set went empty");
}
[Fact]
public void ShouldAutoRebuild_HadSources_NowEmpty_Recently_HoldsOff()
{
// 10s since last source seen — still inside the 15s grace window.
NdiDiscoveryService.ShouldAutoRebuild(
sinceStart: TimeSpan.FromMinutes(5),
sinceLastSeen: TimeSpan.FromSeconds(10),
sinceLastRebuild: TimeSpan.FromSeconds(30))
.Should().BeNull();
}
[Fact]
public void ShouldAutoRebuild_HadSources_NowEmpty_RecentRebuild_HoldsOff()
{
// Grace window expired, but we just rebuilt 8s ago — back off.
NdiDiscoveryService.ShouldAutoRebuild(
sinceStart: TimeSpan.FromMinutes(5),
sinceLastSeen: TimeSpan.FromSeconds(30),
sinceLastRebuild: TimeSpan.FromSeconds(8))
.Should().BeNull();
}
} }

View file

@ -0,0 +1,35 @@
using System.Runtime.Versioning;
using TeamsISO.Engine.NdiInterop;
namespace TeamsISO.Engine.Tests.Interop;
// NdiInteropPInvoke is marked [SupportedOSPlatform("windows")] because it
// P/Invokes the Windows-only NDI runtime. The pure NormalizeGroups helper
// doesn't actually touch native code, but it inherits the platform tag from
// the enclosing class. Re-declaring SupportedOSPlatform here silences CA1416
// — these tests still only run on Windows (the Engine.Tests project itself
// is platform-agnostic but xunit only schedules them when the OS supports).
[SupportedOSPlatform("windows")]
// NdiInteropPInvoke.NormalizeGroups is internal; the engine tests project has
// access via InternalsVisibleTo applied to TeamsISO.Engine.NdiInterop.
public class NdiInteropNormalizeGroupsTests
{
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData(" ", null)]
[InlineData("Public", "Public")] // already canonical
[InlineData("public", "Public")] // lowercase -> canonical (the bug fix)
[InlineData("PUBLIC", "Public")] // shouty -> canonical
[InlineData("PuBlIc", "Public")] // mixed case -> canonical
[InlineData("teamsiso-input", "teamsiso-input")] // custom group: pass through
[InlineData("Public,teamsiso-input", "Public,teamsiso-input")]
[InlineData("public,teamsiso-input", "Public,teamsiso-input")] // mixed list normalizes the standard one only
[InlineData("teamsiso-input,PUBLIC", "teamsiso-input,Public")]
[InlineData(" public , teamsiso-input ", "Public,teamsiso-input")] // whitespace trimmed per part
public void NormalizeGroups_Maps(string? input, string? expected)
{
NdiInteropPInvoke.NormalizeGroups(input).Should().Be(expected);
}
}

View file

@ -10,9 +10,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0"> <PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" /> <PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
@ -24,8 +24,11 @@
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\TeamsISO.Engine\TeamsISO.Engine.csproj" /> <ProjectReference Include="..\..\TeamsISO.Engine\TeamsISO.Engine.csproj" />
<!-- Needed by NdiInteropNormalizeGroupsTests to reach the internal
NormalizeGroups helper (the "public" → "Public" case-folding fix). -->
<ProjectReference Include="..\..\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>