Compare commits

..

No commits in common. "main" and "chore/docs-reconcile" have entirely different histories.

230 changed files with 10481 additions and 7526 deletions

View file

@ -1,4 +1,4 @@
name: CI
name: CI
on:
push:
@ -21,15 +21,15 @@ jobs:
echo "$HOME/.dotnet" >> $GITHUB_PATH
echo "DOTNET_ROOT=$HOME/.dotnet" >> $GITHUB_ENV
- name: Restore (Linux solution filter — excludes Windows-only WPF app)
run: dotnet restore Dragon-ISO.Linux.slnf
- name: Restore (Linux solution filter excludes Windows-only WPF app)
run: dotnet restore TeamsISO.Linux.slnf
- name: Build (Release, treat warnings as errors)
run: dotnet build Dragon-ISO.Linux.slnf --configuration Release --no-restore
run: dotnet build TeamsISO.Linux.slnf --configuration Release --no-restore
- name: Test (excluding requires=ndi)
run: >
dotnet test Dragon-ISO.Linux.slnf
dotnet test TeamsISO.Linux.slnf
--configuration Release
--no-build
--logger "trx;LogFileName=test-results.trx"
@ -47,7 +47,7 @@ jobs:
-reports:"**/coverage.cobertura.xml" \
-targetdir:coverage-report \
-reporttypes:"Cobertura;TextSummary" \
-assemblyfilters:"+Dragon-ISO.Engine;-Dragon-ISO.Engine.NdiInterop"
-assemblyfilters:"+TeamsISO.Engine;-TeamsISO.Engine.NdiInterop"
- name: Enforce coverage threshold (80%)
run: |

View file

@ -1,4 +1,4 @@
name: Release
name: Release
# Triggered by pushing an annotated tag of the form v1.2.3 (or any v-prefixed
# semver). The job runs on a Windows runner because building the WiX MSI
@ -54,48 +54,48 @@ jobs:
}
- name: Restore (Windows solution filter)
run: dotnet restore Dragon-ISO.Windows.slnf
run: dotnet restore TeamsISO.Windows.slnf
- name: Build (Release, treat warnings as errors)
run: dotnet build Dragon-ISO.Windows.slnf --configuration Release --no-restore /p:Version=${{ steps.ver.outputs.version }}
run: dotnet build TeamsISO.Windows.slnf --configuration Release --no-restore /p:Version=${{ steps.ver.outputs.version }}
- name: Run unit tests (excluding requires=ndi)
run: >
dotnet test Dragon-ISO.Windows.slnf
dotnet test TeamsISO.Windows.slnf
--configuration Release
--no-build
--filter "Category!=ndi&requires!=ndi"
- name: Publish Dragon-ISO.App (framework-dependent, win-x64)
- name: Publish TeamsISO.App (framework-dependent, win-x64)
run: >
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj
--configuration Release
--runtime win-x64
--self-contained false
--output publish/Dragon-ISO
--output publish/TeamsISO
/p:Version=${{ steps.ver.outputs.version }}
- name: Publish Dragon-ISO.Console (framework-dependent, win-x64)
- name: Publish TeamsISO.Console (framework-dependent, win-x64)
run: >
dotnet publish src/Dragon-ISO.Console/Dragon-ISO.Console.csproj
dotnet publish src/TeamsISO.Console/TeamsISO.Console.csproj
--configuration Release
--runtime win-x64
--self-contained false
--output publish/Dragon-ISO-Console
--output publish/TeamsISO-Console
/p:Version=${{ steps.ver.outputs.version }}
# Code-sign the WPF .exe BEFORE the MSI is built, so the MSI's embedded
# binaries are signed too. Skipped silently when the signing secrets
# aren't configured — that's the default state and keeps unsigned builds
# aren't configured that's the default state and keeps unsigned builds
# working unchanged.
#
# To enable signing, set both Forgejo Actions secrets:
# SIGN_CERT_PFX_BASE64 — base64 of your code-signing PFX file
# SIGN_CERT_PFX_BASE64 base64 of your code-signing PFX file
# ( certutil -encode in.pfx out.b64; strip BEGIN/END lines )
# SIGN_CERT_PASSWORD — the PFX password
# SIGN_CERT_PASSWORD the PFX password
# Optionally:
# SIGN_TIMESTAMP_URL — RFC 3161 timestamp server (default: digicert)
- name: Sign Dragon-ISO.exe (optional, skipped if no cert)
# SIGN_TIMESTAMP_URL RFC 3161 timestamp server (default: digicert)
- name: Sign TeamsISO.exe (optional, skipped if no cert)
if: ${{ steps.signcfg.outputs.enabled == 'true' }}
env:
SIGN_CERT_PFX_BASE64: ${{ secrets.SIGN_CERT_PFX_BASE64 }}
@ -116,13 +116,13 @@ jobs:
/fd SHA256 `
/td SHA256 `
/tr $tsUrl `
'publish/Dragon-ISO/DragonISO.exe'
if ($LASTEXITCODE -ne 0) { throw "signtool failed on Dragon-ISO.exe (exit $LASTEXITCODE)" }
'publish/TeamsISO/TeamsISO.exe'
if ($LASTEXITCODE -ne 0) { throw "signtool failed on TeamsISO.exe (exit $LASTEXITCODE)" }
Remove-Item $pfxPath -Force
- name: Build MSI installer
run: >
dotnet build installer/Dragon-ISO.Installer.wixproj
dotnet build installer/TeamsISO.Installer.wixproj
--configuration Release
/p:Version=${{ steps.ver.outputs.version }}
@ -136,7 +136,7 @@ jobs:
"name=$($msi.Name)" >> $env:GITHUB_OUTPUT
Write-Host "MSI: $($msi.FullName) ($($msi.Length) bytes)"
# Sign the produced MSI itself. Same gate as exe signing — runs only if
# Sign the produced MSI itself. Same gate as exe signing runs only if
# the cert secret is set. Splitting the two stages means the inner exe
# is signed before being embedded, AND the wrapping MSI carries its own
# signature for SmartScreen.
@ -155,7 +155,6 @@ jobs:
$signtool = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter signtool.exe `
| Where-Object { $_.FullName -match '\\x64\\' } `
| Select-Object -First 1
if (-not $signtool) { throw 'signtool.exe not found on runner' }
& $signtool.FullName sign `
/f $pfxPath `
/p $env:SIGN_CERT_PASSWORD `
@ -167,7 +166,7 @@ jobs:
Remove-Item $pfxPath -Force
- name: Upload MSI as workflow artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: ${{ steps.msi.outputs.name }}
path: ${{ steps.msi.outputs.path }}
@ -196,7 +195,7 @@ jobs:
Write-Host "No release found for $env:TAG; creating one."
$body = @{
tag_name = $env:TAG
name = "Dragon-ISO $env:TAG"
name = "TeamsISO $env:TAG"
body = "Automated build from tag $env:TAG."
draft = $false
prerelease = $env:TAG -match '-(alpha|beta|rc)'

6
.gitignore vendored
View file

@ -31,9 +31,3 @@ Thumbs.db
# Local Claude session metadata
.claude/
# Build / test output logs
*.log
full-output.txt
test-output.txt
test-run.txt

View file

@ -1,86 +1,340 @@
# Changelog
# Changelog
All notable changes to Dragon-ISO are documented here. The format follows
All notable changes to TeamsISO are documented here. The format follows
[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).
## [1.0.0] — 2026-05-17
## [Unreleased]
First general release. Windows-only, .NET 8 WPF, NDI 6.
### Added — v2 "Studio Terminal" GUI (2026-05-13)
### Engine
The May 2026 ground-up redesign — explicit anti-reference to "the v1
GUI screamed AI made it" — landed on the WPF host
(`src/TeamsISO.App/`). The shape brief lives at
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`. An earlier WinUI
3 replatform was scoped on 2026-05-12 and abandoned in favour of doing
the redesign in WPF (activation blockers + redundant work given the
shared view-model surface). The abandoned migration plan + bootstrap
probe are archived under `docs/archive/`.
- **Participant discovery** over NDI with name cleanup — strips the
"MS Teams - " / "(Teams) " prefixes and surfaces the operator-friendly
display name.
- **Per-participant ISO outputs** with normalized framerate, resolution,
aspect mode, and audio routing. Each ISO is an individually-addressable
NDI source.
- **NDI Groups** support — discovery and sender. One-click "Apply
transcoder topology" pins Teams' raw broadcasts to a private
`Dragon-ISO-input` group while Dragon-ISO re-emits on `Public`.
- **Self-healing finder** — if the NDI runtime stalls (zero discovered
sources past a startup grace period, or sources go from present to
empty and stay that way), the engine rebuilds the finder automatically.
- **Real-time recording** — per-output raw BGRA stream + `manifest.json`
+ an FFmpeg `convert.cmd` script for post-production conversion to
H.264 MKV. Recording is opt-in globally and per-participant.
- **PRODUCT.md** + **DESIGN.md**: impeccable context for the redesign.
Solo broadcast operator at 1:50am is the canonical persona; "vibe-coded
GUI" is the explicit anti-reference. Tokens cover dark + light palettes
with context-aware accent split (cyan surface fill stays bright in
both modes; cyan-as-text darkens to `#0E7C82` on light for AA contrast).
- **Theme system** (`Themes/Theme.Dark.xaml`, `Theme.Light.xaml`,
`WildDragonTheme.xaml`) + `Services/ThemeManager.cs` singleton that
swaps the merged dictionary at runtime, reads
`HKCU\…\AppsUseLightTheme` for System mode, subscribes to
`SystemEvents.UserPreferenceChanged`, persists via
`UIPreferences.Theme`. `Ctrl+T` toggles dark ↔ light.
- **v2 main window shell**: default system title bar; 32px header (Wild
Dragon mark + wordmark left, ⌘K / theme / settings icons right); 40px
transport strip (`● 02:14:32 PART 4 · LIVE 2 CTRL :9755`); body with
alert banner + update banner + action toolbar + participants
DataGrid; conditional `IN CALL` meeting bar at bottom; slide-over
settings drawer (420px from right) with OUTPUT / NETWORK / APP tabs.
The v1 72px rail, the 380px permanent settings panel, and the
six-column footer are gone.
- **Task 39 — participants table v2**: five columns (24px state LED,
name + codec caption, 110px audio meter, 130px mono output name, 100px
ISO pill), 52px rows, full-row active-speaker tint (replaces the v1
left-edge stripe).
- **Task 40 — Ctrl+K command palette**: `Views/CommandPaletteWindow.xaml`
+ `ViewModels/CommandPaletteViewModel.cs`. Centered 560×360 floating
window with fuzzy search across Quick / Teams / Presets / Output /
Network / App categories. ↑/↓ navigates, Enter invokes, Esc closes.
- **Interactive HTML preview** at `docs/preview/redesigned-mainwindow.html`
for stakeholders to see the v2 shell.
### UI — "Studio Terminal"
### Added — May 2026 feature batch
- **Dark and light themes** with a runtime swap and a system-follow mode.
The Wild Dragon mark, the participants-grid watermark, and every accent
brush respond to the active theme.
- **Header**: brand mark, theme toggle, settings gear.
- **Transport strip**: session timer, participant count, live ISO count,
control-surface URL — at-a-glance status.
- **Participants table**: 24px state LED, 106px live thumbnail preview,
name + caption, 5-bar audio meter, **inline-editable output name**,
CFG button (per-row override editor), ISO enable pill.
- **Settings drawer** — slide-over from the right with OUTPUT / NETWORK /
APP tabs.
- **Ctrl+K command palette** — fuzzy search across Quick / Teams /
Presets / Output / Network / App categories.
- **Live preview thumbnails** in the participants table; right-click →
Open preview… spawns a non-modal floating window suitable for a
secondary monitor.
#### Engine
- NDI Groups: discovery + sender support so Teams' raw broadcasts can be
pinned to a private "teamsiso-input" group while TeamsISO's own
normalized outputs broadcast on Public.
- One-click "Apply transcoder topology" writes `ndi-config.v1.json` so all
Teams broadcasts go to the private group and TeamsISO re-emits on Public.
- `RawBgraRecorderSink` per-output recorder: `IRecorderSink` interface +
raw BGRA stream + `manifest.json` + `convert.cmd` script for FFmpeg
conversion to H.264 MKV.
- Recording markers: `IRecorderSink.AddMarker(label)` fan-out via
`IIsoController.AddRecordingMarker`. Markers land in `manifest.json`
under `markers[]` for post-production chaptering.
- Preview thumbnails: `IsoPipeline.LatestProcessedFrame` published via
`Volatile.Read` so the UI can render 160×90 BGRA thumbnails in the
participants DataGrid at 1Hz.
- Idempotent `ParticipantTracker.HandleAdded`: re-emitting Added for an
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...)`.
### Output name template
#### Host (WPF)
- 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).
- New default: **the speaker's display name** (`{name}`). Per-participant
overrides are inline-editable in the table. Empty-name fallback to
`Dragon-ISO_{guid}` keeps the NDI sender uniquely identifiable while a
participant's display name resolves upstream.
- Available tokens: `{name}`, `{guid}`, `{machine}`, `{timestamp}`.
#### LAN-reachable control surface
- `ControlSurfaceServer.Start(port, bindToLan)` and `OscBridge.Start(port,
bindToLan)` switch between `127.0.0.1` and all-interfaces (`http://+:port/`,
`IPAddress.Any`) based on the new `ControlSurfaceLanReachable` UI preference.
Settings VM persists the toggle, restarts both surfaces on flip, and
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.
### Operator presets
#### "I only see TeamsISO" — Phase E.1+E.2 quality-of-life
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.
- Save current per-participant ISO assignments + custom output names to
`%LOCALAPPDATA%\Dragon-ISO\presets.json`. Optional auto-apply on next
launch.
#### UI polish — visible affordances on the dark canvas
- Hover state on every themed button (Ghost / Caption / RailIcon / IsoToggle)
was barely distinguishable from the resting state. Bumped `Wd.SurfaceHover`
+ `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.
### Teams orchestration
#### Control surface
- 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`.
- Launch / stop Teams from the app.
- Hide Teams' UI windows during a show.
- Drive in-call controls (mute, camera, share, leave, raise hand) via
UIAutomation.
#### CI / Release
- Forgejo CI is green; tag-push release workflow builds + tests + publishes
+ builds MSI on a Windows runner and attaches it to the auto-created
release via the REST API.
- Optional MSI + exe code-signing wired into `release.yml` — gated on
`SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD` Forgejo Secrets.
### External control surface
### Fixed
- REST + WebSocket on `127.0.0.1:9755` for Bitfocus Companion / Stream
Deck / custom controllers.
- OSC on UDP `127.0.0.1:9000` for TouchOSC.
- Self-contained HTML control panel at `/ui` — open from any phone on
the LAN.
- `.slnf` path-separator mismatch (forward slashes for cross-platform).
- NDI native DLL resolution via `NativeLibrary` resolver.
- `ExpectedRuntimeVersionPrefix` updated to NDI 6 banner format.
- `NdiSourceParser` accepts current Teams desktop's `MS Teams - <name>`
brand format.
- 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).
### Diagnostics & installer
- Rolling daily Serilog logs under `%LOCALAPPDATA%\Dragon-ISO\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/Dragon-ISO/releases/tag/v1.0.0
[Unreleased]: https://forge.wilddragon.net/zgaetano/teamsiso/compare/v0.1.0...HEAD

340
DESIGN.md Normal file
View file

@ -0,0 +1,340 @@
# 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>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest</AnalysisLevel>
<Version>1.0.0</Version>
<Version>1.0.0-alpha.0</Version>
<Authors>Wild Dragon LLC</Authors>
<Company>Wild Dragon LLC</Company>
<Product>TeamsISO</Product>

View file

@ -1,12 +0,0 @@
{
"solution": {
"path": "Dragon-ISO.sln",
"projects": [
"src/Dragon-ISO.Engine/Dragon-ISO.Engine.csproj",
"src/Dragon-ISO.Engine.NdiInterop/Dragon-ISO.Engine.NdiInterop.csproj",
"src/Dragon-ISO.Console/Dragon-ISO.Console.csproj",
"src/tests/Dragon-ISO.Engine.Tests/Dragon-ISO.Engine.Tests.csproj",
"src/tests/Dragon-ISO.Engine.IntegrationTests/Dragon-ISO.Engine.IntegrationTests.csproj"
]
}
}

View file

@ -1,14 +0,0 @@
{
"solution": {
"path": "Dragon-ISO.sln",
"projects": [
"src\\Dragon-ISO.Engine\\Dragon-ISO.Engine.csproj",
"src\\Dragon-ISO.Engine.NdiInterop\\Dragon-ISO.Engine.NdiInterop.csproj",
"src\\Dragon-ISO.Console\\Dragon-ISO.Console.csproj",
"src\\Dragon-ISO.App\\Dragon-ISO.App.csproj",
"src\\tests\\Dragon-ISO.Engine.Tests\\Dragon-ISO.Engine.Tests.csproj",
"src\\tests\\Dragon-ISO.Engine.IntegrationTests\\Dragon-ISO.Engine.IntegrationTests.csproj",
"src\\tests\\Dragon-ISO.App.Tests\\Dragon-ISO.App.Tests.csproj"
]
}
}

81
NEXT_STEPS.md Normal file
View file

@ -0,0 +1,81 @@
# 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.

181
PRODUCT.md Normal file
View file

@ -0,0 +1,181 @@
# 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,77 +1,94 @@
# 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
live-production environment. It receives each participant's NDI stream,
normalizes framerate / resolution / aspect / audio per a configured target,
and re-emits clean, individually-addressable NDI sources for ingestion by a
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.
---
and re-emits clean, individually-addressable NDI sources for ingestion into
a switcher (vMix, OBS, Ross, hardware capture).
## What it does
- **Discovers participants** as Teams broadcasts each one over NDI. Cleans
the Teams-prefixed source name down to a readable display name.
- **Discovers participants** as Teams broadcasts each one over NDI, surfacing
the operator-friendly display name (handles current "MS Teams - Name"
format and the legacy "(Teams) Name" format).
- **Normalizes feeds** to a consistent framerate, resolution, aspect mode,
and audio routing — so the downstream switcher gets predictable inputs
regardless of what each participant's webcam is doing.
- **Routes per-participant** as separate NDI sources with a configurable
per-row output name. Default is the speaker's display name; override
inline in the participants table.
- **Records each ISO to disk** simultaneously — raw BGRA + `manifest.json`
+ FFmpeg `convert.cmd` — so post-production gets a clean per-guest archive.
- **Embeds Teams orchestration**: launch / stop Teams, hide its UI windows
during a show, drive in-call controls (mute, camera, share, leave,
raise hand) without leaving the operator console.
output-name template (`TEAMSISO_{name}`, `{guid}`, `{machine}`, `{timestamp}` tokens).
- **Records each ISO to disk** simultaneously — raw BGRA + sidecar manifest.json
+ ffmpeg convert.cmd — so post-production gets a clean per-guest archive.
- **Embeds Teams orchestration**: launch and stop Teams from the rail, hide
Teams' UI windows during a show, drive in-call controls (mute, camera,
share, leave, raise hand) via UIAutomation.
- **Operator presets** save the current per-participant ISO assignment and
custom output names, applicable on next launch automatically.
- **Live preview thumbnails** in the participants table, plus pop-out
floating preview windows for multi-monitor monitoring.
- **Live preview thumbnails** per participant in the participants table,
plus pop-out floating preview windows (right-click → Open preview…) for
multi-monitor monitoring.
- **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 /
TouchOSC. Self-contained HTML panel at `/ui` for phone-as-controller.
- **Theme-aware** — dark and light palettes, system-following or pinned.
The Wild Dragon mark and watermark flip to match.
TouchOSC integration. Self-contained HTML control panel at
[`/ui`](docs/CONTROL-SURFACE.md) for phone-as-controller.
- **Crash diagnostics** wired to a rolling daily Serilog file sink under
`%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.
## Install
## Status
Grab the latest MSI from the
[Releases page](https://forge.wilddragon.net/zgaetano/teamsiso/releases),
double-click, and accept the install prompts. Per-machine install under
`C:\Program Files\Wild Dragon\TeamsISO`.
Pre-1.0. The May 2026 batch is feature-complete; v1.0 cut is gated on
code-signing the MSI and a smoke pass against a real Teams meeting.
See `CHANGELOG.md` for the [Unreleased] entry.
**Prerequisites:**
- Windows 10 / 11, 64-bit
- [.NET 8 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/8.0)
- [NDI 6 Runtime](https://www.ndi.video/tools/) (the installer warns if
missing but does not block — operators can stage the app before NDI is
rolled out)
- Microsoft Teams (NDI broadcast enabled in admin policy)
The May 2026 ground-up redesign — the v2 "Studio Terminal" shell — has
landed on the WPF host (`src/TeamsISO.App/`). A WinUI 3 replatform was
explored in early May 2026 and abandoned (activation blockers + redundant
work given the redesign is purely XAML / view-layer); the brief lives at
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`, and the
abandoned migration plan + bootstrap probe are archived under
`docs/archive/`.
## Configure
## Build
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:
Requires .NET 8 SDK on Windows. WPF is the only host:
- **Output** — framerate, resolution, aspect mode, audio routing
- **Network** — NDI discovery and output group names
- **App** — recording paths, startup behavior, theme
- `src/TeamsISO.App` — WPF, `net8.0-windows`, the shipping build
Per-participant overrides — click the **CFG** column gear on any row to
override framerate / resolution / aspect / audio for just that participant.
Build from the solution filter:
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
| Key | Action |
| --- | --- |
| `F1` | Open help / cheat sheet |
| `Ctrl + K` (or `Ctrl + P`) | Open the command palette |
| `Ctrl + K` | Open the command palette (also `Ctrl + P`) |
| `Ctrl + T` | Toggle theme (dark ↔ light) |
| `Ctrl + M` | Drop a timestamped marker into every active recording |
| `Ctrl + Shift + S` | Stop every running ISO (emergency) |
@ -84,45 +101,11 @@ override framerate / resolution / aspect / audio for just that participant.
| --- | --- |
| `%APPDATA%\TeamsISO\config.json` | Engine settings (framerate, NDI groups, etc.) |
| `%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 |
| `%USERPROFILE%\Videos\TeamsISO\<date>\` | Default recording output |
| `%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
Proprietary, © Wild Dragon LLC 2026. All rights reserved.
Proprietary, © Wild Dragon LLC 2026.

12
TeamsISO.Linux.slnf Normal file
View file

@ -0,0 +1,12 @@
{
"solution": {
"path": "TeamsISO.sln",
"projects": [
"src/TeamsISO.Engine/TeamsISO.Engine.csproj",
"src/TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj",
"src/TeamsISO.Console/TeamsISO.Console.csproj",
"src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj",
"src/tests/TeamsISO.Engine.IntegrationTests/TeamsISO.Engine.IntegrationTests.csproj"
]
}
}

14
TeamsISO.Windows.slnf Normal file
View file

@ -0,0 +1,14 @@
{
"solution": {
"path": "TeamsISO.sln",
"projects": [
"src\\TeamsISO.Engine\\TeamsISO.Engine.csproj",
"src\\TeamsISO.Engine.NdiInterop\\TeamsISO.Engine.NdiInterop.csproj",
"src\\TeamsISO.Console\\TeamsISO.Console.csproj",
"src\\TeamsISO.App\\TeamsISO.App.csproj",
"src\\tests\\TeamsISO.Engine.Tests\\TeamsISO.Engine.Tests.csproj",
"src\\tests\\TeamsISO.Engine.IntegrationTests\\TeamsISO.Engine.IntegrationTests.csproj",
"src\\tests\\TeamsISO.App.Tests\\TeamsISO.App.Tests.csproj"
]
}
}

View file

@ -1,72 +1,72 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{46E05E34-8A87-4986-87D3-FE0DE4E05F44}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine", "src\Dragon-ISO.Engine\Dragon-ISO.Engine.csproj", "{F0D24EAE-9225-4DC4-B3D2-6966077287A0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine.NdiInterop", "src\Dragon-ISO.Engine.NdiInterop\Dragon-ISO.Engine.NdiInterop.csproj", "{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DBDF4A1D-4215-42D5-B456-2CE7159DF848}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine.Tests", "src\tests\Dragon-ISO.Engine.Tests\Dragon-ISO.Engine.Tests.csproj", "{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.App", "src\Dragon-ISO.App\Dragon-ISO.App.csproj", "{80DCE039-3BBC-4D3F-B44B-51F324591C29}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine.IntegrationTests", "src\tests\Dragon-ISO.Engine.IntegrationTests\Dragon-ISO.Engine.IntegrationTests.csproj", "{A85E331D-026E-4BDE-B89C-0CC4C95001CE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Console", "src\Dragon-ISO.Console\Dragon-ISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.App.Tests", "src\tests\Dragon-ISO.App.Tests\Dragon-ISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.Build.0 = Release|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.Build.0 = Release|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.Build.0 = Release|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.Build.0 = Release|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.Build.0 = Release|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{DBDF4A1D-4215-42D5-B456-2CE7159DF848} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
{80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
EndGlobalSection
EndGlobal

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{46E05E34-8A87-4986-87D3-FE0DE4E05F44}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine", "src\TeamsISO.Engine\TeamsISO.Engine.csproj", "{F0D24EAE-9225-4DC4-B3D2-6966077287A0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.NdiInterop", "src\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj", "{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DBDF4A1D-4215-42D5-B456-2CE7159DF848}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.Tests", "src\tests\TeamsISO.Engine.Tests\TeamsISO.Engine.Tests.csproj", "{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App", "src\TeamsISO.App\TeamsISO.App.csproj", "{80DCE039-3BBC-4D3F-B44B-51F324591C29}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.IntegrationTests", "src\tests\TeamsISO.Engine.IntegrationTests\TeamsISO.Engine.IntegrationTests.csproj", "{A85E331D-026E-4BDE-B89C-0CC4C95001CE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src\TeamsISO.Console\TeamsISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App.Tests", "src\tests\TeamsISO.App.Tests\TeamsISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.Build.0 = Release|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.Build.0 = Release|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.Build.0 = Release|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.Build.0 = Release|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.Build.0 = Release|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{DBDF4A1D-4215-42D5-B456-2CE7159DF848} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
{80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
EndGlobalSection
EndGlobal

View file

@ -1,29 +1,36 @@
# Quick build + test verification before commit-and-push.ps1.
#
# Run from the repo root:
# pwsh -ExecutionPolicy Bypass -File .\build-and-test.ps1
#
# Builds TeamsISO.Windows.slnf in Release with TreatWarningsAsErrors=true
# (the Directory.Build.props default), then runs unit tests excluding the
# requires=ndi tier (those need a live NDI runtime).
$ErrorActionPreference = 'Stop'
if (-not (Test-Path 'Dragon-ISO.Windows.slnf')) {
throw "Run from the Dragon-ISO repo root."
if (-not (Test-Path 'TeamsISO.Windows.slnf')) {
throw "Run from the TeamsISO repo root."
}
$env:Path = "C:\Program Files\dotnet;$env:Path"
Write-Host "=== dotnet --version ===" -ForegroundColor Cyan
dotnet --version
Write-Host ""
Write-Host "=== Restore ===" -ForegroundColor Cyan
dotnet restore Dragon-ISO.Windows.slnf
dotnet restore TeamsISO.Windows.slnf
if ($LASTEXITCODE -ne 0) { throw "Restore failed." }
Write-Host ""
Write-Host "=== Build (Release, TreatWarningsAsErrors=true) ===" -ForegroundColor Cyan
dotnet build Dragon-ISO.Windows.slnf --configuration Release --no-restore --nologo
dotnet build TeamsISO.Windows.slnf --configuration Release --no-restore --nologo
if ($LASTEXITCODE -ne 0) {
throw "Build failed. Most likely cause for this batch: System.Windows.Automation needs an explicit Reference. If so, add to src/Dragon-ISO.App/Dragon-ISO.App.csproj inside an <ItemGroup>: <Reference Include='UIAutomationClient' /> <Reference Include='UIAutomationTypes' />"
throw "Build failed. Most likely cause for this batch: System.Windows.Automation needs an explicit Reference. If so, add to src/TeamsISO.App/TeamsISO.App.csproj inside an <ItemGroup>: <Reference Include='UIAutomationClient' /> <Reference Include='UIAutomationTypes' />"
}
Write-Host ""
Write-Host "=== Tests (excluding requires=ndi) ===" -ForegroundColor Cyan
dotnet test Dragon-ISO.Windows.slnf `
dotnet test TeamsISO.Windows.slnf `
--configuration Release `
--no-build `
--nologo `
@ -31,4 +38,4 @@ dotnet test Dragon-ISO.Windows.slnf `
if ($LASTEXITCODE -ne 0) { throw "Tests failed." }
Write-Host ""
Write-Host "Build + tests green." -ForegroundColor Green
Write-Host "Build + tests green. Now run .\commit-and-push.ps1 to ship." -ForegroundColor Green

443
commit-and-push.ps1 Normal file
View file

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

View file

@ -1,17 +1,17 @@
# Dragon-ISO Control Surface — REST API
# TeamsISO Control Surface — REST API
Dragon-ISO can expose a localhost HTTP server so external controllers
TeamsISO can expose a localhost HTTP server so external controllers
(Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator, custom
node-RED flows, command-line scripts) can drive it without a UI binding.
## Enabling
1. Open Dragon-ISO → Settings → DISPLAY tab.
1. Open TeamsISO → Settings → DISPLAY tab.
2. Tick "Control surface (Stream Deck / Companion)".
3. Default port is **9755**; change it via the port textbox if needed.
4. By default the server binds to `127.0.0.1` only — it is NOT reachable
4. By default the server binds to `127.0.0.1` only it is NOT reachable
from the LAN.
5. To allow other machines on the same network to drive Dragon-ISO (the
5. To allow other machines on the same network to drive TeamsISO (the
"headless host PC + thin client" scenario), tick the nested
"LAN-reachable" checkbox underneath. The settings panel will display
the LAN URL (e.g. `http://192.168.1.42:9755/ui`) with a Copy button.
@ -31,20 +31,20 @@ netsh http add urlacl url=http://+:9755/ user=Everyone
```
Also confirm the Windows Firewall is letting inbound traffic to that port
through — `New-NetFirewallRule -DisplayName "Dragon-ISO Control Surface" -Direction Inbound -Protocol TCP -LocalPort 9755 -Action Allow`
in an elevated PowerShell, or add it through Windows Defender Firewall →
Advanced Settings → Inbound Rules.
through `New-NetFirewallRule -DisplayName "TeamsISO Control Surface" -Direction Inbound -Protocol TCP -LocalPort 9755 -Action Allow`
in an elevated PowerShell, or add it through Windows Defender Firewall
Advanced Settings Inbound Rules.
## Authentication
None — by design. In localhost-only mode, the loopback bind is the
None by design. In localhost-only mode, the loopback bind is the
security model: any process on the operator's machine can hit these
endpoints, the same threat model as a Stream Deck's USB connection.
In LAN-reachable mode, the assumption is a closed/trusted network (a
production-control LAN, a dedicated show subnet, a private vlan). Any
machine that can route to the host on the listener port can drive
Dragon-ISO. **Do not enable LAN-reachable mode on an untrusted network.**
TeamsISO. **Do not enable LAN-reachable mode on an untrusted network.**
## Response shape
@ -58,7 +58,7 @@ specific fields. Errors return HTTP 4xx/5xx with `{"error": "..."}`.
### `GET /ui`
Self-contained HTML control panel. Open this in a browser to drive
Dragon-ISO from a phone, tablet, or second monitor. Lists participants live
TeamsISO from a phone, tablet, or second monitor. Lists participants live
via the same `/ws` WebSocket the rest of the doc describes, and posts to
the REST endpoints when you click. Single page, no external dependencies,
loads in <50KB.
@ -70,7 +70,7 @@ alive?" probes.
```json
{
"product": "Dragon-ISO",
"product": "TeamsISO",
"version": "1.0.0.0",
"endpoints": ["GET /participants", "POST /participants/{id}/iso", ...]
}
@ -89,7 +89,7 @@ Snapshot of the current participant list as the UI sees it.
"isOnline": true,
"isEnabled": false,
"customName": null,
"stateLabel": "—"
"stateLabel": ""
}
]
}
@ -103,7 +103,7 @@ Enable or disable an ISO by participant Id. Body or query string:
{ "enabled": true, "customName": "Host" }
```
`enabled` is optional — omitting it toggles the current state. `customName`
`enabled` is optional omitting it toggles the current state. `customName`
is optional and overrides the auto-generated NDI output name.
```sh
@ -174,7 +174,7 @@ Toggle per-output recording on or off. Body or query string:
```
`directory` is optional when `enabled=false`. Already-running ISOs are not
retroactively recorded — the operator should disable + re-enable a
retroactively recorded the operator should disable + re-enable a
participant to start recording it.
### `POST /recording/marker`
@ -191,8 +191,8 @@ curl -X POST 'http://127.0.0.1:9755/recording/marker?label=Guest+answer'
### `POST /notes`
Append a timestamped line to today's show-notes file at
`%LOCALAPPDATA%\Dragon-ISO\Notes\<YYYY-MM-DD>.md`. Body or query string carries
`text`. Each line is prefixed with `**HH:mm:ss** —`; the file is markdown so
`%LOCALAPPDATA%\TeamsISO\Notes\<YYYY-MM-DD>.md`. Body or query string carries
`text`. Each line is prefixed with `**HH:mm:ss** `; the file is markdown so
it renders nicely in any editor.
```sh
@ -204,7 +204,7 @@ curl -X POST 'http://127.0.0.1:9755/notes?text=guest+segment+starts'
Roll every active recording into a new chunk. Each running pipeline is
disabled (recorder finalizes its `manifest.json`), waits ~150ms, then re-
enabled (recorder opens a fresh subdirectory keyed by display name +
timestamp). Useful for chaptering between show segments — a Stream Deck
timestamp). Useful for chaptering between show segments a Stream Deck
button mapped to this gives operators "next segment" without losing the
already-recorded footage.
@ -217,7 +217,7 @@ Response:
{ "ok": true, "action": "roll-recording", "rolled": 4 }
```
## WebSocket — live state push
## WebSocket live state push
For controllers that want to light a button when an ISO goes LIVE without
polling, connect to:
@ -240,37 +240,37 @@ snapshot is pushed within 250ms. Format:
}
```
Clientâ†server messages are ignored for v1 — all commands go through REST.
Client→server messages are ignored for v1 — all commands go through REST.
## OSC over UDP
Same command surface, different transport. Enable the OSC bridge in the
DISPLAY tab (default port **9000** — TouchOSC's default). Bound to
DISPLAY tab (default port **9000** TouchOSC's default). Bound to
`127.0.0.1` by default; honors the same LAN-reachable toggle as the REST
surface — when LAN mode is on, OSC binds to `0.0.0.0` so a TouchOSC tablet
surface when LAN mode is on, OSC binds to `0.0.0.0` so a TouchOSC tablet
on the same network can talk to the host directly.
Address vocabulary:
```
/Dragon-ISO/iso "DisplayName" {0|1} — toggle/set ISO by display name
/Dragon-ISO/iso/by-id "guid" {0|1} — toggle/set by Id
/Dragon-ISO/preset "Name" — apply preset
/Dragon-ISO/teams/mute — UIA toggle mute
/Dragon-ISO/teams/camera — UIA toggle camera
/Dragon-ISO/teams/leave — UIA leave
/Dragon-ISO/teams/share — UIA share tray
/Dragon-ISO/teams/raise-hand — UIA raise hand
/Dragon-ISO/refresh-discovery — rebuild NDI finder
/Dragon-ISO/stop-all — disable every ISO
/Dragon-ISO/recording {0|1} — recording on/off (default dir)
/Dragon-ISO/recording/marker "Label" — drop a marker on every active recording
/Dragon-ISO/recording/roll — roll every active recording into a new chunk
/Dragon-ISO/notes "Free-form note" — append a timestamped line to today's notes
/teamsiso/iso "DisplayName" {0|1} — toggle/set ISO by display name
/teamsiso/iso/by-id "guid" {0|1} — toggle/set by Id
/teamsiso/preset "Name" — apply preset
/teamsiso/teams/mute — UIA toggle mute
/teamsiso/teams/camera — UIA toggle camera
/teamsiso/teams/leave — UIA leave
/teamsiso/teams/share — UIA share tray
/teamsiso/teams/raise-hand — UIA raise hand
/teamsiso/refresh-discovery — rebuild NDI finder
/teamsiso/stop-all — disable every ISO
/teamsiso/recording {0|1} — recording on/off (default dir)
/teamsiso/recording/marker "Label" — drop a marker on every active recording
/teamsiso/recording/roll — roll every active recording into a new chunk
/teamsiso/notes "Free-form note" — append a timestamped line to today's notes
```
Companion → Surfaces → "Generic OSC" supports outbound OSC; bind a button
press to e.g. `/Dragon-ISO/iso "Jane" 1`. TouchOSC layouts can use the same
Companion → Surfaces → "Generic OSC" supports outbound OSC; bind a button
press to e.g. `/teamsiso/iso "Jane" 1`. TouchOSC layouts can use the same
addresses on the same UDP port.
## Bitfocus Companion recipe
@ -292,7 +292,7 @@ on the appropriate endpoint above.
## Future work
- **HTTPS / token auth** — for deployments that don't have a closed
- **HTTPS / token auth** for deployments that don't have a closed
network, layer TLS termination + a shared bearer token in front of the
HttpListener. Out of scope for v1; the LAN-reachable mode is a
trusted-network feature only.

View file

@ -1,4 +1,4 @@
# Real-time H.264 recording
# Real-time H.264 recording
The default recorder (`RawBgraRecorderSink`) writes uncompressed BGRA to disk
and ships a `convert.cmd` for post-recording FFmpeg encoding. That's safe
@ -7,32 +7,32 @@ and ships a `convert.cmd` for post-recording FFmpeg encoding. That's safe
For long shows or operators on slower disks, the engine ships a
**`MediaFoundationRecorderSink`** that encodes to H.264 in real time using
Windows Media Foundation. Inline encoding cuts disk pressure ~10× and
Windows Media Foundation. Inline encoding cuts disk pressure ~10× and
produces a finished `.mp4` without the convert step.
It's behind a build flag because activating it requires adding a NuGet
dependency. The structural code is already in
`src/Dragon-ISO.Engine/Pipeline/MediaFoundationRecorderSink.cs`.
`src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs`.
## Status — May 2026
## Status May 2026
**Activation deferred.** The Vortice.MediaFoundation 3.6.2 NuGet package
is referenced from `Dragon-ISO.Engine.csproj`, but the `MF_AVAILABLE` symbol
is referenced from `TeamsISO.Engine.csproj`, but the `MF_AVAILABLE` symbol
is *not* defined. The scaffold in
`src/Dragon-ISO.Engine/Pipeline/MediaFoundationRecorderSink.cs` was written
`src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs` was written
against an older Vortice API and needs a port pass before activation:
- `MFVersion` → not on `MediaFactory` in 3.6.2; pass the SDK version
- `MFVersion` not on `MediaFactory` in 3.6.2; pass the SDK version
directly to `MFStartup`.
- `MediaFactory.MF_LOW_LATENCY` → relocated to a different attribute
- `MediaFactory.MF_LOW_LATENCY` relocated to a different attribute
constants class.
- `IMFAttributes.SetUINT32` → replaced with a generic `Set` overload.
- `IMFAttributes.SetUINT32` replaced with a generic `Set` overload.
- `IMFMediaType.MajorType` / `.SubType` / `.AvgBitrate` properties
→ now use `SetGUID(MFAttributeKeys.MajorType, ...)` etc.
- `VideoFormatGuids.RGB32` → renamed (likely `Rgb32`).
- `IMFMediaBuffer.Lock(out IntPtr, out int, out int)` → explicit out-param
now use `SetGUID(MFAttributeKeys.MajorType, ...)` etc.
- `VideoFormatGuids.RGB32` renamed (likely `Rgb32`).
- `IMFMediaBuffer.Lock(out IntPtr, out int, out int)` explicit out-param
signature, no longer returns a locked-buffer wrapper.
- `IMFSinkWriter.Finalize_` → renamed (likely `Finalize`).
- `IMFSinkWriter.Finalize_` renamed (likely `Finalize`).
Until the port lands, the `RawBgraRecorderSink` is the only IRecorderSink
production uses. The raw recorder is reliable and FFmpeg post-processing
@ -45,7 +45,7 @@ disk pressure during the show.
reference implementation lives in the Vortice samples repo under
`samples/MediaFoundationSamples`.
2. **Define the `MF_AVAILABLE` build symbol** in `Dragon-ISO.Engine.csproj`:
2. **Define the `MF_AVAILABLE` build symbol** in `TeamsISO.Engine.csproj`:
```xml
<PropertyGroup>
@ -71,10 +71,10 @@ disk pressure during the show.
## What the MF recorder produces
For each enabled ISO with recording on:
- `<recordings>/<participant>/output.mp4` — H.264 video at the engine's
- `<recordings>/<participant>/output.mp4` H.264 video at the engine's
configured resolution / framerate, target bitrate ~0.07 bits/pixel
(~7 Mbps for 1080p30, ~3 Mbps for 720p30).
- `<recordings>/<participant>/markers.txt` — tab-separated marker offsets
- `<recordings>/<participant>/markers.txt` tab-separated marker offsets
from `IIsoController.AddRecordingMarker`. Manually chapter the .mp4 with
`mp4chaps -c -i markers.txt output.mp4` (mp4chaps from the `mp4v2` tools).
@ -91,5 +91,5 @@ For each enabled ISO with recording on:
| Reliable on legacy GPUs | Yes | Yes (MF falls back to software encoder if no hw H.264) |
If your target machines have NVIDIA NVENC / Intel QuickSync, MF will use
the hardware encoder transparently — that's the path that gives you
the hardware encoder transparently that's the path that gives you
multi-stream realtime H.264 with low CPU.

View file

@ -1,4 +1,4 @@
# Releasing Dragon-ISO
# Releasing TeamsISO
The release workflow at `.forgejo/workflows/release.yml` runs on **annotated tag pushes
matching `v*.*.*`**. It builds, tests, publishes, packages an MSI, and uploads the
@ -11,26 +11,26 @@ MSI as a release asset.
separate Windows runner. Register one with `forgejo-runner register` against a
Windows host that has the .NET 8 SDK + WiX SDK access (the WiX SDK pulls itself
via NuGet at build time, so no separate install).
- The repository's **Create release on tag push** setting on (default), or skip it —
- The repository's **Create release on tag push** setting on (default), or skip it
the workflow will create the release if one doesn't exist.
## Cutting a release
```sh
# Bump the version in Directory.Build.props if you haven't already.
git tag -a v1.0.0 -m "Dragon-ISO 1.0.0"
git tag -a v1.0.0 -m "TeamsISO 1.0.0"
git push origin v1.0.0
```
The workflow will:
1. Restore + build `Dragon-ISO.Windows.slnf` in Release with the tag's version.
2. Run unit tests (the `requires=ndi` integration tier is skipped — it needs a
1. Restore + build `TeamsISO.Windows.slnf` in Release with the tag's version.
2. Run unit tests (the `requires=ndi` integration tier is skipped it needs a
real NDI runtime which a CI runner won't have).
3. Publish `Dragon-ISO.App` and `Dragon-ISO.Console` for `win-x64`,
3. Publish `TeamsISO.App` and `TeamsISO.Console` for `win-x64`,
framework-dependent (.NET 8 Desktop runtime is the user's responsibility).
4. Build `installer/Dragon-ISO.Installer.wixproj`, producing
`Dragon-ISO-Setup-<version>.msi`.
4. Build `installer/TeamsISO.Installer.wixproj`, producing
`TeamsISO-Setup-<version>.msi`.
5. Upload the MSI as a workflow artifact (downloadable from the run page).
6. Attach the MSI to the GitHub-style Release for the tag, creating the release
first if it doesn't exist. Pre-release flag is set automatically when the
@ -39,13 +39,13 @@ The workflow will:
## Code signing
The release workflow has optional signtool integration. It runs only when the
signing-cert secrets are configured on the repository — without them, builds
signing-cert secrets are configured on the repository without them, builds
remain unsigned and produce a SmartScreen warning on first launch.
### Enabling signing
Set these secrets on `forge.wilddragon.net/zgaetano/Dragon-ISO`
→ Settings → Actions → Secrets:
Set these secrets on `forge.wilddragon.net/zgaetano/teamsiso`
→ Settings → Actions → Secrets:
| Secret | Required | Notes |
| --- | --- | --- |
@ -56,7 +56,7 @@ Set these secrets on `forge.wilddragon.net/zgaetano/Dragon-ISO`
When all three are present, the workflow:
1. Decodes the PFX to a temp file on the runner before building.
2. Signs `publish/Dragon-ISO/Dragon-ISO.exe` after publish, before MSI build, so the
2. Signs `publish/TeamsISO/TeamsISO.exe` after publish, before MSI build, so the
binary embedded in the MSI is signed too.
3. Signs the produced MSI itself after WiX builds it.
4. Wipes the temp PFX from disk.
@ -70,7 +70,7 @@ which is what current Microsoft / SmartScreen guidance requires.
per-publisher over time; brand-new OV certs still trip the warning until
enough downloads accumulate.
- **EV (Extended Validation, ~$300/yr, hardware token).** SmartScreen-trusted
immediately. Token-based — to use one in CI you'll need to either (a) keep
immediately. Token-based to use one in CI you'll need to either (a) keep
the runner on a host with the token plugged in, or (b) move to a cloud
signing service like Azure Trusted Signing or DigiCert KeyLocker.

View file

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

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

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

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

@ -0,0 +1,799 @@
<!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.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

View file

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

File diff suppressed because it is too large Load diff

View file

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

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

@ -0,0 +1,43 @@
# 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,520 +0,0 @@
# Dragon-ISO Installer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rebrand the WiX v5 MSI installer from TeamsISO to Dragon-ISO, producing `Dragon-ISO-Setup-1.0.0.0.msi` for end-user download.
**Architecture:** Rename the `.wixproj` file, rewrite `Package.wxs` with Dragon-ISO branding and a simple `WixUI_Minimal` UI (no directory picker), and fix a bug in `release.yml` where the signing step references the wrong executable filename.
**Tech Stack:** WiX Toolset v5, MSBuild, PowerShell; no unit tests (installer files are build-verified by `dotnet build`)
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `installer/TeamsISO.Installer.wixproj` | Rename + rewrite | MSBuild project — output name, publish dir, asset dir |
| `installer/Dragon-ISO.Installer.wixproj` | Created by rename | Same as above, Dragon-ISO branded |
| `installer/Package.wxs` | Rewrite | WiX source — all installer logic, shortcuts, metadata |
| `.forgejo/workflows/release.yml` | Fix line 119 | Fix `Dragon-ISO.exe``DragonISO.exe` (exe filename matches AssemblyName) |
> **Important:** `Dragon-ISO.App.csproj` has `<AssemblyName>DragonISO</AssemblyName>` (no hyphen). The published executable is therefore `DragonISO.exe`, not `Dragon-ISO.exe`. All shortcut targets and signing steps must use `DragonISO.exe`.
---
## Task 1: Rename the .wixproj file
**Files:**
- Rename: `installer/TeamsISO.Installer.wixproj``installer/Dragon-ISO.Installer.wixproj`
- [ ] **Step 1: Rename the file using git mv**
```powershell
cd C:\Users\zacga\source\repos\Dragon-ISO
git mv installer/TeamsISO.Installer.wixproj installer/Dragon-ISO.Installer.wixproj
```
- [ ] **Step 2: Verify the rename**
```powershell
Get-ChildItem installer/
```
Expected: `Dragon-ISO.Installer.wixproj` and `Package.wxs` (no `TeamsISO.Installer.wixproj`)
---
## Task 2: Rewrite Dragon-ISO.Installer.wixproj
**Files:**
- Modify: `installer/Dragon-ISO.Installer.wixproj`
- [ ] **Step 1: Replace the file content entirely**
Write the following to `installer/Dragon-ISO.Installer.wixproj`:
```xml
<Project Sdk="WixToolset.Sdk/5.0.2">
<PropertyGroup>
<OutputType>Package</OutputType>
<OutputName>Dragon-ISO-Setup-$(Version)</OutputName>
<!-- 64-bit MSI; suppresses ICE80 on components in Program Files (x64). -->
<Platform>x64</Platform>
<InstallerPlatform>x64</InstallerPlatform>
<!--
Built artifact location. The installer expects a published build of
Dragon-ISO.App rooted here. CI / local script:
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj
-c Release -r win-x64 --self-contained false
-o $(SolutionDir)publish/Dragon-ISO
-->
<PublishDir>$(MSBuildThisFileDirectory)..\publish\Dragon-ISO\</PublishDir>
<!-- Pass MSBuild values into WiX preprocessor. -->
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\Dragon-ISO.App\Assets\</DefineConstants>
<!-- Code-signing hook (set externally for release builds; left empty for dev). -->
<SignOutput Condition=" '$(SignOutput)' == '' ">false</SignOutput>
</PropertyGroup>
<!--
Reference the WiX UI extension so the MSI shows a friendly progress UI
instead of the silent default.
-->
<ItemGroup>
<PackageReference Include="WixToolset.UI.wixext" Version="5.0.2" />
</ItemGroup>
</Project>
```
- [ ] **Step 2: Verify the file reads back correctly**
```powershell
Get-Content installer/Dragon-ISO.Installer.wixproj | Select-String "OutputName|PublishDir|AssetsDir"
```
Expected output (3 lines):
```
<OutputName>Dragon-ISO-Setup-$(Version)</OutputName>
<PublishDir>$(MSBuildThisFileDirectory)..\publish\Dragon-ISO\</PublishDir>
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\Dragon-ISO.App\Assets\</DefineConstants>
```
---
## Task 3: Rewrite Package.wxs with Dragon-ISO branding
**Files:**
- Modify: `installer/Package.wxs`
Changes from the original TeamsISO version:
- Package Name: "TeamsISO" → "Dragon-ISO"
- SummaryInformation description and keywords updated
- MajorUpgrade error message updated
- Feature Title: "TeamsISO" → "Dragon-ISO"
- UI switched from `WixUI_InstallDir` (shows dir picker) → `WixUI_Minimal` (Welcome → Install → Finish)
- `WIXUI_INSTALLDIR` property removed (not used by WixUI_Minimal)
- ARPHELPLINK URL: teamsiso → dragon-iso
- ARPCOMMENTS: "TeamsISO" → "Dragon-ISO"
- Icon Id: "TeamsISOIcon" → "DragonISOIcon"
- Icon SourceFile: `teamsiso.ico``Dragon-ISO.ico`
- ARPPRODUCTICON value: "TeamsISOIcon" → "DragonISOIcon"
- Added .NET 8 Desktop Runtime detection property
- Install directory Name: "TeamsISO" → "Dragon-ISO"
- Start Menu shortcut Id/Name/Target/Icon updated
- Desktop shortcut Id/Name/Target/Icon updated
- All registry keys: `Software\Wild Dragon\TeamsISO``Software\Wild Dragon\Dragon-ISO`
- Shortcut targets: `TeamsISO.exe``DragonISO.exe` (matches AssemblyName, no hyphen)
- [ ] **Step 1: Replace Package.wxs entirely with Dragon-ISO branded content**
Write the following to `installer/Package.wxs`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!--
Dragon-ISO — MSI installer (WiX v5)
Produces: Dragon-ISO-Setup-<Version>.msi (per-machine install).
Build:
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj -c Release -r win-x64 --self-contained false -o publish/Dragon-ISO
dotnet build installer/Dragon-ISO.Installer.wixproj -c Release
Runtime expectations:
- .NET 8 Desktop runtime present on target (framework-dependent build)
- NDI 6 Runtime present — checked in NdiRuntimeDirV6Search; absence WARNS
but does not block install (operators can install NDI after the app)
Exe filename note:
Dragon-ISO.App.csproj sets AssemblyName=DragonISO (no hyphen — CLR
assembly names cannot contain hyphens). The published executable is
therefore DragonISO.exe. Shortcut targets reference DragonISO.exe.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package Name="Dragon-ISO"
Manufacturer="Wild Dragon LLC"
Version="1.0.0.0"
UpgradeCode="9F4A8B2C-1D3E-4A5B-9C6D-8E7F0A1B2C3D"
Scope="perMachine"
Compressed="yes"
InstallerVersion="500">
<!--
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="Dragon-ISO — 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="Dragon-ISO, NDI, Microsoft Teams, ISO recording, broadcast, live production, vMix, OBS, switcher, Wild Dragon" />
<!--
MajorUpgrade: a newer install replaces an older one in-place. We
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 Dragon-ISO is already installed. Uninstall it before installing this older version."
Schedule="afterInstallInitialize" />
<!--
Single MSI feature; users see only the install/uninstall screens.
-->
<Feature Id="Main" Title="Dragon-ISO" Level="1">
<ComponentGroupRef Id="ApplicationFiles" />
<ComponentGroupRef Id="Shortcuts" />
<ComponentGroupRef Id="DesktopShortcut" />
<ComponentGroupRef Id="ArpEntry" />
</Feature>
<!--
Minimal install UI: Welcome/License → Progress → Finish.
No directory picker — installs to Program Files\Wild Dragon\Dragon-ISO.
-->
<ui:WixUI Id="WixUI_Minimal" />
<!--
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://forge.wilddragon.net/zgaetano/dragon-iso" />
<Property Id="ARPURLINFOABOUT" Value="https://wilddragon.net" />
<Property Id="ARPCONTACT" Value="Wild Dragon LLC — support@wilddragon.net" />
<Property Id="ARPCOMMENTS" Value="Dragon-ISO 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_Minimal; don't redeclare. -->
<Property Id="ARPNOREPAIR" Value="1" />
<!--
ARP icon — references the same .ico the WPF host uses. WiX requires the
icon resource to live next to the wxs OR be reachable at build time;
we point at the source copy under src/Dragon-ISO.App/Assets so the icon
embedded in the MSI matches the icon in the running exe.
-->
<Icon Id="DragonISOIcon" SourceFile="$(var.AssetsDir)Dragon-ISO.ico" />
<Property Id="ARPPRODUCTICON" Value="DragonISOIcon" />
<!--
.NET 8 Desktop Runtime detection. The .NET apphost will surface a
"framework not found" dialog naturally if the runtime is absent;
this property is available for future conditional logic.
VBScript-based install-time dialogs are deprecated in WiX v5 / Windows;
rewriting in C++ is overkill for a soft warning on a soft dependency.
-->
<Property Id="DOTNET8DESKTOPRUNTIME" Value="0">
<RegistrySearch Id="DotNet8DesktopRuntimeSearch"
Root="HKLM"
Key="SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App"
Name="8.0.0"
Type="raw" />
</Property>
<!--
NDI Runtime detection. We check for NDI_RUNTIME_DIR_V6 in the system
environment block. Missing → warn during install, don't block. The
engine surfaces a clear MessageBox with an install-NDI link at first
launch if the runtime really isn't there.
-->
<Property Id="NDIRUNTIMEDIR" Value="0">
<RegistrySearch Id="NdiRuntimeDirV6Search"
Root="HKLM"
Key="SYSTEM\CurrentControlSet\Control\Session Manager\Environment"
Name="NDI_RUNTIME_DIR_V6"
Type="raw" />
</Property>
<!--
NDI runtime detection is surfaced at first app launch (App.xaml.cs pops a
MessageBox with an install link). We deliberately don't block install on
a missing runtime so admins can stage the app before NDI is rolled out.
VBScript-based install-time prompts are deprecated in WiX v5 / Windows
and rewriting in C++ is overkill for a soft warning.
-->
<!--
Install layout under Program Files\Wild Dragon\Dragon-ISO.
-->
<StandardDirectory Id="ProgramFiles64Folder">
<Directory Id="ManufacturerFolder" Name="Wild Dragon">
<Directory Id="INSTALLFOLDER" Name="Dragon-ISO" />
</Directory>
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder">
<Directory Id="WildDragonStartMenuFolder" Name="Wild Dragon" />
</StandardDirectory>
<!--
Files: harvested from the publish output dir at build time.
WiX v5 understands <Files Include="..."> with glob patterns and
synthesizes one Component per file with stable GUIDs.
-->
<ComponentGroup Id="ApplicationFiles" Directory="INSTALLFOLDER">
<Files Include="$(var.PublishDir)**" />
</ComponentGroup>
<!--
Start Menu and Desktop shortcuts — direct .exe targets.
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 Dragon-ISO inherit the launching
token (medium or high integrity, doesn't matter) is the correct
behavior. NDI discovery works fine at either integrity level.
Exe filename: AssemblyName=DragonISO (no hyphen), so target is
DragonISO.exe not Dragon-ISO.exe.
-->
<ComponentGroup Id="Shortcuts" Directory="WildDragonStartMenuFolder">
<Component Id="StartMenuShortcut" Guid="*">
<Shortcut Id="StartMenuDragonISO"
Name="Dragon-ISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]DragonISO.exe"
WorkingDirectory="INSTALLFOLDER"
Icon="DragonISOIcon" />
<!-- Required by ICE64: Start Menu folder must be cleaned on uninstall. -->
<RemoveFolder Id="RemoveWildDragonStartMenuFolder"
Directory="WildDragonStartMenuFolder"
On="uninstall" />
<RegistryValue Root="HKCU"
Key="Software\Wild Dragon\Dragon-ISO"
Name="StartMenuShortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</ComponentGroup>
<StandardDirectory Id="DesktopFolder" />
<ComponentGroup Id="DesktopShortcut" Directory="DesktopFolder">
<Component Id="DesktopShortcutComponent" Guid="*">
<Shortcut Id="DesktopDragonISO"
Name="Dragon-ISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]DragonISO.exe"
WorkingDirectory="INSTALLFOLDER"
Icon="DragonISOIcon" />
<RegistryValue Root="HKCU"
Key="Software\Wild Dragon\Dragon-ISO"
Name="DesktopShortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</ComponentGroup>
<!--
ARP icon registry entry. Optional — the MSI auto-fills most ARP
fields from the Package element. We only need to store the install
path for diagnostic / uninstall tooling.
-->
<ComponentGroup Id="ArpEntry" Directory="INSTALLFOLDER">
<Component Id="ArpIconRegistry" Guid="*">
<RegistryValue Root="HKLM"
Key="Software\Wild Dragon\Dragon-ISO"
Name="InstallPath"
Type="string"
Value="[INSTALLFOLDER]"
KeyPath="yes" />
</Component>
</ComponentGroup>
</Package>
</Wix>
```
- [ ] **Step 2: Verify no "TeamsISO" strings remain in Package.wxs**
```powershell
Select-String -Path installer/Package.wxs -Pattern "TeamsISO"
```
Expected: no output (zero matches)
---
## Task 4: Fix release.yml — wrong exe filename in signing step
**Files:**
- Modify: `.forgejo/workflows/release.yml` line 119
The signing step references `publish/Dragon-ISO/Dragon-ISO.exe` but the app's `AssemblyName` is `DragonISO`, so the published exe is `DragonISO.exe`. Fix it.
- [ ] **Step 1: Edit release.yml to fix the exe path**
In `.forgejo/workflows/release.yml`, find and replace:
Old (line 119):
```
'publish/Dragon-ISO/Dragon-ISO.exe'
```
New:
```
'publish/Dragon-ISO/DragonISO.exe'
```
- [ ] **Step 2: Verify the fix**
```powershell
Select-String -Path .forgejo/workflows/release.yml -Pattern "Dragon-ISO\.exe|DragonISO\.exe"
```
Expected output:
```
.forgejo/workflows/release.yml:119: 'publish/Dragon-ISO/DragonISO.exe'
```
(One match, using `DragonISO.exe` with no hyphen)
---
## Task 5: Check WiX workload and verify build
**Files:** None (verification only)
- [ ] **Step 1: Check if WiX workload is installed**
```powershell
dotnet workload list
```
Expected: output includes `wix` in the list. If not installed, run:
```powershell
dotnet workload install wix
```
- [ ] **Step 2: Publish the app to the expected location**
```powershell
cd C:\Users\zacga\source\repos\Dragon-ISO
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj `
-c Release -r win-x64 --self-contained false `
-o publish/Dragon-ISO
```
Expected: ends with `Build succeeded.` and creates `publish/Dragon-ISO/DragonISO.exe`
- [ ] **Step 3: Verify the exe filename in the publish output**
```powershell
Get-ChildItem publish/Dragon-ISO/ -Filter "*.exe"
```
Expected: one file named `DragonISO.exe` (confirms shortcut targets are correct)
- [ ] **Step 4: Build the MSI**
```powershell
dotnet build installer/Dragon-ISO.Installer.wixproj -c Release /p:Version=1.0.0.0
```
Expected: ends with `Build succeeded.` — no errors, no warnings.
- [ ] **Step 5: Verify the MSI was produced with the correct name**
```powershell
Get-ChildItem installer/bin -Recurse -Filter "*.msi"
```
Expected: one file named `Dragon-ISO-Setup-1.0.0.0.msi`
---
## Task 6: Commit all changes
- [ ] **Step 1: Stage the changed files**
```powershell
cd C:\Users\zacga\source\repos\Dragon-ISO
git add installer/Dragon-ISO.Installer.wixproj
git add installer/Package.wxs
git add .forgejo/workflows/release.yml
```
- [ ] **Step 2: Verify nothing unexpected is staged**
```powershell
git status
```
Expected staged files:
- `installer/Dragon-ISO.Installer.wixproj` (renamed from TeamsISO.Installer.wixproj)
- `installer/Package.wxs` (modified)
- `.forgejo/workflows/release.yml` (modified)
No other files should be staged.
- [ ] **Step 3: Commit**
```powershell
git commit -m "$(cat <<'EOF'
rebrand installer from TeamsISO to Dragon-ISO
- Rename TeamsISO.Installer.wixproj → Dragon-ISO.Installer.wixproj
- Update Package.wxs: product name, shortcuts, registry keys, ARP
metadata, install directory, and icon all updated to Dragon-ISO
- Switch UI from WixUI_InstallDir to WixUI_Minimal (no dir picker)
- Add .NET 8 Desktop Runtime detection property
- Fix release.yml: signing step referenced Dragon-ISO.exe but
AssemblyName=DragonISO so exe is DragonISO.exe (no hyphen)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
EOF
)"
```
---
## Testing Checklist (manual verification after install)
Once the MSI is built, install it on a test machine and verify:
- [ ] `Dragon-ISO-Setup-1.0.0.0.msi` installs without errors
- [ ] App installs to `C:\Program Files\Wild Dragon\Dragon-ISO\`
- [ ] `DragonISO.exe` is present in the install folder
- [ ] Start Menu shows `Wild Dragon → Dragon-ISO` shortcut with correct icon
- [ ] Desktop shows `Dragon-ISO` shortcut with correct icon
- [ ] Both shortcuts launch the app successfully
- [ ] Add/Remove Programs shows:
- Name: Dragon-ISO
- Publisher: Wild Dragon LLC
- Version: 1.0.0.0
- Help link: `https://forge.wilddragon.net/zgaetano/dragon-iso`
- [ ] Uninstall removes all files, shortcuts, and registry entries
- [ ] `%APPDATA%\Dragon-ISO\` (user config) is NOT removed on uninstall

View file

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

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

@ -0,0 +1,106 @@
# 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,207 +0,0 @@
# Dragon-ISO Installer Design
**Date:** 2026-05-31
**Status:** Design Complete
**Audience:** End users downloading and installing Dragon-ISO
---
## Overview
Update the existing WiX Toolset v5 MSI installer from TeamsISO branding to Dragon-ISO. The installer provides a simple, professional Windows installation experience for end users with minimal choices (Next → Install).
**Key characteristics:**
- Per-machine installation to `Program Files\Wild Dragon\Dragon-ISO\`
- Simple UI (no customization dialogs)
- Start Menu and Desktop shortcuts
- Professional Add/Remove Programs (ARP) metadata
- Prerequisite detection for .NET 8 Desktop Runtime and NDI 6 Runtime (warn, don't block)
- Version: 1.0.0.0
---
## File and Naming Structure
### Files to Rename
- `installer/TeamsISO.Installer.wixproj``installer/Dragon-ISO.Installer.wixproj`
- `installer/Package.wxs` → remains (generic name, no change needed)
### Build Output
- **Current (TeamsISO):** `TeamsISO-Setup-1.0.0.0.msi`
- **Updated (Dragon-ISO):** `Dragon-ISO-Setup-1.0.0.0.msi`
### Asset References
- Icon: References `Dragon-ISO.ico` (already exists in `src/Dragon-ISO.App/Assets/`)
- App directory: `src/Dragon-ISO.App/` (updated from `src/TeamsISO.App/`)
- Publish output: `publish/Dragon-ISO/` (updated from `publish/TeamsISO/`)
---
## Branding Updates (in Package.wxs)
### Product Metadata
- **Package Name:** `"Dragon-ISO"` (from `"TeamsISO"`)
- **Manufacturer:** `"Wild Dragon LLC"` (unchanged)
- **UpgradeCode:** Keep existing GUID (allows upgrades from TeamsISO to Dragon-ISO)
- **Version:** `1.0.0.0`
### Summary Information
- **Description:** "Dragon-ISO — per-participant NDI ISO controller for Microsoft Teams. Splits each Teams participant into a normalized NDI source for vMix / OBS / Ross / hardware switchers."
- **Keywords:** "Dragon-ISO, NDI, Microsoft Teams, ISO recording, broadcast, live production, vMix, OBS, switcher, Wild Dragon"
### Add/Remove Programs (ARP) Metadata
- **Help Link:** `https://forge.wilddragon.net/zgaetano/dragon-iso`
- **About Link:** `https://wilddragon.net`
- **Support Contact:** `Wild Dragon LLC — support@wilddragon.net`
- **Comments:** "Dragon-ISO 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."
### Registry Keys
- **Location:** `Software\Wild Dragon\Dragon-ISO` (from `Software\Wild Dragon\TeamsISO`)
- Used for: Start Menu shortcut tracking, Desktop shortcut tracking, Install path storage
### Shortcuts
- **Start Menu:** `Wild Dragon → Dragon-ISO`
- **Desktop:** `Dragon-ISO` shortcut
- **Target:** `[INSTALLFOLDER]Dragon-ISO.exe`
- **Icon:** Dragon-ISO icon from assets
- **Description:** "Per-Participant NDI ISO Controller for Microsoft Teams"
---
## Installation Layout
### Directory Structure
```
Program Files\Wild Dragon\Dragon-ISO\
├── Dragon-ISO.exe (main executable)
├── DragonISO.dll (core assembly)
├── Assets\ (icons, fonts, resources)
├── Themes\ (XAML theme files)
├── [.NET dependencies] (runtime assets, supporting DLLs)
└── [all other published files]
```
### Shortcuts Created
- **Start Menu:** `%ProgramMenu%\Wild Dragon\Dragon-ISO.lnk`
- **Desktop:** `%UserProfile%\Desktop\Dragon-ISO.lnk`
- Both use stable GUIDs for reliable uninstall tracking
### Uninstall Behavior
- Standard Windows uninstall via Add/Remove Programs
- Removes all application files and shortcuts
- Removes registry entries under `Software\Wild Dragon\Dragon-ISO`
- **Preserves:** User config files in `%APPDATA%\Dragon-ISO\` (for future upgrades)
### Add/Remove Programs Entry
Users see:
- **Name:** Dragon-ISO
- **Version:** 1.0.0.0
- **Publisher:** Wild Dragon LLC
- **Help:** Link to Forge repository
- **About:** Link to wilddragon.net
- **Contact:** support@wilddragon.net
- **Icon:** Dragon-ISO app icon
---
## Build Process
### Prerequisites
- .NET 8 SDK
- WiX Toolset v5 (`dotnet workload install wix`)
### Build Steps
1. **Publish the application:**
```powershell
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj `
-c Release -r win-x64 `
-o publish/Dragon-ISO
```
2. **Build the MSI:**
```powershell
dotnet build installer/Dragon-ISO.Installer.wixproj -c Release
```
3. **Output:**
- Location: `installer/bin/Release/Dragon-ISO-Setup-1.0.0.0.msi`
- Ready for end-user distribution
### Version Management
- MSI version is driven by the app's `.csproj` `<Version>` tag
- Update version once, builds auto-propagate to the MSI filename
- UpgradeCode remains constant across versions (enables upgrade detection)
### CI/CD Integration
- Update existing `.forgejo/workflows/` to use new project paths
- Scripts already handle publish → build → sign → release
- Just update path references and output filename
---
## Prerequisites & Runtime Detection
### .NET 8 Desktop Runtime
- **Check:** Registry lookup for installed .NET 8 Desktop Runtime
- **If missing:** Display warning dialog with link to download
- **Install behavior:** Continue installation anyway (user can install .NET 8 later)
- **At app launch:** App checks again; shows MessageBox with install link if still missing
### NDI 6 Runtime
- **Check:** Environment variable `NDI_RUNTIME_DIR_V6`
- **If missing:** Display warning dialog
- **Install behavior:** Continue installation anyway
- **At app launch:** App checks again; shows MessageBox with install link if still missing
**Rationale:** Allows staged deployment where IT can install Dragon-ISO first, then NDI/runtime later. End users get clear guidance at both install-time and app-launch time.
---
## Major Upgrade Behavior
The installer detects when a newer version is already installed and:
1. **Prevents downgrade:** Blocks installation of older versions with a clear error message
2. **In-place upgrade:** Newer installs replace older ones seamlessly
3. **Config preservation:** User configuration in `%APPDATA%\Dragon-ISO\` is preserved
4. **UpgradeCode:** Constant GUID ensures upgrade detection works (TeamsISO → Dragon-ISO, and future versions)
---
## Error Handling
### Installation Failures
- WiX standard rollback behavior: if installation fails, all changes are undone
- Clear error messages for common issues (insufficient permissions, disk space, etc.)
### Shortcut Creation
- If shortcut creation fails, installation continues (non-blocking)
- User can manually create shortcuts from `Program Files\Wild Dragon\Dragon-ISO\Dragon-ISO.exe`
### Registry Operations
- If registry write fails, installation continues (non-blocking)
- ARP entry may be incomplete but app is still functional
---
## Testing Checklist
- [ ] MSI builds successfully with new branding
- [ ] Install to clean system works end-to-end
- [ ] Shortcuts appear in Start Menu and on Desktop
- [ ] ARP entry shows correct metadata
- [ ] Uninstall removes all files and shortcuts
- [ ] Upgrade from TeamsISO to Dragon-ISO works
- [ ] .NET 8 runtime detection shows warning if missing
- [ ] NDI runtime detection shows warning if missing
- [ ] App launches after installation
- [ ] Help/About links in ARP entry work
---
## Future Enhancements (Out of Scope)
- Code signing for published MSI
- Automatic update checks via Squirrel.Windows
- Per-user installation option
- Silent/unattended install mode (for enterprise deployment)

34
docs/test-playbook.md Normal file
View file

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

@ -1,27 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Dragon-ISO — MSI installer (WiX v5)
TeamsISO — MSI installer (WiX v5)
Produces: Dragon-ISO-Setup-<Version>.msi (per-machine install).
Produces: TeamsISO-Setup-<Version>.msi (per-machine install).
Build:
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj -c Release -r win-x64 -p:SelfContained=false -o publish/Dragon-ISO
dotnet build installer/Dragon-ISO.Installer.wixproj -c Release
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj -c Release -r win-x64 (no self contained) -o publish/TeamsISO
dotnet build installer/TeamsISO.Installer.wixproj -c Release
Runtime expectations:
- .NET 8 Desktop runtime present on target (framework-dependent build)
- NDI 6 Runtime present — checked in NdiRuntimeDirV6Search; absence WARNS
- NDI 6 Runtime present — checked in CheckNdiRuntime; absence WARNS
but does not block install (operators can install NDI after the app)
Exe filename note:
Dragon-ISO.App.csproj sets AssemblyName=DragonISO (no hyphen — CLR
assembly names cannot contain hyphens). The published executable is
therefore DragonISO.exe. Shortcut targets reference DragonISO.exe.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package Name="Dragon-ISO"
<Package Name="TeamsISO"
Manufacturer="Wild Dragon LLC"
Version="1.0.0.0"
UpgradeCode="9F4A8B2C-1D3E-4A5B-9C6D-8E7F0A1B2C3D"
@ -29,83 +24,52 @@
Compressed="yes"
InstallerVersion="500">
<!--
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="Dragon-ISO — 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="Dragon-ISO, NDI, Microsoft Teams, ISO recording, broadcast, live production, vMix, OBS, switcher, Wild Dragon" />
<SummaryInformation Description="TeamsISO — Per-Participant NDI ISO Controller for Microsoft Teams"
Manufacturer="Wild Dragon LLC" />
<!--
MajorUpgrade: a newer install replaces an older one in-place. We
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: a newer install replaces an older one in-place.
Disallow downgrades; users should uninstall the newer first.
-->
<MajorUpgrade DowngradeErrorMessage="A newer version of Dragon-ISO 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" />
<!--
Single MSI feature; users see only the install/uninstall screens.
-->
<Feature Id="Main" Title="Dragon-ISO" Level="1">
<Feature Id="Main" Title="TeamsISO" Level="1">
<ComponentGroupRef Id="ApplicationFiles" />
<ComponentGroupRef Id="Shortcuts" />
<ComponentGroupRef Id="DesktopShortcut" />
<ComponentGroupRef Id="ArpEntry" />
</Feature>
<!--
Minimal install UI: Welcome/License -> Progress -> Finish.
No directory picker; installs to Program Files\Wild Dragon\Dragon-ISO.
Friendly install UI. WixToolset.UI.wixext provides several flavors;
WixUI_InstallDir lets the user pick the directory.
-->
<ui:WixUI Id="WixUI_Minimal" />
<ui:WixUI Id="WixUI_InstallDir" />
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
<!--
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.
ARP icon + about-box link.
-->
<Property Id="ARPHELPLINK" Value="https://forge.wilddragon.net/zgaetano/dragon-iso" />
<Property Id="ARPHELPLINK" 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="Dragon-ISO 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_Minimal. Do not redeclare. -->
<!-- ARPNOMODIFY is set by WixUI_InstallDir; don't redeclare. -->
<Property Id="ARPNOREPAIR" Value="1" />
<!--
ARP icon: references the same .ico the WPF host uses. WiX requires the
ARP icon references the same .ico the WPF host uses. WiX requires the
icon resource to live next to the wxs OR be reachable at build time;
we point at the source copy under src/Dragon-ISO.App/Assets so the icon
we point at the published copy under src/TeamsISO.App/Assets so the icon
embedded in the MSI matches the icon in the running exe.
-->
<Icon Id="DragonISOIcon" SourceFile="$(var.AssetsDir)Dragon-ISO.ico" />
<Property Id="ARPPRODUCTICON" Value="DragonISOIcon" />
<!--
.NET 8 Desktop Runtime detection. The .NET apphost will surface a
"framework not found" dialog naturally if the runtime is absent;
this property is available for future conditional logic.
VBScript-based install-time dialogs are deprecated in WiX v5 / Windows;
rewriting in C++ is overkill for a soft warning on a soft dependency.
-->
<Property Id="DOTNET8DESKTOPRUNTIME" Value="0">
<RegistrySearch Id="DotNet8DesktopRuntimeSearch"
Root="HKLM"
Key="SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App\8.0.0"
Name="Version"
Type="raw" />
</Property>
<Icon Id="TeamsISOIcon" SourceFile="$(var.AssetsDir)teamsiso.ico" />
<Property Id="ARPPRODUCTICON" Value="TeamsISOIcon" />
<!--
NDI Runtime detection. We check for NDI_RUNTIME_DIR_V6 in the system
environment block. Missing -> warn during install, don't block. The
environment block. Missing → warn during install, don't block. The
engine surfaces a clear MessageBox with an install-NDI link at first
launch if the runtime really isn't there.
-->
@ -126,11 +90,11 @@
-->
<!--
Install layout under Program Files\Wild Dragon\Dragon-ISO.
Install layout under Program Files\Wild Dragon\TeamsISO.
-->
<StandardDirectory Id="ProgramFiles64Folder">
<Directory Id="ManufacturerFolder" Name="Wild Dragon">
<Directory Id="INSTALLFOLDER" Name="Dragon-ISO" />
<Directory Id="INSTALLFOLDER" Name="TeamsISO" />
</Directory>
</StandardDirectory>
@ -148,33 +112,22 @@
</ComponentGroup>
<!--
Start Menu and Desktop shortcuts: direct .exe targets.
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 Dragon-ISO inherit the launching
token (medium or high integrity, doesn't matter) is the correct
behavior. NDI discovery works fine at either integrity level.
Exe filename: AssemblyName=DragonISO (no hyphen), so target is
DragonISO.exe not Dragon-ISO.exe.
Start Menu shortcut to the WPF host. KeyPath sits on a registry
value so component identity is stable across upgrades.
-->
<ComponentGroup Id="Shortcuts" Directory="WildDragonStartMenuFolder">
<Component Id="StartMenuShortcut" Guid="*">
<Shortcut Id="StartMenuDragonISO"
Name="Dragon-ISO"
<Shortcut Id="StartMenuTeamsISO"
Name="TeamsISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]DragonISO.exe"
WorkingDirectory="INSTALLFOLDER"
Icon="DragonISOIcon" />
Target="[INSTALLFOLDER]TeamsISO.exe"
WorkingDirectory="INSTALLFOLDER" />
<!-- Required by ICE64: Start Menu folder must be cleaned on uninstall. -->
<RemoveFolder Id="RemoveWildDragonStartMenuFolder"
Directory="WildDragonStartMenuFolder"
On="uninstall" />
<RegistryValue Root="HKCU"
Key="Software\Wild Dragon\Dragon-ISO"
Key="Software\Wild Dragon\TeamsISO"
Name="StartMenuShortcut"
Type="integer"
Value="1"
@ -182,33 +135,15 @@
</Component>
</ComponentGroup>
<StandardDirectory Id="DesktopFolder" />
<ComponentGroup Id="DesktopShortcut" Directory="DesktopFolder">
<Component Id="DesktopShortcutComponent" Guid="*">
<Shortcut Id="DesktopDragonISO"
Name="Dragon-ISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]DragonISO.exe"
WorkingDirectory="INSTALLFOLDER"
Icon="DragonISOIcon" />
<RegistryValue Root="HKCU"
Key="Software\Wild Dragon\Dragon-ISO"
Name="DesktopShortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</ComponentGroup>
<!--
ARP icon registry entry. Optional: the MSI auto-fills most ARP
fields from the Package element. We only need to store the install
path for diagnostic / uninstall tooling.
ARP icon registry entry. Optional — the MSI auto-fills most ARP
fields from the Package element. We only need to point at the
executable for the ARP icon.
-->
<ComponentGroup Id="ArpEntry" Directory="INSTALLFOLDER">
<Component Id="ArpIconRegistry" Guid="*">
<RegistryValue Root="HKLM"
Key="Software\Wild Dragon\Dragon-ISO"
Key="Software\Wild Dragon\TeamsISO"
Name="InstallPath"
Type="string"
Value="[INSTALLFOLDER]"
@ -217,4 +152,4 @@
</ComponentGroup>
</Package>
</Wix>
</Wix>

View file

@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Package</OutputType>
<OutputName>Dragon-ISO-Setup-$(Version)</OutputName>
<OutputName>TeamsISO-Setup-$(Version)</OutputName>
<!-- 64-bit MSI; suppresses ICE80 on components in Program Files (x64). -->
<Platform>x64</Platform>
@ -10,15 +10,15 @@
<!--
Built artifact location. The installer expects a published build of
Dragon-ISO.App rooted here. CI / local script:
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj
-c Release -r win-x64 -self-contained false
-o $(SolutionDir)publish/Dragon-ISO
TeamsISO.App rooted here. CI / local script:
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj
-c Release -r win-x64 (with self contained false)
-o $(SolutionDir)publish/TeamsISO
-->
<PublishDir>$(MSBuildThisFileDirectory)..\publish\Dragon-ISO\</PublishDir>
<PublishDir>$(MSBuildThisFileDirectory)..\publish\TeamsISO\</PublishDir>
<!-- Pass MSBuild values into WiX preprocessor. -->
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\Dragon-ISO.App\Assets\</DefineConstants>
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\TeamsISO.App\Assets\</DefineConstants>
<!-- Code-signing hook (set externally for release builds; left empty for dev). -->
<SignOutput Condition=" '$(SignOutput)' == '' ">false</SignOutput>
@ -32,4 +32,4 @@
<PackageReference Include="WixToolset.UI.wixext" Version="5.0.2" />
</ItemGroup>
</Project>
</Project>

View file

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

View file

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

View file

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

View file

@ -1,305 +0,0 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using Microsoft.Extensions.Logging;
using DragonISO.App.ViewModels;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Logging;
using DragonISO.Engine.NdiInterop;
// Application + MessageBox aliases live in GlobalUsings.cs (project-wide).
// Don't redeclare here — Roslyn errors with CS1537 on duplicate alias.
namespace DragonISO.App;
// Split across partial files by responsibility:
// • App.xaml.cs — class skeleton, OnStartup (the wiring
// pipeline that calls into the partials),
// OnExit, CLI arg parser.
// • App.Bootstrap.cs — the linear setup steps OnStartup walks
// (single-instance gate, NDI interop, engine,
// main window, control surface, tray icon,
// onboarding, Teams auto-launch).
// • App.CrashHandlers.cs — AppDomain / Dispatcher / Task exception
// handlers + crash dialog + LogDirectory.
// • App.UpdateCheckBootstrap.cs — the background update-checker
// kickoff (24h-throttled).
public partial class App : Application
{
/// <summary>
/// Per-user mutex name. Including the username (acting as a SID proxy) ensures two
/// different Windows users can each run Dragon-ISO on the same machine, while one
/// user can't spawn duplicate instances that would contend over the NDI runtime
/// and the shared %APPDATA%\Dragon-ISO\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 Dragon-ISO
/// 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>
private static readonly string SingleInstanceMutexName =
$"Global\\WildDragon.DragonISO.SingleInstance.{Environment.UserName}";
private System.Threading.Mutex? _singleInstanceMutex;
private bool _ownsSingleInstanceMutex;
private ThreadMessageEventHandler? _bringToFrontHandler;
private ILoggerFactory? _loggerFactory;
private NdiInteropPInvoke? _interop;
private IsoController? _controller;
private MainViewModel? _viewModel;
private DragonISO.App.Services.ControlSurfaceServer? _controlSurface;
private DragonISO.App.Services.OscBridge? _oscBridge;
// _diskSpaceWatcher removed — only existed to auto-disable recording at low free space.
private DragonISO.App.Services.TrayIconHost? _trayIcon;
/// <summary>
/// REST control surface lifetime. Lives on App so the settings VM can flip
/// it on/off without us plumbing yet another DI dependency through MainViewModel.
/// Null between process startup and the OnStartup wire-up, and after OnExit.
/// </summary>
internal DragonISO.App.Services.ControlSurfaceServer? ControlSurface => _controlSurface;
/// <summary>OSC bridge (UDP) lifetime — same lifecycle pattern as the REST surface.</summary>
internal DragonISO.App.Services.OscBridge? OscBridge => _oscBridge;
/// <summary>Tray-icon host. Exposed so the settings VM can flip the minimize-to-tray toggle.</summary>
internal DragonISO.App.Services.TrayIconHost? TrayIcon => _trayIcon;
[DllImport("user32.dll")]
private static extern uint RegisterWindowMessageW(string lpString);
[DllImport("user32.dll")]
private static extern int PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern int SendNotifyMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
private const IntPtr HWND_BROADCAST = -1;
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%\Dragon-ISO\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);
StartupTrace.Write("base.OnStartup returned");
// De-elevation via runas /trustlevel:0x20000 was tried (commits 191b2c5,
// 54ee578) on the theory that elevated Dragon-ISO can't discover NDI
// sources. THAT THEORY WAS WRONG — verified 2026-05-16 that elevated
// Dragon-ISO 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
// default to a single handler that logs Fatal to Serilog.
AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandled;
DispatcherUnhandledException += OnDispatcherUnhandled;
System.Threading.Tasks.TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
StartupTrace.Write("crash handlers registered");
try { DragonISO.App.Services.ThemeManager.Current.Apply(); StartupTrace.Write("ThemeManager.Apply OK"); }
catch (Exception ex) { StartupTrace.Write($"ThemeManager.Apply THREW: {ex}"); }
// Single-instance gate. Trace the mutex acquisition.
bool acquired = false;
try { acquired = TryAcquireSingleInstance(); } catch (Exception ex) { StartupTrace.Write($"TryAcquireSingleInstance THREW: {ex}"); }
StartupTrace.Write($"TryAcquireSingleInstance returned: {acquired}");
if (!acquired)
{
StartupTrace.Write("not first instance — Shutdown(0)");
Shutdown(0);
return;
}
try
{
StartupTrace.Write("Bootstrap try-block ENTER");
_loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
StartupTrace.Write("EngineLogging.CreateDefault OK");
var logger = _loggerFactory.CreateLogger<App>();
logger.LogInformation(
"DragonISO.App starting up. Build: {Version}. Process: {Pid}.",
typeof(App).Assembly.GetName().Version,
Environment.ProcessId);
StartupTrace.Write("Serilog first write attempted");
if (!TryBootstrapNdiInterop())
{
StartupTrace.Write("TryBootstrapNdiInterop returned false — Shutdown(2)");
Shutdown(2);
return;
}
StartupTrace.Write("TryBootstrapNdiInterop OK");
BootstrapEngine();
StartupTrace.Write("BootstrapEngine OK");
var window = ConstructAndShowMainWindow();
StartupTrace.Write("ConstructAndShowMainWindow OK (window shown)");
BootstrapControlSurfaceServices();
StartupTrace.Write("BootstrapControlSurfaceServices OK");
BootstrapTrayIcon(window);
StartupTrace.Write("BootstrapTrayIcon OK");
TryShowOnboarding(window);
StartupTrace.Write("TryShowOnboarding returned");
ApplyCommandLineArgs(e.Args);
StartupTrace.Write("ApplyCommandLineArgs OK");
StartupTrace.Write("about to await _viewModel.InitializeAsync");
await _viewModel!.InitializeAsync(CancellationToken.None);
StartupTrace.Write("_viewModel.InitializeAsync COMPLETED");
TryAutoLaunchTeams(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)
{
StartupTrace.Write($"OnStartup CATCH: {ex}");
try { _loggerFactory?.CreateLogger<App>().LogCritical(ex, "OnStartup failed before main loop"); }
catch { /* defensive */ }
MessageBox.Show(
"Dragon-ISO failed to start.\n\nDetails: " + ex,
"Dragon-ISO — startup error",
MessageBoxButton.OK,
MessageBoxImage.Error);
Shutdown(1);
}
}
// De-elevation helpers (ShouldDeElevate, TryDeElevateAndExit, the
// Dragon-ISO_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>
/// Parse the supported CLI flags. Currently:
/// <c>--apply-preset NAME</c> — apply the named preset once participants
/// populate. Equivalent to running Dragon-ISO and clicking Presets → select →
/// Apply, but driven from a desktop shortcut.
/// Unrecognized flags are silently ignored — operators using shortcut.lnk
/// files don't need to fight argument parsers.
/// </summary>
private void ApplyCommandLineArgs(string[] args)
{
if (_viewModel is null) return;
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--apply-preset":
if (i + 1 < args.Length && !string.IsNullOrEmpty(args[i + 1]))
{
_viewModel.RequestApplyPresetOnStartup(args[i + 1]);
i++; // consume the value
}
break;
}
}
}
// Crash handlers (OnAppDomainUnhandled / OnDispatcherUnhandled /
// OnUnobservedTaskException / TryLogFatal / TryShowCrashDialog / LogDirectory)
// live in App.CrashHandlers.cs.
protected override async void OnExit(ExitEventArgs e)
{
try
{
_trayIcon?.Dispose();
if (_controlSurface is not null)
await _controlSurface.DisposeAsync();
if (_oscBridge is not null)
await _oscBridge.DisposeAsync();
_viewModel?.Dispose();
if (_controller is not null)
await _controller.DisposeAsync();
_interop?.Dispose();
_loggerFactory?.Dispose();
}
catch
{
// Best-effort shutdown
}
finally
{
// Unsubscribe the bring-to-front filter so the delegate doesn't outlive
// the App; ComponentDispatcher is process-static.
if (_bringToFrontHandler is not null)
{
ComponentDispatcher.ThreadFilterMessage -= _bringToFrontHandler;
_bringToFrontHandler = null;
}
// Release the Mutex iff we acquired it. The "lost the race" path above
// sets _ownsSingleInstanceMutex=false and we skip ReleaseMutex (which
// would throw ApplicationException on an unowned Mutex).
try { if (_ownsSingleInstanceMutex) _singleInstanceMutex?.ReleaseMutex(); }
catch { /* defensive: already-released or invalid handle */ }
_singleInstanceMutex?.Dispose();
}
base.OnExit(e);
}
}

View file

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

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

View file

@ -1,88 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<!--
WinForms in addition to WPF for the system-tray NotifyIcon — there's no
WPF equivalent. The two frameworks coexist cleanly in .NET 8; UseWindowsForms
adds System.Windows.Forms.dll without changing the application model.
-->
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>DragonISO.App</RootNamespace>
<AssemblyName>DragonISO</AssemblyName>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationIcon>Assets\Dragon-ISO.ico</ApplicationIcon>
<!--
Required by ParticipantViewModel.ScaleNearestNeighborBgra, which writes
directly into a WriteableBitmap's pinned BackBuffer (IntPtr) for 10×
better thumbnail update perf than going through Span<byte>.
-->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Dragon-ISO.Engine\Dragon-ISO.Engine.csproj" />
<ProjectReference Include="..\Dragon-ISO.Engine.NdiInterop\Dragon-ISO.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>
<!--
Grant the test assembly access to internal types — specifically the
OperatorPresetStore.PathOverride hook used to redirect file IO away from
%LOCALAPPDATA% during tests. We use AssemblyAttribute rather than
AssemblyInfo.cs so it co-locates with the project's other config.
-->
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>Dragon-ISO.App.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<!--
Strings.resx — user-facing English MessageBox copy. Embedded as a
.NET resource so ResourceManager can resolve Dragon-ISO.App.Properties.Strings
by basename. Strings.Designer.cs is hand-written (see file comment).
-->
<ItemGroup>
<EmbeddedResource Update="Properties\Strings.resx">
<LogicalName>Dragon-ISO.App.Properties.Strings.resources</LogicalName>
</EmbeddedResource>
</ItemGroup>
<!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. -->
<ItemGroup>
<!-- Source navy-blue dragon-mark, kept for AboutWindow / installer iconography. -->
<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\Dragon-ISO.ico" />
<!--
Inter Variable from rsms/inter v3.19 (OFL). Single .ttf covers every weight
from 100 (Thin) to 900 (Black) so we don't ship a directory of duplicates.
-->
<Resource Include="Assets\Fonts\Inter.ttf" />
<!--
JetBrains Mono Variable v2.304 (OFL). Used for machine names, source IDs,
and stat counters where a fixed-width font reads better than Inter.
-->
<Resource Include="Assets\Fonts\JetBrainsMono.ttf" />
</ItemGroup>
</Project>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,400 +0,0 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows.Threading;
using Microsoft.Extensions.Logging;
using DragonISO.App.ViewModels;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Domain;
namespace DragonISO.App.Services;
/// <summary>
/// Localhost-only HTTP control surface. Lets external controllers (Bitfocus
/// Companion, Stream Deck plugins, Bome MIDI Translator, custom node-RED flows,
/// etc.) drive Dragon-ISO without needing to embed a UI binding.
///
/// Bound to 127.0.0.1 by default — exposing this to LAN would require auth, and
/// the typical operator workflow is "Stream Deck on the same machine as Dragon-ISO".
/// If a future user needs LAN access, add a token check + bind to a configurable
/// address; both are deliberately punted for v1.
///
/// Endpoints (all return application/json):
///
/// GET / — server info + endpoint list
/// GET /participants — list of {id, displayName, isOnline, isEnabled}
/// POST /participants/{id}/iso — body {"enabled":bool,"customName":string?}
/// POST /participants/iso — body {"displayName":string,"enabled":bool} (look up by name)
/// POST /presets/{name}/apply — apply a saved preset
/// POST /presets/refresh-discovery — rebuild NDI finder
/// POST /presets/stop-all — disable every running ISO
/// POST /teams/mute — toggle mute via UIA
/// POST /teams/camera — toggle camera via UIA
/// POST /teams/leave — leave the call via UIA
/// POST /teams/share — open share tray via UIA
/// POST /teams/raise-hand — toggle raise hand via UIA
/// POST /recording — body {"enabled":bool,"directory":string?}
///
/// All POST bodies are optional — endpoints that take parameters accept them
/// either via JSON body or via query string (?enabled=true&amp;customName=Host).
/// This is friendly to Companion's "URL with query string" mode.
/// </summary>
// Endpoint handlers live in partial files under Services/ControlSurface/Endpoints/.
// This file holds the host: listener lifecycle, accept loop, dispatch table,
// response helpers, and the WebSocket push loop.
public sealed partial class ControlSurfaceServer : IAsyncDisposable
{
public const int DefaultPort = 9755;
private readonly IIsoController _controller;
private readonly Func<MainViewModel?> _viewModel;
private readonly ILogger<ControlSurfaceServer>? _logger;
private HttpListener? _listener;
private CancellationTokenSource? _cts;
private Task? _acceptTask;
private DispatcherTimer? _pushTimer;
private readonly ConcurrentDictionary<Guid, WebSocket> _clients = new();
private string _lastPushedSnapshot = string.Empty;
public bool IsRunning { get; private set; }
public int Port { get; private set; } = DefaultPort;
/// <summary>True when the listener is bound to all interfaces (LAN-reachable) rather than just 127.0.0.1.</summary>
public bool BoundToLan { get; private set; }
/// <summary>
/// JSON serializer options shared across all responses. Camel-case property
/// naming matches Companion's request shape and what most JS clients expect.
/// </summary>
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public ControlSurfaceServer(
IIsoController controller,
Func<MainViewModel?> viewModel,
ILogger<ControlSurfaceServer>? logger = null)
{
_controller = controller;
_viewModel = viewModel;
_logger = logger;
}
/// <summary>
/// Start listening on the given port. Idempotent: if already running on the
/// same (port, bindToLan) combination, no-op; otherwise stop + restart.
/// </summary>
/// <param name="port">TCP port to listen on.</param>
/// <param name="bindToLan">
/// When true, binds to all interfaces (<c>http://+:port/</c>) so other
/// machines on the LAN can reach the control surface — typical for
/// "headless show machine + thin client controller" setups. When false
/// (default), binds to <c>127.0.0.1</c> only.
///
/// LAN binding requires either running Dragon-ISO as Administrator OR a
/// one-time URL ACL reservation at the OS level:
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
/// If neither is in place the listener throws AccessDeniedException
/// which we catch and surface as a logger warning.
/// </summary>
public void Start(int port, bool bindToLan = false)
{
if (IsRunning && Port == port && BoundToLan == bindToLan) return;
Stop();
Port = port;
BoundToLan = bindToLan;
_listener = new HttpListener();
var prefix = bindToLan
? $"http://+:{port}/"
: $"http://127.0.0.1:{port}/";
_listener.Prefixes.Add(prefix);
try
{
_listener.Start();
}
catch (HttpListenerException ex)
{
_logger?.LogWarning(ex,
"Could not start control surface on {Prefix}. " +
"If binding to LAN, run as Administrator once OR run: " +
"netsh http add urlacl url=http://+:{Port}/ user=Everyone",
prefix, port);
_listener = null;
return;
}
_cts = new CancellationTokenSource();
_acceptTask = Task.Run(() => AcceptLoopAsync(_cts.Token));
// Drive the WebSocket push loop on the UI dispatcher so we can read the
// ObservableCollection-backed Participants list without thread races. 4Hz
// is fast enough that operators see immediate feedback when they flip an
// ISO on the Stream Deck without us spamming the wire when nothing's
// changing — the snapshot serializer dedupes against the previous push.
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is not null)
{
_pushTimer = new DispatcherTimer(DispatcherPriority.Background, dispatcher)
{
Interval = TimeSpan.FromMilliseconds(250),
};
_pushTimer.Tick += async (_, _) => await PushSnapshotIfChangedAsync();
_pushTimer.Start();
}
IsRunning = true;
_logger?.LogInformation("Control surface listening on {Prefix} (REST + ws)", prefix);
}
public void Stop()
{
if (!IsRunning) return;
try { _pushTimer?.Stop(); } catch { /* ignore */ }
_pushTimer = null;
// Close + drop every connected WebSocket; clients will reconnect when the
// operator re-enables the surface.
foreach (var (id, ws) in _clients.ToArray())
{
try { ws.Abort(); } catch { /* ignore */ }
try { ws.Dispose(); } catch { /* ignore */ }
_clients.TryRemove(id, out _);
}
try { _cts?.Cancel(); } catch { /* ignore */ }
try { _listener?.Stop(); } catch { /* ignore */ }
try { _listener?.Close(); } catch { /* ignore */ }
try { _acceptTask?.Wait(TimeSpan.FromSeconds(2)); } catch { /* ignore */ }
_listener = null;
_cts?.Dispose();
_cts = null;
_acceptTask = null;
IsRunning = false;
}
public async ValueTask DisposeAsync()
{
Stop();
await Task.CompletedTask;
}
private async Task AcceptLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested && _listener is not null && _listener.IsListening)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync(); }
catch (HttpListenerException) { break; } // listener stopped
catch (ObjectDisposedException) { break; }
catch (InvalidOperationException) { break; }
// Each request gets its own task so a slow handler doesn't head-of-line block
// others. Handlers are short (no I/O beyond the controller call) so this is
// fine without explicit concurrency limits.
_ = Task.Run(() => HandleRequestAsync(ctx));
}
}
private async Task HandleRequestAsync(HttpListenerContext ctx)
{
var req = ctx.Request;
var res = ctx.Response;
// Tracks whether we should call res.Close() in the finally. WebSocket
// upgrades transfer ownership of the connection to the WebSocket
// instance — closing the response here would tear down the freshly-
// upgraded socket immediately. So we skip the finally close on that
// path.
var closeResponseInFinally = true;
try
{
res.Headers["Access-Control-Allow-Origin"] = "*";
if (req.HttpMethod == "OPTIONS")
{
res.Headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
res.Headers["Access-Control-Allow-Headers"] = "Content-Type";
res.StatusCode = 204;
return;
}
var path = req.Url?.AbsolutePath?.TrimEnd('/') ?? "";
// WebSocket upgrade: live state push for controllers that don't want
// to poll. Returns immediately after upgrading; HandleWebSocketAsync
// owns the connection until the client disconnects.
if (req.IsWebSocketRequest && path == "/ws")
{
var wsContext = await ctx.AcceptWebSocketAsync(subProtocol: null);
closeResponseInFinally = false;
_ = Task.Run(() => HandleWebSocketAsync(wsContext.WebSocket));
return;
}
var body = await ReadBodyAsync(req);
// GET /ui — embedded HTML control panel. Served as text/html
// rather than JSON so a browser renders it directly.
if (req.HttpMethod == "GET" && path == "/ui")
{
res.ContentType = "text/html; charset=utf-8";
var html = ControlPanelHtml.Get();
var bytes = System.Text.Encoding.UTF8.GetBytes(html);
res.ContentLength64 = bytes.Length;
await res.OutputStream.WriteAsync(bytes);
return;
}
// GET /participants/{id}/thumbnail.bmp — small BMP of the latest
// processed frame. Returns 404 when no pipeline is running for
// this participant. The HTML control panel uses this URL with
// a cache-busting query param every ~1s to drive live preview
// tiles. BMP (not JPEG) because WPF imaging types NRE from
// non-UI threads and BMP encodes in plain managed code; the
// 40KB payload at 192-wide compresses fine over LAN gzip.
// Old /thumbnail.jpg URL accepted for backward compat.
if (req.HttpMethod == "GET" && path.StartsWith("/participants/", StringComparison.Ordinal)
&& (path.EndsWith("/thumbnail.bmp", StringComparison.Ordinal) || path.EndsWith("/thumbnail.jpg", StringComparison.Ordinal)))
{
var ext = path.EndsWith("/thumbnail.bmp", StringComparison.Ordinal) ? "/thumbnail.bmp" : "/thumbnail.jpg";
var idSegment = path.AsSpan("/participants/".Length,
path.Length - "/participants/".Length - ext.Length).ToString();
if (!Guid.TryParse(idSegment, out var thumbId))
{
res.StatusCode = 400;
await WriteJsonAsync(res, new { error = "invalid id" });
return;
}
var bmp = TryEncodeThumbnailJpeg(thumbId);
if (bmp is null)
{
res.StatusCode = 404;
await WriteJsonAsync(res, new { error = "no frame", id = thumbId });
return;
}
res.ContentType = "image/bmp";
res.AddHeader("Cache-Control", "no-store, must-revalidate");
res.ContentLength64 = bmp.Length;
await res.OutputStream.WriteAsync(bmp);
return;
}
object? response = (req.HttpMethod, path) switch
{
("GET", "" or "/") => GetServerInfo(),
("GET", "/participants") => GetParticipants(),
("POST", "/presets/refresh-discovery") => RefreshDiscovery(),
("POST", "/presets/stop-all") => await StopAllAsync(),
("POST", "/teams/mute") => InvokeTeams(TeamsControlBridge.ToggleMute, "mute"),
("POST", "/teams/camera") => InvokeTeams(TeamsControlBridge.ToggleCamera, "camera"),
("POST", "/teams/leave") => InvokeTeams(TeamsControlBridge.LeaveCall, "leave"),
("POST", "/teams/share") => InvokeTeams(TeamsControlBridge.OpenShareTray, "share"),
("POST", "/teams/raise-hand") => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"),
// /recording routes removed alongside the rest of the recording surface.
// Topology — read the machine NDI config to report whether raw
// Teams NDI sources are hidden from the LAN, and let the
// operator apply / restore without leaving the web UI.
("GET", "/topology") => GetTopology(),
("POST", "/topology/apply") => await ApplyTopologyAsync(),
("POST", "/topology/restore") => await RestoreTopologyAsync(),
_ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal)
&& path.EndsWith("/override", StringComparison.Ordinal)
=> await SetIsoOverrideByIdAsync(path, body),
_ when req.HttpMethod == "DELETE" && path.StartsWith("/participants/", StringComparison.Ordinal)
&& path.EndsWith("/override", StringComparison.Ordinal)
=> await ClearIsoOverrideByIdAsync(path),
("POST", "/notes") => AppendNote(body, req.QueryString),
("POST", "/participants/iso") => await ToggleIsoByNameAsync(body, req.QueryString),
_ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal)
=> await ToggleIsoByIdAsync(path, body, req.QueryString),
_ when req.HttpMethod == "POST" && path.StartsWith("/presets/", StringComparison.Ordinal)
&& path.EndsWith("/apply", StringComparison.Ordinal)
=> await ApplyPresetAsync(path),
_ => NotFound(),
};
if (response is null)
{
res.StatusCode = 404;
await WriteJsonAsync(res, new { error = "not found" });
return;
}
await WriteJsonAsync(res, response);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Control surface request failed: {Path}", req.Url?.AbsolutePath);
try
{
res.StatusCode = 500;
await WriteJsonAsync(res, new { error = ex.Message });
}
catch { /* defensive */ }
}
finally
{
if (closeResponseInFinally)
{
try { res.Close(); } catch { /* defensive */ }
}
}
}
// ─── handlers ───────────────────────────────────────────────────────
//
// Endpoint bodies live in Services/ControlSurface/Endpoints/*.cs as
// partials of this class. See HomeEndpoints, ParticipantsEndpoints,
// PresetsEndpoints, TeamsEndpoints, TopologyEndpoints, NotesEndpoints,
// and ThumbnailEndpoint. The WebSocket push surface is at
// Services/ControlSurface/WebSocketHub.cs.
[SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")]
private object NotFound() => new { error = "not found" };
// ─── helpers ────────────────────────────────────────────────────────
private static async Task<JsonElement> ReadBodyAsync(HttpListenerRequest req)
{
if (req.HttpMethod != "POST" || req.ContentLength64 == 0) return default;
using var sr = new StreamReader(req.InputStream, req.ContentEncoding ?? Encoding.UTF8);
var raw = await sr.ReadToEndAsync();
if (string.IsNullOrWhiteSpace(raw)) return default;
try
{
return JsonSerializer.Deserialize<JsonElement>(raw);
}
catch
{
return default;
}
}
private static async Task WriteJsonAsync(HttpListenerResponse res, object payload)
{
res.ContentType = "application/json; charset=utf-8";
var json = JsonSerializer.Serialize(payload, JsonOpts);
var bytes = Encoding.UTF8.GetBytes(json);
res.ContentLength64 = bytes.Length;
await res.OutputStream.WriteAsync(bytes);
}
private static bool? TryGetBool(JsonElement body, System.Collections.Specialized.NameValueCollection query, string key)
{
if (body.ValueKind == JsonValueKind.Object &&
body.TryGetProperty(key, out var v) && v.ValueKind is JsonValueKind.True or JsonValueKind.False)
return v.GetBoolean();
var q = query[key];
if (q is null) return null;
return q.Equals("true", StringComparison.OrdinalIgnoreCase) || q == "1";
}
private static string? TryGetString(JsonElement body, System.Collections.Specialized.NameValueCollection query, string key)
{
if (body.ValueKind == JsonValueKind.Object &&
body.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String)
return v.GetString();
return query[key];
}
}

View file

@ -1,150 +0,0 @@
using System.IO;
using System.Linq;
using System.Text;
namespace DragonISO.App.Services;
/// <summary>
/// User-editable template for the NDI source name a participant's ISO is
/// published as. Default <c>"{name}"</c> renders the speaker's display name
/// directly, which is what downstream switchers want when they key on
/// readable identifiers. Operators can override globally to
/// <c>"Dragon-ISO_{guid}"</c> for the legacy stable-id behavior, or
/// <c>"Dragon-ISO_{machine}_{name}"</c> when multiple Dragon-ISO machines feed
/// 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"/>:
/// <c>{name}</c> participant display name, sanitized (alphanumeric + underscore)
/// <c>{guid}</c> first 8 hex chars of the participant's Id, uppercase
/// <c>{machine}</c> sanitized PC hostname (Environment.MachineName)
/// <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>Dragon-ISO_{guid}</c> so
/// the NDI sender always has a usable, unique identifier.
///
/// Persisted to <c>%LOCALAPPDATA%\Dragon-ISO\output-name-template.txt</c>.
/// </summary>
public static class OutputNameTemplate
{
/// <summary>
/// Default template — renders just the speaker's display name. Was
/// <c>"Dragon-ISO_{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 = "Dragon-ISO_{guid}";
private static string TemplatePath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO", "output-name-template.txt");
/// <summary>
/// Get the operator's current template, or the shipped default when no
/// override has been saved (or the override file is missing/unreadable).
/// </summary>
public static string Get()
{
try
{
if (File.Exists(TemplatePath))
{
var raw = File.ReadAllText(TemplatePath).Trim();
if (!string.IsNullOrEmpty(raw)) return raw;
}
}
catch
{
// Disk read failure → fall through to default. The next Set() call
// will overwrite cleanly.
}
return DefaultTemplate;
}
public static void Set(string template)
{
try
{
var dir = Path.GetDirectoryName(TemplatePath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(TemplatePath, template ?? string.Empty);
}
catch
{
// Best-effort persistence; the in-memory value still sticks for
// this session.
}
}
/// <summary>
/// Expand tokens in <paramref name="template"/> for a specific participant.
/// Result is sanitized into NDI-safe characters: alphanumeric, underscore,
/// hyphen, period. NDI spec allows more, but a conservative set keeps
/// downstream switchers happy.
/// </summary>
public static string Render(string template, Guid participantId, string displayName)
{
var safeName = SanitizeForNdi(displayName);
var guid = participantId.ToString("N")[..8].ToUpperInvariant();
var machine = SanitizeForNdi(Environment.MachineName);
var timestamp = DateTimeOffset.Now.ToString("yyyyMMdd_HHmmss");
var result = template
.Replace("{name}", safeName)
.Replace("{guid}", guid)
.Replace("{machine}", machine)
.Replace("{timestamp}", timestamp);
// Final sanitize on the rendered result — protects against a template
// that includes literal characters NDI doesn't accept.
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
// Dragon-ISO_{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)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
var sb = new StringBuilder(s.Length);
foreach (var c in s)
{
if (char.IsLetterOrDigit(c) || c is '_' or '-' or '.')
sb.Append(c);
else if (char.IsWhiteSpace(c))
sb.Append('_');
// else: skip
}
return sb.ToString();
}
}

View file

@ -1,177 +0,0 @@
using System.Runtime.InteropServices;
namespace DragonISO.App.Services;
/// <summary>
/// Phase E.4 — Embedded Teams via SetParent.
///
/// Reparents Teams' main top-level window into a Dragon-ISO-owned host
/// (typically a Border element's HWND). Strips the captured window's
/// caption + thick frame so it integrates flush with the host, and
/// remembers enough about the original to restore it cleanly later.
///
/// The Win32 behavior is well understood for classic Win32 apps, but
/// modern Teams runs WebView2 in its main window; WebView2's renderer is
/// sensitive to parent changes and may flash white frames during
/// reparent, drop input focus, or refuse to redraw until forced. We mark
/// the feature experimental and ensure the restore path always runs (the
/// caller wraps Embed in a finally block) so operators can fall back to
/// auto-hide mode if embedding misbehaves on their specific Teams build.
///
/// Lives in its own static class — separated from <see cref="TeamsLauncher"/>
/// because the embedding lifecycle (reparent → resize → restore) is its
/// own thing, and the Win32 surface it requires (SetParent / window-style
/// muck / SetWindowPos / MoveWindow) isn't reused by the launch / hide /
/// in-call control paths.
/// </summary>
public static class TeamsEmbedHost
{
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);
[DllImport("user32.dll", SetLastError = true)]
private static extern int GetWindowLongPtr(IntPtr hWnd, int nIndex);
[DllImport("user32.dll", SetLastError = true)]
private static extern int SetWindowLongPtr(IntPtr hWnd, int nIndex, int dwNewLong);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, [MarshalAs(UnmanagedType.Bool)] bool bRepaint);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int GetWindowTextLengthW(IntPtr hWnd);
private const int GWL_STYLE = -16;
private const long WS_CHILD = 0x40000000;
private const long WS_POPUP = unchecked((long)0x80000000);
private const long WS_CAPTION = 0x00C00000;
private const long WS_THICKFRAME = 0x00040000;
private const long WS_BORDER = 0x00800000;
private const long WS_DLGFRAME = 0x00400000;
private const uint SWP_FRAMECHANGED = 0x0020;
private const uint SWP_NOMOVE = 0x0002;
private const uint SWP_NOSIZE = 0x0001;
private const uint SWP_NOZORDER = 0x0004;
private const uint SWP_NOACTIVATE = 0x0010;
/// <summary>
/// Captures the original parent + window style so embedding can be
/// reversed cleanly. Tracked per-HWND so multiple consecutive
/// embed / unembed cycles don't lose the original chrome.
/// </summary>
private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState;
private static IntPtr _embeddedHwnd = IntPtr.Zero;
/// <summary>True when a Teams window is currently parented inside a Dragon-ISO host.</summary>
public static bool IsEmbedded => _embeddedHwnd != IntPtr.Zero;
/// <summary>
/// Reparents Teams' most-recently-used top-level window into
/// <paramref name="hostHwnd"/>. Strips Teams' caption + thick frame
/// so it integrates flush with the host. Returns true on success,
/// false if no Teams window could be found.
///
/// The host HWND is typically obtained via:
/// var src = (System.Windows.Interop.HwndSource)
/// PresentationSource.FromVisual(MyHostBorder);
/// src.Handle // → IntPtr suitable for hostHwnd
/// </summary>
public static bool EmbedTeamsInto(IntPtr hostHwnd, int width, int height)
{
if (hostHwnd == IntPtr.Zero) return false;
var teamsWindows = TeamsLauncher.EnumerateTopLevelTeamsWindows();
if (teamsWindows.Count == 0) return false;
// Pick the longest-title window as the "main" one — same
// heuristic GetActiveWindowTitle uses; matches the call /
// meeting window.
IntPtr best = IntPtr.Zero;
int bestLen = -1;
foreach (var w in teamsWindows)
{
var len = GetWindowTextLengthW(w);
if (len > bestLen) { bestLen = len; best = w; }
}
if (best == IntPtr.Zero) return false;
// Already embedded? Unembed first to clean state.
if (_embeddedHwnd != IntPtr.Zero) RestoreEmbed();
// Save original style + parent so we can fully reverse later.
var originalStyle = GetWindowLongPtr(best, GWL_STYLE);
var originalParent = SetParent(best, hostHwnd); // returns old parent
_embedSavedState = (originalParent, originalStyle);
_embeddedHwnd = best;
// Strip top-level decorations + add WS_CHILD so the OS treats
// it as a child window of the host.
var newStyle = originalStyle;
unchecked
{
newStyle &= ~(int)WS_CAPTION;
newStyle &= ~(int)WS_THICKFRAME;
newStyle &= ~(int)WS_BORDER;
newStyle &= ~(int)WS_DLGFRAME;
newStyle &= ~(int)WS_POPUP;
newStyle |= (int)WS_CHILD;
}
SetWindowLongPtr(best, GWL_STYLE, newStyle);
// Force a non-client recalculation so the style change takes
// effect.
SetWindowPos(best, IntPtr.Zero, 0, 0, 0, 0,
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
// Place at top-left of host, full host size.
MoveWindow(best, 0, 0, width, height, true);
return true;
}
/// <summary>
/// Resize the currently-embedded Teams window to <paramref name="width"/>
/// × <paramref name="height"/>. Called when the host element resizes
/// (window resize, layout change, etc.). No-op if nothing is embedded.
/// </summary>
public static void ResizeEmbedded(int width, int height)
{
if (_embeddedHwnd == IntPtr.Zero || width <= 0 || height <= 0) return;
MoveWindow(_embeddedHwnd, 0, 0, width, height, true);
}
/// <summary>
/// Reverse an active embed: SetParent back to desktop + restore the
/// original window style so Teams looks/behaves like a normal
/// top-level window again. Safe to call when nothing is embedded —
/// no-op.
/// </summary>
public static void RestoreEmbed()
{
if (_embeddedHwnd == IntPtr.Zero || _embedSavedState is null) return;
var (origParent, origStyle) = _embedSavedState.Value;
try
{
// Restore original style FIRST so when we reparent the
// window's top-level decorations come back correctly.
SetWindowLongPtr(_embeddedHwnd, GWL_STYLE, origStyle);
// SetParent(hwnd, Zero) returns to desktop. We could pass
// origParent verbatim but for Teams that's always the
// desktop anyway, and IntPtr.Zero is documented as
// "reparent to desktop".
SetParent(_embeddedHwnd, IntPtr.Zero);
SetWindowPos(_embeddedHwnd, IntPtr.Zero, 0, 0, 0, 0,
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
}
catch { /* defensive — restore must never throw */ }
finally
{
_embedSavedState = null;
_embeddedHwnd = IntPtr.Zero;
}
}
}

View file

@ -1,40 +0,0 @@
using System.IO;
namespace DragonISO.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%\Dragon-ISO\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),
"Dragon-ISO");
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

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

View file

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

View file

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

View file

@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Dragon-ISO.Engine\Dragon-ISO.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>Dragon-ISO.Engine.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -1,215 +0,0 @@
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using DragonISO.Engine.Interop;
namespace DragonISO.Engine.Discovery;
/// <summary>
/// Polls <see cref="INdiInterop.GetCurrentSources"/> at a fixed cadence, diffs the
/// resulting set against the previous poll, and emits <see cref="DiscoveryEvent"/>s
/// on a channel for downstream consumers.
/// </summary>
public sealed class NdiDiscoveryService
{
private readonly INdiInterop _interop;
private readonly ChannelWriter<DiscoveryEvent> _writer;
private readonly ILogger<NdiDiscoveryService> _logger;
private NdiFindHandle _finder;
private readonly HashSet<string> _previous = new();
private string? _discoveryGroups;
private int _refreshRequested;
public NdiDiscoveryService(
INdiInterop interop,
ChannelWriter<DiscoveryEvent> writer,
ILogger<NdiDiscoveryService> logger,
string? discoveryGroups = null)
{
_interop = interop;
_writer = writer;
_logger = logger;
_discoveryGroups = discoveryGroups;
_finder = interop.CreateFinder(discoveryGroups);
}
/// <summary>
/// Request that the next poll tick rebuild the underlying NDI finder. Useful right
/// after the operator changes discovery groups or applies a new transcoder topology
/// — without this, the finder is bound to the groups it was created with and never
/// sees new sources from the just-configured group. Honored on the next
/// <see cref="RunAsync"/> tick: the old finder is disposed, a fresh one is created,
/// and the seen-set is cleared so all currently-visible sources re-fire as
/// <see cref="DiscoveryEvent.Added"/>. Cheap (idempotent) — extra Refresh calls
/// while a refresh is already pending are coalesced.
/// </summary>
public void RequestRefresh() => Interlocked.Exchange(ref _refreshRequested, 1);
/// <summary>
/// Runs a single poll cycle. Public for unit testing; production uses <see cref="RunAsync"/>.
/// </summary>
public void PollOnce()
{
var current = _interop.GetCurrentSources(_finder);
var currentSet = new HashSet<string>(current);
foreach (var name in currentSet.Except(_previous))
{
var parsed = NdiSourceParser.Parse(name);
if (parsed is null)
{
_logger.LogTrace("Ignoring unrecognized source: {Name}", name);
continue;
}
_writer.TryWrite(new DiscoveryEvent.Added(parsed));
}
foreach (var name in _previous.Except(currentSet))
{
var parsed = NdiSourceParser.Parse(name);
if (parsed is null) continue;
_writer.TryWrite(new DiscoveryEvent.Removed(parsed));
}
_previous.Clear();
foreach (var name in currentSet) _previous.Add(name);
}
/// <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)
{
try
{
// 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)
{
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)
{
RebuildFinder(reason);
lastRebuildAt = now;
}
}
try { PollOnce(); }
catch (Exception ex) { _logger.LogWarning(ex, "Discovery poll failed; will retry on next tick."); }
if (_previous.Count > 0) lastSeenAt = DateTimeOffset.UtcNow;
}
}
finally
{
_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>
/// Updates the cached discovery-groups string used by future finder rebuilds.
/// Call <see cref="RequestRefresh"/> after this to actually pick up the change.
/// </summary>
public void UpdateDiscoveryGroups(string? discoveryGroups) =>
_discoveryGroups = discoveryGroups;
}

View file

@ -1,9 +1,9 @@
<Window x:Class="DragonISO.App.AboutWindow"
<Window x:Class="TeamsISO.App.AboutWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="About DragonISO"
Icon="/Assets/DragonISO.ico"
Title="About TeamsISO"
Icon="/Assets/teamsiso.ico"
Width="460" Height="500"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
@ -36,7 +36,7 @@
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="About DragonISO"
<TextBlock Text="About TeamsISO"
Style="{StaticResource Wd.Text.Caption}"
Margin="20,12,0,0"
VerticalAlignment="Center"/>
@ -62,7 +62,7 @@
Margin="0,0,0,16"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="DragonISO"
<TextBlock Text="TeamsISO"
Style="{StaticResource Wd.Text.Title}"
FontSize="28"
HorizontalAlignment="Center"/>
@ -146,12 +146,12 @@
Click="OnOpenLogs"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Open %LOCALAPPDATA%\DragonISO\Logs in Explorer"/>
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Logs in Explorer"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Notes"
Click="OnOpenNotes"
Padding="14,6"
ToolTip="Open %LOCALAPPDATA%\DragonISO\Notes in Explorer"/>
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Notes in Explorer"/>
</StackPanel>
</StackPanel>

View file

@ -1,13 +1,13 @@
using System.Diagnostics;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Runtime.Versioning;
using System.Windows;
using System.Windows.Navigation;
using DragonISO.App.Services;
using DragonISO.Engine.NdiInterop;
using TeamsISO.App.Services;
using TeamsISO.Engine.NdiInterop;
namespace DragonISO.App;
namespace TeamsISO.App;
/// <summary>
/// Modal "About" dialog. Surfaces enough info that a user filing a support ticket
@ -65,7 +65,7 @@ public partial class AboutWindow : Window
/// <summary>
/// Quick-jump: open a path in Explorer. Creates the directory if missing
/// (operator might click "Recordings" before any have been made). Best-
/// effort — Explorer launch failures don't surface a dialog.
/// effort Explorer launch failures don't surface a dialog.
/// </summary>
private static void OpenInExplorer(string path)
{
@ -87,18 +87,18 @@ public partial class AboutWindow : Window
private void OnOpenLogs(object sender, RoutedEventArgs e) =>
OpenInExplorer(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO", "Logs"));
"TeamsISO", "Logs"));
// OnOpenRecordings removed — recording feature axed.
// OnOpenRecordings removed recording feature axed.
private void OnOpenNotes(object sender, RoutedEventArgs e) =>
OpenInExplorer(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO", "Notes"));
"TeamsISO", "Notes"));
/// <summary>
/// Build the diagnostic bundle and tell the operator where it landed. The
/// bundle is just zipped logs / config / presets — no screenshots, no
/// bundle is just zipped logs / config / presets no screenshots, no
/// memory dumps. Intended to be attached to a bug report.
/// </summary>
private void OnExportDiagnostics(object sender, RoutedEventArgs e)
@ -109,7 +109,7 @@ public partial class AboutWindow : Window
var open = MessageBox.Show(
this,
$"Diagnostic bundle written to:\n\n{path}\n\nOpen the containing folder?",
"Dragon-ISO — Diagnostics exported",
"TeamsISO — Diagnostics exported",
MessageBoxButton.YesNo,
MessageBoxImage.Information);
if (open == MessageBoxResult.Yes)
@ -131,7 +131,7 @@ public partial class AboutWindow : Window
MessageBox.Show(
this,
$"Diagnostic export failed.\n\n{ex.Message}",
"Dragon-ISO — Diagnostic export",
"TeamsISO — Diagnostic export",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
@ -158,7 +158,7 @@ public partial class AboutWindow : Window
$"{result.Message}\n\n" +
$"You're on {result.CurrentVersion}; latest is {result.LatestTag}.\n\n" +
"Open the releases page to download the new MSI?",
"Dragon-ISO — Update available",
"TeamsISO — Update available",
MessageBoxButton.YesNo,
MessageBoxImage.Information);
if (open == MessageBoxResult.Yes)
@ -169,7 +169,7 @@ public partial class AboutWindow : Window
MessageBox.Show(
this,
result.Message ?? "You're on the latest release.",
"Dragon-ISO — Up to date",
"TeamsISO — Up to date",
MessageBoxButton.OK,
MessageBoxImage.Information);
break;
@ -179,7 +179,7 @@ public partial class AboutWindow : Window
MessageBox.Show(
this,
$"Couldn't check for updates.\n\n{result.Message}",
"Dragon-ISO — Update check failed",
"TeamsISO — Update check failed",
MessageBoxButton.OK,
MessageBoxImage.Warning);
break;
@ -193,7 +193,7 @@ public partial class AboutWindow : Window
/// <summary>
/// Open the company site in the default browser. We intentionally use the
/// shell's URL handler rather than a tab inside the app — this is a
/// shell's URL handler rather than a tab inside the app this is a
/// "tell me more" link, not a workflow.
/// </summary>
private void OnWebsiteClick(object sender, RoutedEventArgs e)

View file

@ -1,4 +1,4 @@
<Application x:Class="DragonISO.App.App"
<Application x:Class="TeamsISO.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>

View file

@ -0,0 +1,461 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Threading;
using Microsoft.Extensions.Logging;
using TeamsISO.App.ViewModels;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Interop;
using TeamsISO.Engine.Logging;
using TeamsISO.Engine.NdiInterop;
using TeamsISO.Engine.Persistence;
using TeamsISO.Engine.Pipeline;
// Application + MessageBox aliases live in GlobalUsings.cs (project-wide).
// Don't redeclare here — Roslyn errors with CS1537 on duplicate alias.
namespace TeamsISO.App;
public partial class App : Application
{
/// <summary>
/// Per-user mutex name. Including the SID-equivalent (the username) ensures two
/// 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
/// and the shared %APPDATA%\TeamsISO\config.json.
/// </summary>
private static readonly string SingleInstanceMutexName =
$"Local\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}";
private System.Threading.Mutex? _singleInstanceMutex;
private bool _ownsSingleInstanceMutex;
private ThreadMessageEventHandler? _bringToFrontHandler;
private ILoggerFactory? _loggerFactory;
private NdiInteropPInvoke? _interop;
private IsoController? _controller;
private MainViewModel? _viewModel;
private TeamsISO.App.Services.ControlSurfaceServer? _controlSurface;
private TeamsISO.App.Services.OscBridge? _oscBridge;
// _diskSpaceWatcher removed — only existed to auto-disable recording at low free space.
private TeamsISO.App.Services.TrayIconHost? _trayIcon;
/// <summary>
/// REST control surface lifetime. Lives on App so the settings VM can flip
/// it on/off without us plumbing yet another DI dependency through MainViewModel.
/// Null between process startup and the OnStartup wire-up, and after OnExit.
/// </summary>
internal TeamsISO.App.Services.ControlSurfaceServer? ControlSurface => _controlSurface;
/// <summary>OSC bridge (UDP) lifetime — same lifecycle pattern as the REST surface.</summary>
internal TeamsISO.App.Services.OscBridge? OscBridge => _oscBridge;
/// <summary>Tray-icon host. Exposed so the settings VM can flip the minimize-to-tray toggle.</summary>
internal TeamsISO.App.Services.TrayIconHost? TrayIcon => _trayIcon;
[DllImport("user32.dll")]
private static extern uint RegisterWindowMessageW(string lpString);
[DllImport("user32.dll")]
private static extern int PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern int SendNotifyMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
private const IntPtr HWND_BROADCAST = -1;
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// Crash diagnostics — wire the three exception channels WPF leaves open by
// default to a single handler that logs Fatal to Serilog (which has the
// 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;
DispatcherUnhandledException += OnDispatcherUnhandled;
System.Threading.Tasks.TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
// Resolve and apply the theme BEFORE any window is shown so we don't
// paint a dark frame for one tick then flip to light (or vice versa).
// ThemeManager.Apply swaps Application.Resources.MergedDictionaries
// in place; DynamicResource refs in WildDragonTheme.xaml re-bind.
TeamsISO.App.Services.ThemeManager.Current.Apply();
// Single-instance gate: if another TeamsISO is already running for this user,
// broadcast the bring-to-front message and exit silently. This prevents the
// NDI/config contention seen during testing where two finders, two senders
// with the same default name, and two writers to config.json all raced.
bool createdNew;
_singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true, SingleInstanceMutexName, out createdNew);
_ownsSingleInstanceMutex = createdNew;
if (!createdNew)
{
var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
if (bringToFront != 0)
SendNotifyMessageW(HWND_BROADCAST, bringToFront, IntPtr.Zero, IntPtr.Zero);
Shutdown(0);
return;
}
// Listen for the broadcast — if a *new* instance launches and finds us already
// running, it'll send this message; we surface our window in response. Hold the
// delegate in a field so OnExit can unsubscribe cleanly even though the
// AppDomain teardown would also drop it.
var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
_bringToFrontHandler = (ref System.Windows.Interop.MSG msg, ref bool handled) =>
{
if (msg.message == (int)bringToFrontMsg && MainWindow is not null)
{
if (MainWindow.WindowState == WindowState.Minimized) MainWindow.WindowState = WindowState.Normal;
MainWindow.Activate();
MainWindow.Topmost = true;
MainWindow.Topmost = false;
handled = true;
}
};
ComponentDispatcher.ThreadFilterMessage += _bringToFrontHandler;
try
{
// WPF host: write to both console (visible if attached) and a rolling daily
// file under %LOCALAPPDATA%\TeamsISO\Logs so users have something to grab when
// they file an issue.
_loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
var logger = _loggerFactory.CreateLogger<App>();
logger.LogInformation(
"TeamsISO.App starting up. Build: {Version}. Process: {Pid}.",
typeof(App).Assembly.GetName().Version,
Environment.ProcessId);
// ---- Preflight: NDI runtime ----
try
{
_interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger<NdiInteropPInvoke>());
}
catch (Exception ex)
{
MessageBox.Show(
"TeamsISO could not initialize the NDI runtime.\n\n" +
"Install the NDI Runtime from https://ndi.video/tools/ and try again.\n\n" +
"Details: " + ex.Message,
"TeamsISO — NDI runtime missing",
MessageBoxButton.OK,
MessageBoxImage.Error);
Shutdown(2);
return;
}
// ---- Engine wiring ----
var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"TeamsISO", "config.json");
var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger<ConfigStore>());
var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix);
var scaler = new ManagedNearestNeighborFrameScaler();
var loggerFactoryRef = _loggerFactory;
var interopRef = _interop;
IsoPipeline PipelineFactory(IsoPipelineConfig config)
{
var clock = new PeriodicTimerFrameClock(config.Settings.FramerateHz);
return new IsoPipeline(
config, interopRef, scaler, clock,
ExponentialBackoff.Default,
(delay, ct) => Task.Delay(delay, ct),
loggerFactoryRef);
}
_controller = new IsoController(
_interop, PipelineFactory, configStore, probe, _loggerFactory);
_viewModel = new MainViewModel(_controller, Dispatcher);
var window = new MainWindow(_viewModel);
window.Show();
MainWindow = window;
// REST control surface for Stream Deck / Companion. Off by default —
// operators turn it on via the DISPLAY tab. When the toggle flips,
// GlobalSettingsViewModel reaches into App.Current to start/stop it.
_controlSurface = new TeamsISO.App.Services.ControlSurfaceServer(
_controller,
() => _viewModel,
_loggerFactory.CreateLogger<TeamsISO.App.Services.ControlSurfaceServer>());
_oscBridge = new TeamsISO.App.Services.OscBridge(
_controller,
() => _viewModel,
_loggerFactory.CreateLogger<TeamsISO.App.Services.OscBridge>());
// Auto-start the REST + WebSocket control surface if the operator
// turned it on in a previous session. The settings VM's setter
// also calls Start when the operator toggles it during a session;
// this block covers the "restart the app, expect it still on" case.
if (_viewModel.Settings.ControlSurfaceEnabled)
{
try
{
_controlSurface.Start(
_viewModel.Settings.ControlSurfacePort,
_viewModel.Settings.ControlSurfaceLanReachable);
}
catch (Exception ex)
{
_loggerFactory.CreateLogger<App>().LogWarning(ex,
"Control surface auto-start failed; operator can retry via Settings.");
}
}
// DiskSpaceWatcher removed alongside the rest of the recording surface.
// Tray icon host. Disabled by default; the settings VM flips
// Enabled when the operator toggles the DISPLAY checkbox. Hosting
// it from App ensures the icon's lifetime matches the process,
// not the main window (which gets hidden during minimize-to-tray).
_trayIcon = new TeamsISO.App.Services.TrayIconHost(window)
{
Enabled = _viewModel.Settings.MinimizeToTray,
};
// First-launch onboarding. The dialog explains the once-per-machine
// setup (NDI runtime, Teams admin permission, transcoder topology)
// that the UI alone can't communicate clearly. Suppressed after the
// user dismisses it with the checkbox checked. We show it AFTER the
// main window so the dialog has a sensible Owner for centering and
// z-order.
if (OnboardingWindow.ShouldShow())
{
try
{
var onboarding = new OnboardingWindow { Owner = window };
onboarding.ShowDialog();
}
catch
{
// Defensive: an onboarding-dialog failure should never block startup.
}
}
// Parse CLI args BEFORE InitializeAsync so any --apply-preset request
// overrides the persisted auto-apply preference cleanly.
ApplyCommandLineArgs(e.Args);
await _viewModel.InitializeAsync(CancellationToken.None);
// Auto-launch Teams in the background if the operator has opted in.
// Combined with AutoHideTeamsWindows this gives the "I only see
// TeamsISO" experience — Teams runs but never appears on screen,
// and all interaction routes through the IN-CALL bar + participants
// DataGrid. Fire-and-forget so a slow Teams launch doesn't delay
// TeamsISO's window from appearing.
if (_viewModel.Settings.LaunchTeamsOnStartup && !Services.TeamsLauncher.IsRunning())
{
_ = Task.Run(() =>
{
try
{
if (Services.TeamsLauncher.TryLaunch(out var launchError))
{
if (_viewModel.Settings.AutoHideTeamsWindows)
_ = Services.TeamsLauncher.AutoHideAfterLaunchAsync();
}
else
{
logger.LogWarning("Auto-launch Teams on startup failed: {Error}", launchError);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Auto-launch Teams on startup threw");
}
});
}
else if (_viewModel.Settings.AutoHideTeamsWindows && Services.TeamsLauncher.IsRunning())
{
// Teams is already up from a previous session. If auto-hide is
// on, hide it now so the operator's "I only see TeamsISO" rule
// applies even when Teams was launched externally.
_ = Services.TeamsLauncher.AutoHideAfterLaunchAsync();
}
// Background update check, throttled to once per 24h. Fire-and-forget
// so a slow / offline update server never delays startup. Surfaces a
// banner via UpdateBanner if newer; failures just log.
if (Services.UpdateChecker.LaunchCheckEnabled)
{
_ = Task.Run(async () =>
{
try
{
var result = await Services.UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
if (result?.Status == Services.UpdateChecker.UpdateStatus.UpdateAvailable
&& !string.IsNullOrEmpty(result.LatestTag)
&& !string.IsNullOrEmpty(result.CurrentVersion))
{
await Dispatcher.InvokeAsync(() =>
_viewModel.UpdateBanner.Show(result.CurrentVersion!, result.LatestTag!));
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Background update check failed");
}
});
}
}
catch (Exception ex)
{
// Log the full exception (incl. stack + inner) to Serilog BEFORE the
// 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"); }
catch { /* defensive */ }
MessageBox.Show(
"TeamsISO failed to start.\n\nDetails: " + ex,
"TeamsISO — startup error",
MessageBoxButton.OK,
MessageBoxImage.Error);
Shutdown(1);
}
}
/// <summary>
/// Where the rolling Serilog file sink writes. Reused by the crash dialog so we
/// can show the user the exact directory to attach when filing a bug.
/// </summary>
private static string LogDirectory =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Logs");
/// <summary>
/// Parse the supported CLI flags. Currently:
/// <c>--apply-preset NAME</c> — apply the named preset once participants
/// populate. Equivalent to running TeamsISO and clicking Presets → select →
/// Apply, but driven from a desktop shortcut.
/// Unrecognized flags are silently ignored — operators using shortcut.lnk
/// files don't need to fight argument parsers.
/// </summary>
private void ApplyCommandLineArgs(string[] args)
{
if (_viewModel is null) return;
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--apply-preset":
if (i + 1 < args.Length && !string.IsNullOrEmpty(args[i + 1]))
{
_viewModel.RequestApplyPresetOnStartup(args[i + 1]);
i++; // consume the value
}
break;
}
}
}
private void OnAppDomainUnhandled(object sender, UnhandledExceptionEventArgs e)
{
// IsTerminating is almost always true here — finalizers and managed-thread
// top-frames don't have a graceful path back. Log + show a dialog inline
// since the process will exit either way.
var ex = e.ExceptionObject as Exception;
TryLogFatal("AppDomain.UnhandledException", ex);
TryShowCrashDialog(ex, terminating: e.IsTerminating);
}
private void OnDispatcherUnhandled(object sender, DispatcherUnhandledExceptionEventArgs e)
{
TryLogFatal("Dispatcher.UnhandledException", e.Exception);
TryShowCrashDialog(e.Exception, terminating: false);
// Mark Handled so a single bad UI thunk doesn't take the whole app down —
// the user has the dialog and the log; they can choose to keep going.
e.Handled = true;
}
private void OnUnobservedTaskException(object? sender, System.Threading.Tasks.UnobservedTaskExceptionEventArgs e)
{
TryLogFatal("TaskScheduler.UnobservedTaskException", e.Exception);
// Don't show a dialog here — these fire from the finalizer thread and
// tend to be cleanup-time noise, not user-actionable. Log only.
e.SetObserved();
}
private void TryLogFatal(string source, Exception? ex)
{
try
{
var logger = _loggerFactory?.CreateLogger<App>();
logger?.LogCritical(ex, "{Source} fired", source);
}
catch
{
// Logger itself failed (rare — disk full, permission denied). Swallow:
// there's nothing useful we can do, and re-throwing during crash
// handling makes things worse.
}
}
private static void TryShowCrashDialog(Exception? ex, bool terminating)
{
try
{
var heading = terminating
? "TeamsISO encountered an unrecoverable error and will exit."
: "TeamsISO encountered an error.";
var details = ex?.GetType().Name + ": " + (ex?.Message ?? "(no details)");
var body =
heading + "\n\n" +
details + "\n\n" +
$"A full diagnostic log has been written to:\n{LogDirectory}\n\n" +
"Attach the most recent file from that directory to your bug report.";
MessageBox.Show(body, "TeamsISO — Error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
catch
{
// Even the dialog failed (e.g., during shutdown when the message pump
// is already gone). Nothing more to do.
}
}
protected override async void OnExit(ExitEventArgs e)
{
try
{
_trayIcon?.Dispose();
if (_controlSurface is not null)
await _controlSurface.DisposeAsync();
if (_oscBridge is not null)
await _oscBridge.DisposeAsync();
_viewModel?.Dispose();
if (_controller is not null)
await _controller.DisposeAsync();
_interop?.Dispose();
_loggerFactory?.Dispose();
}
catch
{
// Best-effort shutdown
}
finally
{
// Unsubscribe the bring-to-front filter so the delegate doesn't outlive
// the App; ComponentDispatcher is process-static.
if (_bringToFrontHandler is not null)
{
ComponentDispatcher.ThreadFilterMessage -= _bringToFrontHandler;
_bringToFrontHandler = null;
}
// Release the Mutex iff we acquired it. The "lost the race" path above
// sets _ownsSingleInstanceMutex=false and we skip ReleaseMutex (which
// would throw ApplicationException on an unowned Mutex).
try { if (_ownsSingleInstanceMutex) _singleInstanceMutex?.ReleaseMutex(); }
catch { /* defensive: already-released or invalid handle */ }
_singleInstanceMutex?.Dispose();
}
base.OnExit(e);
}
}

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View file

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View file

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View file

@ -1,8 +1,8 @@
using System.Globalization;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace DragonISO.App.Converters;
namespace TeamsISO.App.Converters;
[ValueConversion(typeof(bool), typeof(Visibility))]
public sealed class BoolToVisibilityConverter : IValueConverter

View file

@ -1,9 +1,9 @@
using System.Collections;
using System.Collections;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace DragonISO.App.Converters;
namespace TeamsISO.App.Converters;
/// <summary>
/// Maps a collection (or its count) to <see cref="Visibility"/>. Pass

View file

@ -1,8 +1,8 @@
using System.Globalization;
using System.Globalization;
using System.Windows.Data;
using DragonISO.Engine.Domain;
using TeamsISO.Engine.Domain;
namespace DragonISO.App.Converters;
namespace TeamsISO.App.Converters;
/// <summary>
/// Renders engine enum values into operator-friendly strings.
@ -39,7 +39,7 @@ public sealed class EnumDescriptionConverter : IValueConverter
},
AudioMode m => m switch
{
AudioMode.Auto => "Auto (isolated → mixed fallback)",
AudioMode.Auto => "Auto (isolated mixed fallback)",
AudioMode.Isolated => "Isolated",
AudioMode.Mixed => "Mixed",
_ => m.ToString()

View file

@ -1,26 +1,26 @@
using System.Globalization;
using System.Globalization;
using System.Windows.Data;
namespace DragonISO.App.Converters;
namespace TeamsISO.App.Converters;
/// <summary>
/// Converts a display name to up to two uppercase initials for an avatar bubble.
/// "Brendon Power" → "BP". "(Local)" → "L". Falls back to "·" for empty inputs.
/// "Brendon Power" → "BP". "(Local)" → "L". Falls back to "·" for empty inputs.
/// </summary>
public sealed class InitialsConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var s = value as string;
if (string.IsNullOrWhiteSpace(s)) return "·";
if (string.IsNullOrWhiteSpace(s)) return "·";
// Strip surrounding parens / punctuation that would otherwise become
// useless initials (e.g. "(Local)" should yield "L", not "(").
var cleaned = new string(s.Where(c => char.IsLetterOrDigit(c) || char.IsWhiteSpace(c)).ToArray()).Trim();
if (cleaned.Length == 0) return "·";
if (cleaned.Length == 0) return "·";
var parts = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0) return "·";
if (parts.Length == 0) return "·";
if (parts.Length == 1) return char.ToUpperInvariant(parts[0][0]).ToString();
return $"{char.ToUpperInvariant(parts[0][0])}{char.ToUpperInvariant(parts[^1][0])}";
}

View file

@ -1,10 +1,10 @@
using System.Globalization;
using System.Globalization;
using System.Windows.Data;
namespace DragonISO.App.Converters;
namespace TeamsISO.App.Converters;
/// <summary>
/// Maps an audio level (0.0–1.0) to an opacity for a single audio-meter
/// Maps an audio level (0.01.0) to an opacity for a single audio-meter
/// segment. The XAML binds five copies, each with a different
/// <see cref="Binding.ConverterParameter"/> threshold (0.2, 0.4, 0.6,
/// 0.8, 1.0). A segment renders at full opacity when the live level
@ -21,7 +21,7 @@ public sealed class LevelThresholdConverter : IValueConverter
/// <summary>Opacity for an above-threshold segment. Defaults to 1.0.</summary>
public double ActiveOpacity { get; set; } = 1.0;
/// <summary>Opacity for a below-threshold segment. Defaults to 0.18 — visible enough to read the segment shape but clearly off.</summary>
/// <summary>Opacity for a below-threshold segment. Defaults to 0.18 visible enough to read the segment shape but clearly off.</summary>
public double InactiveOpacity { get; set; } = 0.18;
public object Convert(object value, System.Type targetType, object parameter, CultureInfo culture)

View file

@ -1,11 +1,11 @@
using System.Globalization;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace DragonISO.App.Converters;
namespace TeamsISO.App.Converters;
/// <summary>
/// Tiny utility converter — null or empty string ⇒ Collapsed, otherwise
/// Tiny utility converter — null or empty string ⇒ Collapsed, otherwise
/// Visible. Used by the v2 command palette's optional shortcut chip
/// (e.g. "Ctrl+R") so rows without a bound shortcut don't render the
/// empty pill outline.

View file

@ -1,9 +1,9 @@
<Window x:Class="DragonISO.App.HelpWindow"
<Window x:Class="TeamsISO.App.HelpWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Help"
Icon="/Assets/DragonISO.ico"
Icon="/Assets/teamsiso.ico"
Width="540" Height="560"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
@ -54,7 +54,7 @@
<!-- Header -->
<StackPanel Grid.Row="1" Margin="0,16,0,16">
<TextBlock Text="DragonISO cheat sheet"
<TextBlock Text="TeamsISO cheat sheet"
Style="{StaticResource Wd.Text.Title}"/>
<TextBlock Text="Keyboard shortcuts, file locations, and quick links."
Style="{StaticResource Wd.Text.Subtle}"
@ -148,22 +148,22 @@
LineHeight="20">
<Run Text="Engine config" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
<LineBreak/>
<Run Text="%APPDATA%\DragonISO\config.json"/>
<Run Text="%APPDATA%\TeamsISO\config.json"/>
<LineBreak/>
<LineBreak/>
<Run Text="Operator presets" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
<LineBreak/>
<Run Text="%LOCALAPPDATA%\DragonISO\presets.json"/>
<Run Text="%LOCALAPPDATA%\TeamsISO\presets.json"/>
<LineBreak/>
<LineBreak/>
<Run Text="Diagnostic logs (rolling daily)" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
<LineBreak/>
<Run Text="%LOCALAPPDATA%\DragonISO\Logs\"/>
<Run Text="%LOCALAPPDATA%\TeamsISO\Logs\"/>
<LineBreak/>
<LineBreak/>
<Run Text="Recordings (default)" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
<LineBreak/>
<Run Text="%USERPROFILE%\Videos\DragonISO\&lt;date&gt;\"/>
<Run Text="%USERPROFILE%\Videos\TeamsISO\&lt;date&gt;\"/>
<LineBreak/>
<LineBreak/>
<Run Text="NDI Access Manager config" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
@ -197,7 +197,7 @@
Foreground="{DynamicResource Wd.Accent.Cyan}"
TextDecorations="None"
Click="OnDocsClick">
forge.wilddragon.net/zgaetano/DragonISO
forge.wilddragon.net/zgaetano/teamsiso
</Hyperlink>
</TextBlock>
</StackPanel>

View file

@ -1,7 +1,7 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Windows;
namespace DragonISO.App;
namespace TeamsISO.App;
/// <summary>
/// F1 cheat-sheet dialog. Lists keyboard shortcuts, file locations, and a
@ -20,7 +20,7 @@ public partial class HelpWindow : Window
{
Process.Start(new ProcessStartInfo
{
FileName = "https://forge.wilddragon.net/zgaetano/Dragon-ISO",
FileName = "https://forge.wilddragon.net/zgaetano/teamsiso",
UseShellExecute = true,
});
}

View file

@ -1,12 +1,12 @@
<Window x:Class="DragonISO.App.MainWindow"
<Window x:Class="TeamsISO.App.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:DragonISO.App.ViewModels"
xmlns:conv="clr-namespace:DragonISO.App.Converters"
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels"
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
mc:Ignorable="d"
Title="DragonISO"
Title="TeamsISO"
Width="1280" Height="780"
MinWidth="980" MinHeight="640"
WindowStartupLocation="CenterScreen"
@ -19,12 +19,11 @@
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
<!--
v2 SHELL — "Studio Terminal"
(Approved 2026-05-13. Shape brief at docs/shapes/2026-05-13-DragonISO-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
below it carries the brand mark, wordmark, and two icon buttons:
theme toggle and settings drawer. (Command palette is still reachable
via Ctrl+K — keybinding only, no visible button.) Below that, a
below it carries the brand mark, wordmark, and three icon buttons:
⌘K (command palette), theme toggle, settings drawer. Below that, a
single transport strip carries the operator's at-a-glance status.
The participants area is the canvas — no rail, no permanent side
panel, no footer. The meeting bar at the bottom renders ONLY when
@ -40,7 +39,6 @@
FalseValue="Visible"/>
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
<conv:LevelThresholdConverter x:Key="LevelGate"/>
<conv:CountToVisibilityConverter x:Key="CountToVis"/>
</Window.Resources>
<Window.InputBindings>
@ -105,16 +103,12 @@
Background="Transparent"
BorderThickness="0"
Cursor="Hand"
ToolTip="About DragonISO">
<!-- 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}"
ToolTip="About TeamsISO">
<Image Source="/Assets/dragon-mark.png"
Width="20" Height="20"
RenderOptions.BitmapScalingMode="HighQuality"/>
</Button>
<TextBlock Text="DragonISO"
<TextBlock Text="TeamsISO"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="13"
FontWeight="Medium"
@ -123,21 +117,28 @@
Margin="8,0,0,0"/>
</StackPanel>
<!-- Right cluster: two icon buttons. The theme button cycles
<!-- Right cluster: three icon buttons. ⌘K opens the command
palette (Ctrl+K shortcut). The theme button cycles
dark ↔ light (Ctrl+T). The gear opens the settings
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. -->
drawer. That's the entire chrome. -->
<StackPanel Grid.Column="2"
Orientation="Horizontal"
VerticalAlignment="Center"
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}"
Command="{Binding ToggleThemeCommand}"
Padding="6,4"
Margin="0,0,2,0"
ToolTip="Theme (System / Dark / Light)">
ToolTip="Toggle theme (Ctrl+T)">
<Path Data="M 8,2 C 8,2 4,4 4,8 C 4,12 8,14 8,14 C 5,14 2,11 2,8 C 2,5 5,2 8,2 Z"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.4"
@ -145,21 +146,16 @@
Width="14" Height="14"
Stretch="None"/>
</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}"
Click="OnSettingsToggleClick"
Padding="6,2"
Padding="6,4"
ToolTip="Settings">
<TextBlock Text="&#x2699;"
FontSize="16"
FontFamily="Segoe UI Symbol"
Foreground="{DynamicResource Wd.Text.Secondary}"
VerticalAlignment="Center"
HorizontalAlignment="Center"/>
<Path Data="M 8,1 L 8,3 M 8,13 L 8,15 M 1,8 L 3,8 M 13,8 L 15,8 M 3,3 L 4.5,4.5 M 11.5,11.5 L 13,13 M 3,13 L 4.5,11.5 M 11.5,4.5 L 13,3 M 8,5.5 C 9.4,5.5 10.5,6.6 10.5,8 C 10.5,9.4 9.4,10.5 8,10.5 C 6.6,10.5 5.5,9.4 5.5,8 C 5.5,6.6 6.6,5.5 8,5.5"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="14" Height="14"
Stretch="None"/>
</Button>
</StackPanel>
</Grid>
@ -414,7 +410,7 @@
<!--
Participants table — v2 "Studio Terminal" layout.
Spec columns (from docs/shapes/2026-05-13-…studio-terminal.md):
Five columns:
1. State LED 24px — 8×8 hard-edged square. Filled cyan
when LIVE; filled coral on ERROR;
filled amber on NO SIGNAL / STARTING;
@ -426,22 +422,12 @@
each lit when DisplayedAudioLevel
crosses its threshold (0.2, 0.4,
0.6, 0.8, 1.0). No averaging.
4. Output name 130px — JetBrains Mono 12 — the NDI source
name DragonISO broadcasts as.
5. ISO toggle pill 100px — LIVE = cyan-muted fill with cyan
4. Output name 150px — JetBrains Mono 12 — the NDI source
name TeamsISO broadcasts as.
5. ISO toggle pill 110px — LIVE = cyan-muted fill with cyan
text; OFF = hollow neutral; ERROR
gets the existing trigger swap.
Deliberate deviations from the spec (operator preference, see
4944de5 — "restore live thumbnail preview column"):
• A 106px live thumbnail column sits between State LED and
Name. Replaces the table's previous role as the only place
to see what the operator is broadcasting; the pop-out
preview window is the secondary view.
• A 32px ghost-button cell on the right edge of Name opens
the per-ISO override dialog (framerate / resolution /
aspect / audio). Hidden on hover-out.
Row height 52 (down from 56). Active speaker = full-row
bg.active-speaker tint set by the global DataGridRow style
(avoids the impeccable side-stripe-border ban).
@ -451,30 +437,7 @@
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.M}"
Background="{DynamicResource Wd.Surface}"
ClipToBounds="True">
<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"/>
Background="{DynamicResource Wd.Surface}">
<DataGrid x:Name="ParticipantsGrid"
ItemsSource="{Binding ParticipantsView}"
AutoGenerateColumns="False"
@ -488,8 +451,7 @@
CanUserResizeRows="False"
SelectionMode="Single"
SelectionUnit="FullRow"
RowHeight="52"
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}}">
RowHeight="52">
<DataGrid.Columns>
<!-- Col 1 — State LED. 8×8 hard-edged Rectangle.
Default fill is hollow (transparent with stroke). DataTriggers
@ -637,70 +599,52 @@
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 4 — Output name (mono, INLINE EDITABLE). The NDI source
name DragonISO will broadcast this participant as. Defaults
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">
<!-- Col 4 — Output name (mono). The NDI source name TeamsISO
will broadcast this participant as. -->
<DataGridTemplateColumn Header="Output" Width="150" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBox Text="{Binding EditableOutputName, UpdateSourceTrigger=LostFocus, Mode=TwoWay}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
Background="Transparent"
BorderThickness="0"
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."/>
<TextBlock Text="{Binding OutputName}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 5a — Per-row gear: opens the ISO override editor for this
participant. We use the Unicode gear glyph (U+2699) instead
of a custom Path — it renders cleanly at any size, doesn't
disappear against dark rows the way 1.4px strokes do, and
reads as "settings" at a glance. Header is "CFG" so the
affordance is discoverable even when the row hover state
isn't active. -->
<DataGridTemplateColumn Header="CFG" Width="56" IsReadOnly="True">
participant. Narrow (32px) so the table still fits inside a
1280px window after the toggle column. -->
<DataGridTemplateColumn Header="" Width="32" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnIsoOverrideClick"
Padding="6,2"
Padding="6,4"
VerticalAlignment="Center"
HorizontalAlignment="Center"
ToolTip="Override output settings for this participant (framerate, resolution, audio)">
<TextBlock Text="&#x2699;"
FontSize="16"
FontFamily="Segoe UI Symbol"
Foreground="{DynamicResource Wd.Text.Primary}"
VerticalAlignment="Center"
HorizontalAlignment="Center"/>
ToolTip="Override output settings for this participant">
<Path Data="M 8,1 L 8,3 M 8,13 L 8,15 M 1,8 L 3,8 M 13,8 L 15,8 M 3,3 L 4.5,4.5 M 11.5,11.5 L 13,13 M 3,13 L 4.5,11.5 M 11.5,4.5 L 13,3 M 8,5.5 C 9.4,5.5 10.5,6.6 10.5,8 C 10.5,9.4 9.4,10.5 8,10.5 C 6.6,10.5 5.5,9.4 5.5,8 C 5.5,6.6 6.6,5.5 8,5.5"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="14" Height="14"
Stretch="None"/>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 5 — ISO toggle. LIVE = cyan-muted fill + cyan border + cyan text.
OFF = hollow neutral. Error states use the existing IsoToggle style.
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">
<!-- Col 5 — ISO toggle pill. LIVE = cyan-muted fill + cyan border + cyan text.
OFF = hollow neutral. Error states use the existing IsoToggle style. -->
<DataGridTemplateColumn Header="ISO" Width="110">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Command="{Binding ToggleIsoCommand}"
Margin="0,0,12,0"
Padding="10,6"
Padding="14,6"
VerticalAlignment="Center">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource Wd.Button.IsoToggle}">
@ -721,60 +665,6 @@
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<!-- Empty-state placeholder. Renders when no NDI participants
have been discovered yet. Mono sentence + one tertiary
Refresh button — no illustration, no mascot, per the v2
shape brief's empty-states section.
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"
VerticalAlignment="Center"
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}, ConverterParameter=empty}">
<!-- Discovering: cyan dot + neutral progress copy. -->
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center"
Visibility="{Binding IsDiscovering, Converter={StaticResource BoolToVis}}">
<Ellipse Width="7" Height="7"
Fill="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"
Margin="0,0,10,0"/>
<TextBlock Text="scanning for ndi sources…"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
VerticalAlignment="Center"/>
</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>
</Grid>
</Border>
</Grid>
@ -1007,7 +897,7 @@
HorizontalAlignment="Stretch"
Margin="0,16,0,0"
Padding="0,9"/>
<TextBlock Text="Pins Teams' raw NDI broadcasts to a private 'DragonISO-input' group so they don't pollute the Public network. Restart Teams after applying."
<TextBlock Text="Pins Teams' raw NDI broadcasts to a private 'teamsiso-input' group so they don't pollute the Public network. Restart Teams after applying."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
@ -1020,7 +910,7 @@
<TextBox Text="{Binding Settings.OutputNameTemplate, UpdateSourceTrigger=LostFocus}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"/>
<TextBlock Text="Tokens: {name}, {guid}, {machine}, {timestamp}. Default: {name} — the speaker's display name."
<TextBlock Text="Tokens: {name}, {guid}, {machine}, {timestamp}. Default: TEAMSISO_{guid}."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
@ -1055,7 +945,7 @@
Height="1"
Background="{DynamicResource Wd.Border}"/>
<CheckBox Content="Launch Microsoft Teams on DragonISO startup"
<CheckBox Content="Launch Microsoft Teams on TeamsISO startup"
IsChecked="{Binding Settings.LaunchTeamsOnStartup}"/>
<CheckBox Content="Auto-hide Teams windows when launched"
IsChecked="{Binding Settings.AutoHideTeamsWindows}"

View file

@ -1,11 +1,11 @@
using System;
using System;
using System.Linq;
using System.Windows;
using System.Windows.Input;
using DragonISO.App.Services;
using DragonISO.App.ViewModels;
using TeamsISO.App.Services;
using TeamsISO.App.ViewModels;
namespace DragonISO.App;
namespace TeamsISO.App;
public partial class MainWindow : Window
{
@ -42,15 +42,10 @@ public partial class MainWindow : Window
/// <summary>Persist the placement on close so next launch lands in the same spot.</summary>
private void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
{
// A failure persisting window state must NEVER block the window from
// closing — operator's shutdown comes first. WindowStateStore.Save
// already swallows its own IO errors; this is defense-in-depth for
// anything that escapes (NRE, future regression, etc.).
try { WindowStateStore.Save(this); }
catch { /* best-effort: forgo placement memory for one launch */ }
WindowStateStore.Save(this);
}
/// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary>
/// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary>
private void OnAboutClick(object sender, RoutedEventArgs e)
{
var about = new AboutWindow { Owner = this };
@ -77,7 +72,7 @@ public partial class MainWindow : Window
/// Tracks whether we have hidden Teams' windows so the next click reverses
/// the action. We treat this as "intent" rather than a query of OS state
/// because hidden windows still report as hidden if the operator manually
/// re-opens them and we only care about Dragon-ISO's own toggle history.
/// re-opens them and we only care about TeamsISO's own toggle history.
/// </summary>
private bool _teamsWindowsHidden;
@ -92,8 +87,8 @@ public partial class MainWindow : Window
if (!TeamsLauncher.IsRunning())
{
MessageBox.Show(
Properties.Strings.HideShowTeams_NotRunning_Message,
Properties.Strings.HideShowTeams_Title,
"Microsoft Teams isn't running. Click the camera icon above to launch it first.",
"TeamsISO — Hide / show Teams",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@ -116,9 +111,9 @@ public partial class MainWindow : Window
/// <summary>
/// Three-state click behavior matching operator intuition:
/// 1. Teams not running → launch it via TeamsLauncher's fallback chain.
/// 2. Teams running but its windows are hidden → restore + foreground them.
/// 3. Teams running with visible windows → bring the most recent to front.
/// 1. Teams not running launch it via TeamsLauncher's fallback chain.
/// 2. Teams running but its windows are hidden restore + foreground them.
/// 3. Teams running with visible windows bring the most recent to front.
/// (Stopping Teams is a right-click action; see OnLaunchTeamsRightClick.)
/// </summary>
private void OnLaunchTeamsClick(object sender, RoutedEventArgs e)
@ -130,8 +125,8 @@ public partial class MainWindow : Window
if (!TeamsLauncher.TryLaunch(out var error))
{
MessageBox.Show(
string.Format(Properties.Strings.LaunchTeams_Failed_MessageFormat, error),
Properties.Strings.LaunchTeams_Title,
$"Could not launch Microsoft Teams.\n\n{error}",
"TeamsISO — Launch Teams",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
@ -139,8 +134,8 @@ public partial class MainWindow : Window
{
var autoHide = (DataContext as MainViewModel)?.Settings.AutoHideTeamsWindows ?? false;
toast?.Show(autoHide
? "Launching Microsoft Teams (will hide windows automatically)…"
: "Launching Microsoft Teams…");
? "Launching Microsoft Teams (will hide windows automatically)"
: "Launching Microsoft Teams");
if (autoHide)
{
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
@ -157,26 +152,22 @@ public partial class MainWindow : Window
var shown = TeamsLauncher.ShowWindows();
_teamsWindowsHidden = false;
toast?.Show(shown > 0
? $"Teams is already running — surfaced {shown} window(s)"
? $"Teams is already running surfaced {shown} window(s)"
: "Teams is running but has no visible windows yet");
}
/// <summary>
/// Right-click on the Launch button asks to stop Teams. Split out from the
/// left-click so a normal click is "open / surface" rather than the previous
/// "open OR ambush you with a stop dialog". The confirmation dialog here is
/// intentional — Stop Teams is a destructive mid-show action; explicit
/// confirmation is the right pattern, not the "ambush" anti-pattern that
/// was fixed for left-click. The palette also offers Stop Teams for
/// keyboard-first operators.
/// "open OR ambush you with a stop dialog".
/// </summary>
private void OnLaunchTeamsRightClick(object sender, MouseButtonEventArgs e)
{
if (!TeamsLauncher.IsRunning()) return;
var confirm = MessageBox.Show(
Properties.Strings.StopTeams_Confirm_Message,
Properties.Strings.StopTeams_Title,
"Microsoft Teams is currently running.\n\nClose all Teams windows now?",
"TeamsISO — Stop Teams",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes) return;
@ -186,9 +177,9 @@ public partial class MainWindow : Window
{
MessageBox.Show(
asked == 0
? Properties.Strings.StopTeams_NoneResponded
: string.Format(Properties.Strings.StopTeams_AskedFormat, asked),
Properties.Strings.StopTeams_Title,
? "No Teams windows responded to close."
: $"Sent close to {asked} Teams window(s); some may still be exiting.",
"TeamsISO — Stop Teams",
MessageBoxButton.OK,
MessageBoxImage.Information);
}
@ -208,8 +199,8 @@ public partial class MainWindow : Window
/// <summary>
/// Toggle the v2 settings drawer overlay. The header gear button and the
/// drawer's own Close button both call this. State is held by the
/// overlay's <see cref="UIElement.Visibility"/> directly — no separate
/// flag — so the toggle is idempotent regardless of how many entry
/// overlay's <see cref="UIElement.Visibility"/> directly no separate
/// flag so the toggle is idempotent regardless of how many entry
/// points open / close it.
/// </summary>
private void OnSettingsToggleClick(object sender, RoutedEventArgs e)
@ -221,7 +212,7 @@ public partial class MainWindow : Window
}
/// <summary>
/// Clicking the scrim behind the drawer dismisses it — same affordance as
/// Clicking the scrim behind the drawer dismisses it same affordance as
/// every well-behaved slide-over on every platform.
/// </summary>
private void OnSettingsScrimClick(object sender, MouseButtonEventArgs e)
@ -231,7 +222,7 @@ public partial class MainWindow : Window
}
/// <summary>
/// Open the v2 Ctrl+K command palette. Bound to the header ⌘K button and
/// Open the v2 Ctrl+K command palette. Bound to the header K button and
/// to the Ctrl+K keyboard binding. The palette is a chromeless floating
/// window owned by this MainWindow so it centers correctly, closes on
/// Deactivated (click outside), and inherits z-order. We construct a

View file

@ -1,9 +1,9 @@
<Window x:Class="DragonISO.App.NotesWindow"
<Window x:Class="TeamsISO.App.NotesWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Show notes"
Icon="/Assets/DragonISO.ico"
Icon="/Assets/teamsiso.ico"
Width="540" Height="560"
WindowStartupLocation="CenterOwner"
WindowStyle="None"

View file

@ -1,10 +1,10 @@
using System.Diagnostics;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Threading;
using DragonISO.App.Services;
using TeamsISO.App.Services;
namespace DragonISO.App;
namespace TeamsISO.App;
/// <summary>
/// Inline viewer for the daily show-notes file. Reads
@ -12,7 +12,7 @@ namespace DragonISO.App;
/// shown so REST/OSC-driven note appends surface live without the operator
/// having to click Refresh.
///
/// We don't allow editing here — the file is intentionally a one-way log
/// We don't allow editing here the file is intentionally a one-way log
/// (operator stamps, post-show review). If someone wants to edit, they
/// click "Open in editor" and use Notepad.
/// </summary>
@ -32,7 +32,7 @@ public partial class NotesWindow : Window
_refreshTimer.Tick += (_, _) => RefreshIfChanged();
Loaded += (_, _) =>
{
DateLine.Text = $"{DateTimeOffset.Now:dddd, MMMM d, yyyy} · {NotesService.TodayPath}";
DateLine.Text = $"{DateTimeOffset.Now:dddd, MMMM d, yyyy} · {NotesService.TodayPath}";
ReloadFromDisk();
_refreshTimer.Start();
};
@ -44,7 +44,7 @@ public partial class NotesWindow : Window
private void OnRefresh(object sender, RoutedEventArgs e) => ReloadFromDisk();
/// <summary>
/// Cheap mtime/size check — only re-reads the file when something changed.
/// Cheap mtime/size check only re-reads the file when something changed.
/// Saves the textbox a flicker on every 2s tick when no notes are being
/// added. Falls through to a full reload if the file got smaller (operator
/// might have edited externally).
@ -79,7 +79,7 @@ public partial class NotesWindow : Window
_lastFileSize = info.Length;
_lastFileWrite = info.LastWriteTimeUtc;
NotesText.Text = File.ReadAllText(path);
// Scroll to bottom so the latest stamp is visible — operators are
// Scroll to bottom so the latest stamp is visible operators are
// typically reading "what just happened" not "what happened first."
Dispatcher.BeginInvoke(new Action(() =>
{

View file

@ -1,9 +1,9 @@
<Window x:Class="DragonISO.App.OnboardingWindow"
<Window x:Class="TeamsISO.App.OnboardingWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Welcome to DragonISO"
Icon="/Assets/DragonISO.ico"
Title="Welcome to TeamsISO"
Icon="/Assets/teamsiso.ico"
Width="560" Height="600"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
@ -59,7 +59,7 @@
HorizontalAlignment="Left"
Margin="0,0,0,12"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="DragonISO routes Microsoft Teams participants as isolated NDI feeds."
<TextBlock Text="TeamsISO routes Microsoft Teams participants as isolated NDI feeds."
Style="{StaticResource Wd.Text.Title}"
TextWrapping="Wrap"/>
<TextBlock Text="A few one-time setup notes before you start."
@ -96,7 +96,7 @@
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
Text="DragonISO depends on NDI 6 from NewTek/Vizrt. Get it at ndi.video/tools — pick the Tools bundle for free, or use the Runtime-only installer if you have an NDI subscription."/>
Text="TeamsISO depends on NDI 6 from NewTek/Vizrt. Get it at ndi.video/tools — pick the Tools bundle for free, or use the Runtime-only installer if you have an NDI subscription."/>
</StackPanel>
</Border>
@ -152,7 +152,7 @@
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
Text="Settings → NETWORK → Apply transcoder topology. This pins Teams' raw NDI broadcasts to a private 'DragonISO-input' group so they don't pollute the Public network. Restart Teams once after applying."/>
Text="Settings → NETWORK → Apply transcoder topology. This pins Teams' raw NDI broadcasts to a private 'teamsiso-input' group so they don't pollute the Public network. Restart Teams once after applying."/>
</StackPanel>
</Border>
@ -180,11 +180,11 @@
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
Text="Once you've toggled the right ISOs, click Presets in the participants header to save them by display name. Turn on 'Auto-apply last preset on launch' under DISPLAY settings and DragonISO will restore that routing on every subsequent launch."/>
Text="Once you've toggled the right ISOs, click Presets in the participants header to save them by display name. Turn on 'Auto-apply last preset on launch' under DISPLAY settings and TeamsISO will restore that routing on every subsequent launch."/>
</StackPanel>
</Border>
<!-- Step 5 — Headless Teams ("I only see DragonISO") -->
<!-- Step 5 — Headless Teams ("I only see TeamsISO") -->
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
@ -208,7 +208,7 @@
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
Text="To use DragonISO as your only window: tick both 'Launch Microsoft Teams on DragonISO startup' and 'Auto-hide Teams windows when launched' under Settings → DISPLAY. Teams runs in the background; the IN-CALL bar shows the meeting state (READY / IN CALL · meeting title), the Mute/Camera/Share/Leave buttons drive Teams via UIAutomation, and the URL paste-box joins meetings directly. Use the eye-icon button in the left rail to manually restore Teams' windows when you need them."/>
Text="To use TeamsISO as your only window: tick both 'Launch Microsoft Teams on TeamsISO startup' and 'Auto-hide Teams windows when launched' under Settings → DISPLAY. Teams runs in the background; the IN-CALL bar shows the meeting state (READY / IN CALL · meeting title), the Mute/Camera/Share/Leave buttons drive Teams via UIAutomation, and the URL paste-box joins meetings directly. Use the eye-icon button in the left rail to manually restore Teams' windows when you need them."/>
</StackPanel>
</Border>
@ -236,7 +236,7 @@
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
Text="For headless host PC + thin-client setups: tick 'Control surface' then 'LAN-reachable' under DISPLAY. DragonISO listens on http://&lt;your-lan-ip&gt;:9755/ui — open that URL from any browser on the LAN. First-time use needs a one-shot 'netsh http add urlacl url=http://+:9755/ user=Everyone' in an Administrator PowerShell."/>
Text="For headless host PC + thin-client setups: tick 'Control surface' then 'LAN-reachable' under DISPLAY. TeamsISO listens on http://&lt;your-lan-ip&gt;:9755/ui — open that URL from any browser on the LAN. First-time use needs a one-shot 'netsh http add urlacl url=http://+:9755/ user=Everyone' in an Administrator PowerShell."/>
</StackPanel>
</Border>
@ -264,7 +264,7 @@
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
Text="Diagnostic logs roll daily under %LOCALAPPDATA%\DragonISO\Logs. Settings live at %APPDATA%\DragonISO\config.json; presets at %LOCALAPPDATA%\DragonISO\presets.json. Attach the most recent log when filing an issue at forge.wilddragon.net/zgaetano/DragonISO."/>
Text="Diagnostic logs roll daily under %LOCALAPPDATA%\TeamsISO\Logs. Settings live at %APPDATA%\TeamsISO\config.json; presets at %LOCALAPPDATA%\TeamsISO\presets.json. Attach the most recent log when filing an issue at forge.wilddragon.net/zgaetano/teamsiso."/>
</StackPanel>
</Border>

View file

@ -1,7 +1,7 @@
using System.IO;
using System.IO;
using System.Windows;
namespace DragonISO.App;
namespace TeamsISO.App;
/// <summary>
/// First-launch welcome dialog. Walks the user through the once-per-machine
@ -10,8 +10,8 @@ namespace DragonISO.App;
/// presets live for later self-service.
///
/// Suppression is governed by a marker file at
/// <c>%LOCALAPPDATA%\Dragon-ISO\onboarding.flag</c>. The presence of the file —
/// regardless of contents — means "don't show again." The user can restore
/// <c>%LOCALAPPDATA%\TeamsISO\onboarding.flag</c>. The presence of the file —
/// regardless of contents means "don't show again." The user can restore
/// the dialog by deleting that file.
/// </summary>
public partial class OnboardingWindow : Window
@ -19,7 +19,7 @@ public partial class OnboardingWindow : Window
private static string FlagPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO", "onboarding.flag");
"TeamsISO", "onboarding.flag");
public OnboardingWindow() => InitializeComponent();
@ -30,7 +30,7 @@ public partial class OnboardingWindow : Window
public static bool ShouldShow()
{
try { return !File.Exists(FlagPath); }
catch { return false; } // permission errors → assume already shown
catch { return false; } // permission errors assume already shown
}
private void OnDismiss(object sender, RoutedEventArgs e)
@ -47,7 +47,7 @@ public partial class OnboardingWindow : Window
}
catch
{
// Disk full / permission denied — show the dialog again next launch
// Disk full / permission denied show the dialog again next launch
// rather than fail noisily.
}
}

View file

@ -1,9 +1,9 @@
<Window x:Class="DragonISO.App.PresetsDialog"
<Window x:Class="TeamsISO.App.PresetsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Operator presets"
Icon="/Assets/DragonISO.ico"
Icon="/Assets/teamsiso.ico"
Width="460" Height="520"
WindowStartupLocation="CenterOwner"
WindowStyle="None"

View file

@ -1,11 +1,11 @@
using System.Collections.ObjectModel;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using DragonISO.App.Services;
using DragonISO.App.ViewModels;
using DragonISO.Engine.Controller;
using TeamsISO.App.Services;
using TeamsISO.App.ViewModels;
using TeamsISO.Engine.Controller;
namespace DragonISO.App;
namespace TeamsISO.App;
/// <summary>
/// Modal dialog for saving and loading operator presets. Owned by
@ -101,7 +101,7 @@ public partial class PresetsDialog : Window
var confirm = MessageBox.Show(
this,
$"A preset named \"{name}\" already exists. Overwrite it?",
"Dragon-ISO — Overwrite preset",
"TeamsISO — Overwrite preset",
MessageBoxButton.YesNo,
MessageBoxImage.Question,
MessageBoxResult.No);
@ -122,7 +122,7 @@ public partial class PresetsDialog : Window
MessageBox.Show(
this,
$"Could not save preset.\n\n{ex.Message}",
"Dragon-ISO — Save preset",
"TeamsISO — Save preset",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
@ -130,7 +130,7 @@ public partial class PresetsDialog : Window
/// <summary>
/// Apply the selected preset: walks the current participants list, matching
/// by display name (the only stable join key across meetings — Ids are
/// by display name (the only stable join key across meetings Ids are
/// regenerated each meeting). For each match, set the custom output name and
/// reconcile its enabled state with the preset by calling EnableIsoAsync /
/// DisableIsoAsync as needed. Participants in the preset who aren't in the
@ -143,15 +143,15 @@ public partial class PresetsDialog : Window
ApplyButton.IsEnabled = false;
try
{
// PresetApplier owns the apply loop — same code path the REST control
// PresetApplier owns the apply loop same code path the REST control
// surface and auto-apply-on-launch use. Dialog passes null dispatcher
// since OnApply already runs on the UI thread.
var result = await PresetApplier.ApplyAsync(
row.Preset, _participants, _controller, dispatcher: null);
var summary = result.Skipped > 0
? $"Applied \"{row.Name}\" — {result.Changed} change(s); {result.Skipped} not in meeting"
: $"Applied \"{row.Name}\" — {result.Changed} change(s)";
? $"Applied \"{row.Name}\" {result.Changed} change(s); {result.Skipped} not in meeting"
: $"Applied \"{row.Name}\" {result.Changed} change(s)";
_toast?.Show(summary);
DialogResult = true;
Close();
@ -184,14 +184,14 @@ public partial class PresetsDialog : Window
var confirm = MessageBox.Show(
this,
$"A preset named \"{newName}\" already exists. Overwrite it?",
"Dragon-ISO — Duplicate preset",
"TeamsISO — Duplicate preset",
MessageBoxButton.YesNo,
MessageBoxImage.Question,
MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
}
// Re-using Save() with a fresh SavedAt timestamp — Save's overwrite
// Re-using Save() with a fresh SavedAt timestamp Save's overwrite
// semantics handle the name-collision case cleanly.
OperatorPresetStore.Save(new OperatorPresetStore.Preset(
Name: newName,
@ -204,14 +204,14 @@ public partial class PresetsDialog : Window
{
MessageBox.Show(this,
$"Could not duplicate preset.\n\n{ex.Message}",
"Dragon-ISO — Duplicate preset",
"TeamsISO — Duplicate preset",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
/// <summary>
/// Suggest a name for a copy: "Foo" → "Foo (copy)", "Foo (copy)" → "Foo (copy 2)".
/// Suggest a name for a copy: "Foo" → "Foo (copy)", "Foo (copy)" → "Foo (copy 2)".
/// Bumps the digit if the operator iterates from a copy.
/// </summary>
private static string SuggestCopyName(string original)
@ -275,7 +275,7 @@ public partial class PresetsDialog : Window
var confirm = MessageBox.Show(
this,
$"Delete preset \"{row.Name}\"? This cannot be undone.",
"Dragon-ISO — Delete preset",
"TeamsISO — Delete preset",
MessageBoxButton.YesNo,
MessageBoxImage.Warning,
MessageBoxResult.No);
@ -292,7 +292,7 @@ public partial class PresetsDialog : Window
MessageBox.Show(
this,
$"Could not delete preset.\n\n{ex.Message}",
"Dragon-ISO — Delete preset",
"TeamsISO — Delete preset",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
@ -313,9 +313,9 @@ public partial class PresetsDialog : Window
{
var dlg = new Microsoft.Win32.SaveFileDialog
{
Title = "Export Dragon-ISO presets",
FileName = $"Dragon-ISO-presets-{DateTimeOffset.Now:yyyy-MM-dd}.json",
Filter = "Dragon-ISO preset bundle (*.json)|*.json",
Title = "Export TeamsISO presets",
FileName = $"teamsiso-presets-{DateTimeOffset.Now:yyyy-MM-dd}.json",
Filter = "TeamsISO preset bundle (*.json)|*.json",
DefaultExt = "json",
};
if (dlg.ShowDialog(this) != true) return;
@ -330,7 +330,7 @@ public partial class PresetsDialog : Window
{
MessageBox.Show(this,
$"Could not export presets.\n\n{ex.Message}",
"Dragon-ISO — Export presets",
"TeamsISO — Export presets",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
@ -338,7 +338,7 @@ public partial class PresetsDialog : Window
/// <summary>
/// Load a bundle from a path the user picks. On name collision we ask once
/// (covering all collisions) whether to overwrite — a per-preset prompt would
/// (covering all collisions) whether to overwrite a per-preset prompt would
/// be exhausting for a 20-preset bundle. The Y/N here drives the overwrite
/// flag passed to <see cref="OperatorPresetStore.ImportBundle"/>.
/// </summary>
@ -346,8 +346,8 @@ public partial class PresetsDialog : Window
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = "Import Dragon-ISO presets",
Filter = "Dragon-ISO preset bundle (*.json)|*.json|All files (*.*)|*.*",
Title = "Import TeamsISO presets",
Filter = "TeamsISO preset bundle (*.json)|*.json|All files (*.*)|*.*",
};
if (dlg.ShowDialog(this) != true) return;
@ -357,7 +357,7 @@ public partial class PresetsDialog : Window
{
MessageBox.Show(this,
$"Could not read the file.\n\n{ex.Message}",
"Dragon-ISO — Import presets",
"TeamsISO — Import presets",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
@ -369,8 +369,8 @@ public partial class PresetsDialog : Window
catch
{
MessageBox.Show(this,
"That file isn't a valid Dragon-ISO preset bundle.",
"Dragon-ISO — Import presets",
"That file isn't a valid TeamsISO preset bundle.",
"TeamsISO — Import presets",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
@ -379,7 +379,7 @@ public partial class PresetsDialog : Window
{
MessageBox.Show(this,
"The bundle is empty.",
"Dragon-ISO — Import presets",
"TeamsISO — Import presets",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@ -398,7 +398,7 @@ public partial class PresetsDialog : Window
$"{collisions} preset name(s) in this bundle already exist on this machine.\n\n" +
"Yes = overwrite local copies with the bundle's versions.\n" +
"No = keep local copies; only import new presets.",
"Dragon-ISO — Import presets",
"TeamsISO — Import presets",
MessageBoxButton.YesNoCancel,
MessageBoxImage.Question,
MessageBoxResult.No);
@ -411,13 +411,13 @@ public partial class PresetsDialog : Window
{
MessageBox.Show(this,
$"Import failed.\n\n{result.Error}",
"Dragon-ISO — Import presets",
"TeamsISO — Import presets",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
var summary = $"Imported — {result.Added} new";
var summary = $"Imported {result.Added} new";
if (result.Overwritten > 0) summary += $", {result.Overwritten} overwritten";
if (result.Skipped > 0) summary += $", {result.Skipped} skipped";
_toast?.Show(summary);

View file

@ -1,9 +1,9 @@
<Window x:Class="DragonISO.App.PreviewWindow"
<Window x:Class="TeamsISO.App.PreviewWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Preview"
Icon="/Assets/DragonISO.ico"
Icon="/Assets/teamsiso.ico"
Width="640" Height="400"
MinWidth="320" MinHeight="200"
Background="Black"

View file

@ -1,21 +1,21 @@
using System.Windows;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Pipeline;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Pipeline;
namespace DragonISO.App;
namespace TeamsISO.App;
/// <summary>
/// Non-modal floating preview window for a single participant. Shows the
/// engine's most recent <see cref="ProcessedFrame"/> at a higher refresh
/// rate (~20Hz) than the participants DataGrid thumbnails (1Hz). Multi-
/// monitor friendly: operator drags it to a second display, leaves the
/// main Dragon-ISO window on the primary.
/// main TeamsISO window on the primary.
///
/// Uses <see cref="WriteableBitmap"/> with <see cref="WriteableBitmap.WritePixels(Int32Rect, IntPtr, int, int)"/>
/// — the engine produces full-resolution BGRA frames so we can write them
/// the engine produces full-resolution BGRA frames so we can write them
/// straight into the bitmap without scaling. WPF's Image control with
/// Stretch=Uniform handles aspect-correct fit to the window size.
/// </summary>
@ -71,12 +71,12 @@ public partial class PreviewWindow : Window
PreviewImage.Source = _bitmap;
_lastWidth = frame.Width;
_lastHeight = frame.Height;
ResolutionText.Text = $"{frame.Width}×{frame.Height}";
ResolutionText.Text = $"{frame.Width}×{frame.Height}";
}
// WritePixels takes a buffer + stride + rect. Stride = width * 4 for
// BGRA. We slice the ProcessedFrame's ReadOnlyMemory<byte> via .Span
// and use the IntPtr overload via MemoryMarshal — but the
// and use the IntPtr overload via MemoryMarshal but the
// byte-array overload is simpler and the compiler picks the right
// ToArray-free path because the engine already allocates a fresh
// array per frame.

View file

@ -1,8 +1,8 @@
namespace DragonISO.App.Services;
namespace TeamsISO.App.Services;
/// <summary>
/// The HTML / CSS / JS for the embedded control panel served at
/// <c>GET /ui</c>. Single self-contained string — no external CDN deps, no
/// <c>GET /ui</c>. Single self-contained string no external CDN deps, no
/// build step, no React. Phone-friendly remote that connects via WebSocket
/// to <c>/ws</c> and posts to the existing REST endpoints.
///
@ -10,7 +10,7 @@
/// - Live preview tiles per participant via GET /participants/{id}/thumbnail.jpg
/// (engine encodes the latest ProcessedFrame as a 192-wide JPEG; refreshed
/// ~1Hz alongside the WebSocket state push).
/// - Topology toggle card — shows whether raw Teams NDI sources are
/// - Topology toggle card shows whether raw Teams NDI sources are
/// hidden from the LAN, with Apply / Restore buttons that hit the
/// /topology/apply + /topology/restore REST endpoints. Operator still
/// has to restart Teams afterward, surfaced in a banner on apply.
@ -22,7 +22,7 @@ internal static class ControlPanelHtml
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<title>Dragon-ISO Control</title>
<title>TeamsISO Control</title>
<style>
:root {
--bg: #0a0a0a;
@ -142,11 +142,11 @@ internal static class ControlPanelHtml
</style>
</head>
<body>
<h1>Dragon-ISO control surface</h1>
<h1>TeamsISO control surface</h1>
<div class='card'>
<div class='status'>
<span><span id='conn' class='dot gray'></span> <span id='conn-text'>connectingâ¦</span></span>
<span><span id='conn' class='dot gray'></span> <span id='conn-text'>connecting</span></span>
<span id='count' class='sub'></span>
</div>
</div>
@ -156,7 +156,7 @@ internal static class ControlPanelHtml
<span id='topo-dot' class='dot gray'></span>
<div>
<div class='label-caps'>Network topology</div>
<strong id='topo-label'>â</strong>
<strong id='topo-label'></strong>
<div id='topo-detail' class='sub' style='margin-top: 2px;'></div>
</div>
</div>
@ -173,7 +173,7 @@ internal static class ControlPanelHtml
<button onclick='post(""/teams/camera"")'>Camera</button>
<button onclick='post(""/teams/share"")'>Share</button>
<button onclick='post(""/teams/leave"")'>Leave</button>
<button onclick='dropNote()'>Noteâ¦</button>
<button onclick='dropNote()'>Note</button>
<button onclick='post(""/presets/refresh-discovery"")'>Refresh</button>
<button class='danger' onclick='post(""/presets/stop-all"")'>Stop all ISOs</button>
</div>
@ -226,20 +226,20 @@ function paintTopology(t) {
topoLabel.textContent = 'Teams hidden from LAN';
} else if (t.mode === 'public') {
topoDot.className = 'dot amber';
topoLabel.textContent = 'Public â raw Teams visible';
topoLabel.textContent = 'Public raw Teams visible';
} else {
topoDot.className = 'dot gray';
topoLabel.textContent = 'Unknown';
}
const sends = (t.senders || []).join(', ') || 'â';
const recvs = (t.receivers || []).join(', ') || 'â';
topoDetail.textContent = 'send: ' + sends + ' · recv: ' + recvs;
const sends = (t.senders || []).join(', ') || '—';
const recvs = (t.receivers || []).join(', ') || '—';
topoDetail.textContent = 'send: ' + sends + ' · recv: ' + recvs;
}
async function applyTopology() {
const r = await post('/topology/apply');
if (r && r.ok) {
topoBanner.textContent = '✠' + (r.note || 'Applied. Restart Microsoft Teams for it to take effect.');
topoBanner.textContent = ' ' + (r.note || 'Applied. Restart Microsoft Teams for it to take effect.');
topoBanner.classList.add('show');
setTimeout(() => topoBanner.classList.remove('show'), 8000);
}
@ -250,7 +250,7 @@ async function restoreTopology() {
if (!confirm('Restore default NDI groups? Teams will broadcast on public again after you restart it.')) return;
const r = await post('/topology/restore');
if (r && r.ok) {
topoBanner.textContent = '✠Defaults restored. Restart Microsoft Teams for it to take effect.';
topoBanner.textContent = ' Defaults restored. Restart Microsoft Teams for it to take effect.';
topoBanner.classList.add('show');
setTimeout(() => topoBanner.classList.remove('show'), 8000);
}
@ -282,15 +282,15 @@ const openPanels = new Set();
function shortFps(v) {
for (const [k, label] of FRAMERATE_OPTS) if (k === v) return label;
return v || 'â';
return v || '—';
}
function shortRes(v) {
for (const [k, label] of RESOLUTION_OPTS) if (k === v) return label;
return v || 'â';
return v || '—';
}
function shortAudio(v) {
for (const [k, label] of AUDIO_OPTS) if (k === v) return label;
return v || 'â';
return v || '—';
}
function buildSelect(opts, current) {
@ -323,21 +323,21 @@ function render(participants) {
const row = document.createElement('div');
row.className = 'participant-row';
const stateColor = p.isEnabled ? 'cyan' : (p.isOnline ? 'gray' : 'coral');
// Live preview tile — cache-bust with a 1s-bucket query param so the
// Live preview tile cache-bust with a 1s-bucket query param so the
// browser refreshes the image without flickering on every WS message.
const bust = Math.floor(Date.now() / 1000);
const previewUrl = '/participants/' + p.id + '/thumbnail.jpg?t=' + bust;
row.innerHTML =
""<span class='dot "" + stateColor + ""'></span>"" +
""<img class='preview' alt='' onerror=\""this.style.display='none'; this.nextElementSibling.style.display='flex';\"" >"" +
""<div class='preview empty' style='display:none;'>â</div>"" +
""<div class='preview empty' style='display:none;'></div>"" +
""<div class='grow'>"" +
""<div class='name'></div>"" +
""<div class='sub'></div>"" +
""</div>"" +
""<div class='row-right'>"" +
""<span class='cfg-caption'></span>"" +
""<button class='gear-btn' title='Output settings'>âš</button>"" +
""<button class='gear-btn' title='Output settings'></button>"" +
""<button class='enable-btn'></button>"" +
""</div>"";
const img = row.querySelector('img.preview');
@ -346,7 +346,7 @@ function render(participants) {
const subEl = row.querySelector('.sub');
subEl.textContent =
(p.stateLabel || (p.isOnline ? 'online' : 'offline')) +
(p.customName ? ' · ' + p.customName : '');
(p.customName ? ' · ' + p.customName : '');
if (isOverride) {
const pill = document.createElement('span');
pill.className = 'ovr-pill';
@ -354,10 +354,10 @@ function render(participants) {
subEl.appendChild(pill);
}
row.querySelector('.cfg-caption').textContent =
shortFps(eff.framerate) + ' · ' + shortRes(eff.resolution) + ' · ' + shortAudio(eff.audio);
shortFps(eff.framerate) + ' · ' + shortRes(eff.resolution) + ' · ' + shortAudio(eff.audio);
const enableBtn = row.querySelector('.enable-btn');
enableBtn.className = 'enable-btn ' + (p.isEnabled ? 'live' : '');
enableBtn.textContent = p.isEnabled ? '⏠LIVE' : 'Enable';
enableBtn.textContent = p.isEnabled ? ' LIVE' : 'Enable';
enableBtn.onclick = () => post('/participants/iso', {
displayName: p.displayName,
enabled: !p.isEnabled,
@ -419,7 +419,7 @@ function render(participants) {
}
function connect() {
setConn('gray', 'connectingâ¦');
setConn('gray', 'connecting');
const ws = new WebSocket(
(location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
ws.onopen = () => { setConn('green', 'live'); fetchTopology(); };
@ -430,7 +430,7 @@ function connect() {
} catch (e) { console.warn(e); }
};
ws.onclose = () => {
setConn('coral', 'disconnected â retry in 3s');
setConn('coral', 'disconnected retry in 3s');
setTimeout(connect, 3000);
};
ws.onerror = () => setConn('coral', 'error');
@ -438,7 +438,7 @@ function connect() {
connect();
// Re-poll topology every 30s in case the operator changes the machine NDI
// config externally (NDI Access Manager, manual edit). Cheap — one HTTP GET.
// config externally (NDI Access Manager, manual edit). Cheap one HTTP GET.
setInterval(fetchTopology, 30000);
</script>
</body>

File diff suppressed because it is too large Load diff

View file

@ -1,30 +1,30 @@
using System.IO;
using System.IO;
using System.IO.Compression;
using System.Reflection;
using System.Text;
namespace DragonISO.App.Services;
namespace TeamsISO.App.Services;
/// <summary>
/// Gathers logs + config + presets + version metadata into a single .zip the
/// operator can attach to a bug report. Surfaced via the "Export diagnostics"
/// button in About.
///
/// We deliberately do NOT include screenshots or any process/memory dumps —
/// We deliberately do NOT include screenshots or any process/memory dumps
/// that's outside the scope of a v1 support bundle and would raise privacy
/// flags. The bundle has only files the user already wrote with their Dragon-ISO
/// flags. The bundle has only files the user already wrote with their TeamsISO
/// usage; nothing here is hidden state.
/// </summary>
public static class DiagnosticsBundle
{
/// <summary>
/// Build the bundle and return the path it was written to.
/// Throws on disk failure — the caller toasts/dialogs.
/// Throws on disk failure the caller toasts/dialogs.
/// </summary>
public static string Export()
{
var ts = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
var fileName = $"Dragon-ISO-diagnostics-{ts}.zip";
var fileName = $"teamsiso-diagnostics-{ts}.zip";
var outDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var downloads = Path.Combine(outDir, "Downloads");
if (!Directory.Exists(downloads)) downloads = outDir; // fall back to ~/
@ -51,9 +51,9 @@ public static class DiagnosticsBundle
?? asm.GetName().Version?.ToString()
?? "unknown";
var sb = new StringBuilder();
sb.AppendLine("Dragon-ISO diagnostic bundle");
sb.AppendLine("TeamsISO diagnostic bundle");
sb.AppendLine($"Generated: {DateTimeOffset.Now:o}");
sb.AppendLine($"Dragon-ISO version: {version}");
sb.AppendLine($"TeamsISO version: {version}");
sb.AppendLine($".NET runtime: {Environment.Version}");
sb.AppendLine($"OS: {Environment.OSVersion}");
sb.AppendLine($"Machine: {Environment.MachineName}");
@ -115,17 +115,17 @@ public static class DiagnosticsBundle
private static string LogsDirectory =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO", "Logs");
"TeamsISO", "Logs");
private static string LocalAppDataPath(string fileName) =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO", fileName);
"TeamsISO", fileName);
private static string AppDataPath(string fileName) =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Dragon-ISO", fileName);
"TeamsISO", fileName);
private static string NdiConfigPath() =>
Path.Combine(

View file

@ -1,19 +1,19 @@
using System.IO;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace DragonISO.App.Services;
namespace TeamsISO.App.Services;
/// <summary>
/// Reads and writes NDI Access Manager's per-user config at
/// <c>%APPDATA%\NDI\ndi-config.v1.json</c>. This file controls global NDI behavior for
/// every NDI application on the machine — sender groups, receiver groups, RUDP/TCP
/// every NDI application on the machine sender groups, receiver groups, RUDP/TCP
/// transport toggles, allowed adapters, etc. NDI applications read it on startup, so
/// changes here only take effect after restarting the affected app (Teams, OBS, etc.).
///
/// We use it to implement the "transcoder topology" requested by the user: pin Teams'
/// raw at-source-resolution NDI broadcasts to a private group (<c>Dragon-ISO-input</c>) so
/// they don't pollute the production network, while Dragon-ISO's own clean normalized ISO
/// raw at-source-resolution NDI broadcasts to a private group (<c>teamsiso-input</c>) so
/// they don't pollute the production network, while TeamsISO's own clean normalized ISO
/// outputs continue to broadcast on the standard <c>Public</c> group that downstream
/// switchers and recorders default to.
///
@ -36,7 +36,7 @@ public static class NdiAccessManagerConfig
/// Default name of the private group used for the transcoder topology.
/// Matches the convention referenced in the NDI Network settings UI.
/// </summary>
public const string TranscoderInputGroup = "Dragon-ISO-input";
public const string TranscoderInputGroup = "teamsiso-input";
/// <summary>
/// Result of an apply attempt. <see cref="Success"/> indicates the file was
@ -54,12 +54,12 @@ public static class NdiAccessManagerConfig
/// Configures the machine-wide NDI groups so:
/// <list type="bullet">
/// <item>All local senders (Teams, anything else) broadcast on
/// <paramref name="senderGroup"/> only — i.e. the private input group.</item>
/// <paramref name="senderGroup"/> only i.e. the private input group.</item>
/// <item>All local receivers see both <paramref name="senderGroup"/> and
/// <c>public</c> so Dragon-ISO can discover Teams' sources AND any
/// <c>public</c> so TeamsISO can discover Teams' sources AND any
/// standard public sources from elsewhere on the network.</item>
/// </list>
/// Dragon-ISO's own per-pipeline <c>OutputGroups</c> still overrides the per-machine
/// TeamsISO's own per-pipeline <c>OutputGroups</c> still overrides the per-machine
/// default at the sender level, so its normalized ISO outputs go on Public.
/// </summary>
/// <param name="senderGroup">Private group name for Teams' raw broadcasts.</param>

Some files were not shown because too many files have changed in this diff Show more