Compare commits

..

No commits in common. "main" and "phase-a-complete" have entirely different histories.

221 changed files with 3908 additions and 24392 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: |
@ -59,14 +59,14 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: test-results
path: '**/test-results.trx'
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage-report/

View file

@ -1,216 +0,0 @@
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
# requires the WiX SDK which is supported on Windows; the engine + console
# can in principle be built on Linux, but for simplicity we do everything in
# one job here so the publish output paths line up for the installer.
on:
push:
tags:
- 'v*.*.*'
jobs:
build-msi:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # tags + full history needed to derive the version
- name: Derive version from tag
id: ver
shell: pwsh
run: |
# GITHUB_REF is refs/tags/vX.Y.Z; strip the prefix.
$tag = "${env:GITHUB_REF}".Replace('refs/tags/', '')
$version = $tag.TrimStart('v')
"tag=$tag" >> $env:GITHUB_OUTPUT
"version=$version" >> $env:GITHUB_OUTPUT
Write-Host "Building version $version (tag $tag)"
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
# Probe for the signing cert up front and expose a step output downstream
# steps can use in their `if:` guards. We can't reference `secrets.*`
# directly from `if:` (Forgejo/GitHub policy), so we set a dummy env
# variable from the secret and check whether it's non-empty here.
- name: Detect signing configuration
id: signcfg
shell: pwsh
env:
PFX_PROBE: ${{ secrets.SIGN_CERT_PFX_BASE64 }}
run: |
if ($env:PFX_PROBE) {
"enabled=true" >> $env:GITHUB_OUTPUT
Write-Host "Code-signing: ENABLED (cert secret detected)."
} else {
"enabled=false" >> $env:GITHUB_OUTPUT
Write-Host "Code-signing: DISABLED (SIGN_CERT_PFX_BASE64 not set). Build will produce unsigned binaries."
}
- name: Restore (Windows solution filter)
run: dotnet restore Dragon-ISO.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 }}
- name: Run unit tests (excluding requires=ndi)
run: >
dotnet test Dragon-ISO.Windows.slnf
--configuration Release
--no-build
--filter "Category!=ndi&requires!=ndi"
- name: Publish Dragon-ISO.App (framework-dependent, win-x64)
run: >
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj
--configuration Release
--runtime win-x64
--self-contained false
--output publish/Dragon-ISO
/p:Version=${{ steps.ver.outputs.version }}
- name: Publish Dragon-ISO.Console (framework-dependent, win-x64)
run: >
dotnet publish src/Dragon-ISO.Console/Dragon-ISO.Console.csproj
--configuration Release
--runtime win-x64
--self-contained false
--output publish/Dragon-ISO-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
# working unchanged.
#
# To enable signing, set both Forgejo Actions secrets:
# 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
# Optionally:
# SIGN_TIMESTAMP_URL — RFC 3161 timestamp server (default: digicert)
- name: Sign Dragon-ISO.exe (optional, skipped if no cert)
if: ${{ steps.signcfg.outputs.enabled == 'true' }}
env:
SIGN_CERT_PFX_BASE64: ${{ secrets.SIGN_CERT_PFX_BASE64 }}
SIGN_CERT_PASSWORD: ${{ secrets.SIGN_CERT_PASSWORD }}
SIGN_TIMESTAMP_URL: ${{ secrets.SIGN_TIMESTAMP_URL }}
shell: pwsh
run: |
$tsUrl = if ($env:SIGN_TIMESTAMP_URL) { $env:SIGN_TIMESTAMP_URL } else { 'http://timestamp.digicert.com' }
$pfxPath = Join-Path $env:RUNNER_TEMP 'codesign.pfx'
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:SIGN_CERT_PFX_BASE64))
$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 `
/fd SHA256 `
/td SHA256 `
/tr $tsUrl `
'publish/Dragon-ISO/DragonISO.exe'
if ($LASTEXITCODE -ne 0) { throw "signtool failed on Dragon-ISO.exe (exit $LASTEXITCODE)" }
Remove-Item $pfxPath -Force
- name: Build MSI installer
run: >
dotnet build installer/Dragon-ISO.Installer.wixproj
--configuration Release
/p:Version=${{ steps.ver.outputs.version }}
- name: Locate MSI
id: msi
shell: pwsh
run: |
$msi = Get-ChildItem -Path installer/bin -Recurse -Filter '*.msi' | Select-Object -First 1
if (-not $msi) { throw "No MSI produced under installer/bin." }
"path=$($msi.FullName)" >> $env:GITHUB_OUTPUT
"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
# 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.
- name: Sign MSI (optional, skipped if no cert)
if: ${{ steps.signcfg.outputs.enabled == 'true' }}
env:
SIGN_CERT_PFX_BASE64: ${{ secrets.SIGN_CERT_PFX_BASE64 }}
SIGN_CERT_PASSWORD: ${{ secrets.SIGN_CERT_PASSWORD }}
SIGN_TIMESTAMP_URL: ${{ secrets.SIGN_TIMESTAMP_URL }}
MSI_PATH: ${{ steps.msi.outputs.path }}
shell: pwsh
run: |
$tsUrl = if ($env:SIGN_TIMESTAMP_URL) { $env:SIGN_TIMESTAMP_URL } else { 'http://timestamp.digicert.com' }
$pfxPath = Join-Path $env:RUNNER_TEMP 'codesign.pfx'
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:SIGN_CERT_PFX_BASE64))
$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 `
/fd SHA256 `
/td SHA256 `
/tr $tsUrl `
$env:MSI_PATH
if ($LASTEXITCODE -ne 0) { throw "signtool failed on MSI (exit $LASTEXITCODE)" }
Remove-Item $pfxPath -Force
- name: Upload MSI as workflow artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.msi.outputs.name }}
path: ${{ steps.msi.outputs.path }}
# Forgejo doesn't ship a stable upload-release-asset action, so we use
# the REST API directly. This: (1) finds the release that the tag push
# auto-created, (2) uploads the MSI as an asset on it. Requires that
# the repo's "Create a release on tag push" setting is on, OR that the
# release was created beforehand. If no release exists, we create one.
- name: Attach MSI to release
shell: pwsh
env:
FORGE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FORGE_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ steps.ver.outputs.tag }}
MSI_PATH: ${{ steps.msi.outputs.path }}
MSI_NAME: ${{ steps.msi.outputs.name }}
run: |
$headers = @{ Authorization = "token $env:FORGE_TOKEN" }
# Find the release for this tag.
try {
$release = Invoke-RestMethod -Method Get `
-Uri "$env:FORGE_API/releases/tags/$env:TAG" -Headers $headers
} catch {
Write-Host "No release found for $env:TAG; creating one."
$body = @{
tag_name = $env:TAG
name = "Dragon-ISO $env:TAG"
body = "Automated build from tag $env:TAG."
draft = $false
prerelease = $env:TAG -match '-(alpha|beta|rc)'
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post `
-Uri "$env:FORGE_API/releases" -Headers $headers `
-ContentType 'application/json' -Body $body
}
# Upload the MSI as an asset.
$uploadUri = "$env:FORGE_API/releases/$($release.id)/assets?name=$env:MSI_NAME"
curl.exe -fSL `
-H "Authorization: token $env:FORGE_TOKEN" `
-H "Content-Type: application/octet-stream" `
--upload-file "$env:MSI_PATH" `
"$uploadUri"
Write-Host "Asset $env:MSI_NAME attached to release $env:TAG."

9
.gitignore vendored
View file

@ -28,12 +28,3 @@ publish/
# OS
.DS_Store
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 +0,0 @@
# Changelog
All notable changes to Dragon-ISO 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
First general release. Windows-only, .NET 8 WPF, NDI 6.
### Engine
- **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.
### UI — "Studio Terminal"
- **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.
### Output name template
- 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}`.
### Operator presets
- Save current per-participant ISO assignments + custom output names to
`%LOCALAPPDATA%\Dragon-ISO\presets.json`. Optional auto-apply on next
launch.
### Teams orchestration
- 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.
### External control surface
- 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.
### 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

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"
]
}
}

126
README.md
View file

@ -1,128 +1,20 @@
# 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.
TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a live-production environment. It receives each participant's NDI stream, normalizes framerate and resolution per a configured target, and re-emits clean, individually-addressable NDI sources for ingestion into 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.
## Status
---
Pre-1.0. See `docs/superpowers/specs/` for the active spec and `docs/superpowers/plans/` for in-flight implementation plans.
## What it does
## Build
- **Discovers participants** as Teams broadcasts each one over NDI. Cleans
the Teams-prefixed source name down to a readable display name.
- **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.
- **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.
- **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.
Requires .NET 8 SDK.
## Install
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`.
**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)
## Configure
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:
- **Output** — framerate, resolution, aspect mode, audio routing
- **Network** — NDI discovery and output group names
- **App** — recording paths, startup behavior, theme
Per-participant overrides — click the **CFG** column gear on any row to
override framerate / resolution / aspect / audio for just that participant.
## Keyboard shortcuts
| Key | Action |
| --- | --- |
| `F1` | Open help / cheat sheet |
| `Ctrl + K` (or `Ctrl + P`) | Open the command palette |
| `Ctrl + T` | Toggle theme (dark ↔ light) |
| `Ctrl + M` | Drop a timestamped marker into every active recording |
| `Ctrl + Shift + S` | Stop every running ISO (emergency) |
| `Ctrl + R` | Refresh NDI discovery (rebuild finder) |
| `1``9` / `NumPad 1``9` | Toggle the Nth visible participant's ISO |
## File locations
| Path | Contents |
| --- | --- |
| `%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\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
```
dotnet build
dotnet test
## License
Proprietary, © Wild Dragon LLC 2026. All rights reserved.
Proprietary, © Wild Dragon LLC 2026.

11
TeamsISO.Linux.slnf Normal file
View file

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

View file

@ -1,72 +1,58 @@

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
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
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}
EndGlobalSection
EndGlobal

View file

@ -1,34 +0,0 @@
$ErrorActionPreference = 'Stop'
if (-not (Test-Path 'Dragon-ISO.Windows.slnf')) {
throw "Run from the Dragon-ISO 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
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
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' />"
}
Write-Host ""
Write-Host "=== Tests (excluding requires=ndi) ===" -ForegroundColor Cyan
dotnet test Dragon-ISO.Windows.slnf `
--configuration Release `
--no-build `
--nologo `
--filter "Category!=ndi&requires!=ndi"
if ($LASTEXITCODE -ne 0) { throw "Tests failed." }
Write-Host ""
Write-Host "Build + tests green." -ForegroundColor Green

View file

@ -1,298 +0,0 @@
# Dragon-ISO Control Surface — REST API
Dragon-ISO 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.
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
from the LAN.
5. To allow other machines on the same network to drive Dragon-ISO (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.
When enabled, the toast confirms `Control surface listening on
http://127.0.0.1:9755/` (or the all-interfaces equivalent in LAN mode).
### One-time setup for LAN-reachable mode
Windows requires elevated permission to bind a non-loopback HTTP listener.
If you turn on LAN-reachable mode and don't see a connection from another
machine, run this **once** in an Administrator PowerShell (replace `9755`
if you've changed the port):
```powershell
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.
## Authentication
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.**
## Response shape
All responses are `application/json` with `Access-Control-Allow-Origin: *`
so a browser-based control panel served from another origin can call the
endpoints. Most successful responses include `"ok": true` plus operation-
specific fields. Errors return HTTP 4xx/5xx with `{"error": "..."}`.
## Endpoints
### `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
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.
### `GET /`
Returns server info and an endpoint summary. Useful for "is the surface
alive?" probes.
```json
{
"product": "Dragon-ISO",
"version": "1.0.0.0",
"endpoints": ["GET /participants", "POST /participants/{id}/iso", ...]
}
```
### `GET /participants`
Snapshot of the current participant list as the UI sees it.
```json
{
"participants": [
{
"id": "1c3e2a8b-...-...",
"displayName": "Jane",
"isOnline": true,
"isEnabled": false,
"customName": null,
"stateLabel": "—"
}
]
}
```
### `POST /participants/{id}/iso`
Enable or disable an ISO by participant Id. Body or query string:
```json
{ "enabled": true, "customName": "Host" }
```
`enabled` is optional — omitting it toggles the current state. `customName`
is optional and overrides the auto-generated NDI output name.
```sh
curl -X POST 'http://127.0.0.1:9755/participants/1c3e2a8b-.../iso?enabled=true&customName=Host'
```
### `POST /participants/iso`
Same as above but resolves by display name instead of Id. The Id varies
across meetings; the display name is the operator-stable identifier.
```json
{ "displayName": "Jane", "enabled": true }
```
### `POST /presets/{name}/apply`
Apply a saved preset to the live participant list. Walks every participant
in the meeting, matches by display name, sets the custom output name, and
reconciles each enable/disable via the engine. Same code path as the
Presets dialog and the auto-apply-on-launch flow (`PresetApplier.ApplyAsync`).
```json
{
"ok": true,
"name": "Friday Show",
"matched": 4,
"changed": 2,
"skipped": 1
}
```
`matched` is how many participants in the preset were live in the meeting;
`changed` is how many actually flipped state; `skipped` is preset entries
with no live counterpart.
### `POST /presets/refresh-discovery`
Force NDI discovery to rebuild its finder. Useful after Apply Transcoder
Topology or when Teams restarts mid-show. Returns immediately; the rebuild
happens on the next poll tick.
```sh
curl -X POST http://127.0.0.1:9755/presets/refresh-discovery
```
### `POST /presets/stop-all`
Disable every running ISO. Equivalent to clicking "Stop all ISOs" in the
header. Returns the count that were running.
### `POST /teams/mute` / `/camera` / `/share` / `/leave` / `/raise-hand`
Drive the corresponding Microsoft Teams in-call control via UIAutomation.
Returns one of `Invoked` / `TeamsNotRunning` / `ControlNotFound` /
`InvokeFailed` in the `result` field.
```sh
curl -X POST http://127.0.0.1:9755/teams/mute
```
### `POST /recording`
Toggle per-output recording on or off. Body or query string:
```json
{ "enabled": true, "directory": "D:/recordings/show-2026-05-09" }
```
`directory` is optional when `enabled=false`. Already-running ISOs are not
retroactively recorded — the operator should disable + re-enable a
participant to start recording it.
### `POST /recording/marker`
Drop a timestamped marker into every active recording. Body or query string
optionally carries a `label`; if omitted, the label defaults to
`Marker @ HH:mm:ss`. Markers land in each recording's `manifest.json` under
the `markers[]` array as `{ "offsetMs": 12345.6, "label": "Guest answer" }`.
```sh
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
it renders nicely in any editor.
```sh
curl -X POST 'http://127.0.0.1:9755/notes?text=guest+segment+starts'
```
### `POST /recording/roll`
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
button mapped to this gives operators "next segment" without losing the
already-recorded footage.
```sh
curl -X POST http://127.0.0.1:9755/recording/roll
```
Response:
```json
{ "ok": true, "action": "roll-recording", "rolled": 4 }
```
## WebSocket — live state push
For controllers that want to light a button when an ISO goes LIVE without
polling, connect to:
```
ws://127.0.0.1:9755/ws
```
On connect, the server sends a participants snapshot. Whenever the snapshot
changes (participant joins/leaves, ISO toggled, custom name edited), a fresh
snapshot is pushed within 250ms. Format:
```json
{
"type": "participants",
"participants": [
{ "id": "...", "displayName": "Jane", "isOnline": true,
"isEnabled": true, "customName": "Host", "stateLabel": "LIVE" }
]
}
```
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
`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
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
```
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
addresses on the same UDP port.
## Bitfocus Companion recipe
Companion ships a generic HTTP module. Configure a button:
- **Action:** `HTTP: HTTP POST request`
- **URL:** `http://127.0.0.1:9755/teams/mute`
- **Body type:** None
Or for a participant-specific toggle:
- **URL:** `http://127.0.0.1:9755/participants/iso?displayName=Jane&enabled=true`
## Stream Deck XL recipe (without Companion)
Use the "Web Requests" plugin (or any equivalent). Set the action to a POST
on the appropriate endpoint above.
## Future work
- **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,95 +0,0 @@
# 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
(no extra dependencies, works without an encoder installed) but disk-heavy:
1080p60 = ~500 MB/s, 720p30 = ~88 MB/s.
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
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`.
## 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 *not* defined. The scaffold in
`src/Dragon-ISO.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
directly to `MFStartup`.
- `MediaFactory.MF_LOW_LATENCY` → relocated to a different attribute
constants class.
- `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
signature, no longer returns a locked-buffer wrapper.
- `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
via the emitted `convert.cmd` produces equivalent .mp4s; you just pay the
disk pressure during the show.
## Activating it (after the port)
1. **Update the scaffold** to match Vortice 3.6.2's API surface. A clean
reference implementation lives in the Vortice samples repo under
`samples/MediaFoundationSamples`.
2. **Define the `MF_AVAILABLE` build symbol** in `Dragon-ISO.Engine.csproj`:
```xml
<PropertyGroup>
<DefineConstants>$(DefineConstants);MF_AVAILABLE</DefineConstants>
</PropertyGroup>
```
3. **Swap the recorder factory** in `IsoController.EnableIsoAsync`:
```csharp
// Old:
recorder = new RawBgraRecorderSink(_loggerFactory.CreateLogger<RawBgraRecorderSink>());
// New:
recorder = new MediaFoundationRecorderSink(_loggerFactory.CreateLogger<MediaFoundationRecorderSink>());
```
Both classes implement `IRecorderSink` so the rest of the pipeline is
unchanged.
4. **Build and smoke-test.** Existing unit tests don't touch the recorder;
the integration tier covers it once you've enabled MF.
## What the MF recorder produces
For each enabled ISO with recording on:
- `<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
from `IIsoController.AddRecordingMarker`. Manually chapter the .mp4 with
`mp4chaps -c -i markers.txt output.mp4` (mp4chaps from the `mp4v2` tools).
## Trade-offs vs. RawBgraRecorderSink
| | Raw BGRA | Media Foundation H.264 |
| --------------------- | --------------- | ---------------------- |
| Dependencies | None | Vortice.MediaFoundation NuGet |
| Disk @ 1080p60 | ~500 MB/s | ~50 MB/s |
| Disk @ 720p30 | ~88 MB/s | ~9 MB/s |
| CPU | Negligible | Moderate (inline encode) |
| Output | `.bgra` + `convert.cmd` for FFmpeg post-pass | Finished `.mp4` |
| Markers in container | No (sidecar JSON) | Sidecar `.txt`, chapter via mp4chaps |
| 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
multi-stream realtime H.264 with low CPU.

View file

@ -1,79 +0,0 @@
# Releasing Dragon-ISO
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
MSI as a release asset.
## Prerequisites
- A **Windows runner** registered to this Forgejo instance. WiX MSI builds require
Windows; the existing CI runs on Linux for unit tests, but releases need a
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 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 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
real NDI runtime which a CI runner won't have).
3. Publish `Dragon-ISO.App` and `Dragon-ISO.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`.
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
tag contains `-alpha`, `-beta`, or `-rc`.
## 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
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:
| Secret | Required | Notes |
| --- | --- | --- |
| `SIGN_CERT_PFX_BASE64` | yes | Base64 of your code-signing PFX file. Generate with `certutil -encode in.pfx out.b64`, then strip the `-----BEGIN/END CERTIFICATE-----` lines. |
| `SIGN_CERT_PASSWORD` | yes | The PFX password. |
| `SIGN_TIMESTAMP_URL` | no | RFC 3161 timestamp server. Defaults to `http://timestamp.digicert.com`. |
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
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.
Both signing steps use SHA-256 for both the file hash and the timestamp digest,
which is what current Microsoft / SmartScreen guidance requires.
### Cert types
- **OV (Organization Validation, ~$200/yr).** SmartScreen reputation is built
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
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.
For v1.0 we recommend the Azure Trusted Signing route: replace the PFX block
in `release.yml` with `azure/trusted-signing-action` once an Azure subscription
is set up. The current PFX path is the simplest thing that works for now.

File diff suppressed because it is too large Load diff

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,8 @@
# Plan Backlog
Phase A is implemented (tag: `phase-a-complete`). After Phase A merges and CI is green:
1. **Phase B — NDI Interop & Pipeline** — add real P/Invoke shim in `TeamsISO.Engine.NdiInterop`, real `IFrameScaler` against libyuv, `NdiReceiver` and `NdiSender`, `IsoPipeline`, `IsoController`, runtime version probe. Console smoke runner. Integration test suite goes live (Windows + NDI runtime required).
2. **Phase C — UI & Packaging** — WPF MVVM app on top of the engine. Settings view, participant list, alert banner, system health indicators. WiX MSI installer, release pipeline on tag, About dialog.
Each phase gets its own `YYYY-MM-DD-teamsiso-phase-X-<topic>.md` plan written by `superpowers:writing-plans` once the previous phase is shipped.

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

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

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

@ -0,0 +1,15 @@
# TeamsISO Manual Test Playbook
This doc grows with each phase. Phase A is unit-test only — nothing to verify against live Teams yet. Phase B will fill in NDI runtime checks; Phase C will add the live-meeting end-to-end checklist.
## Pre-checks (run before each release branch)
- [ ] `dotnet build TeamsISO.sln` succeeds with zero warnings on Windows.
- [ ] `dotnet build TeamsISO.Linux.slnf` succeeds with zero warnings on Linux/macOS.
- [ ] `dotnet test TeamsISO.Linux.slnf --filter "Category!=ndi&requires!=ndi"` reports all unit tests passing.
- [ ] CI run on `main` is green.
- [ ] Code coverage on `TeamsISO.Engine` is ≥80%.
## Live-meeting checklist (Phase C)
(To be added.)

View file

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

View file

@ -1,220 +0,0 @@
<?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 -p:SelfContained=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. Do not 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\8.0.0"
Name="Version"
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>

View file

@ -1,208 +0,0 @@
<Window x:Class="DragonISO.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"
Width="460" Height="500"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
ResizeMode="NoResize"
Background="{DynamicResource Wd.Canvas}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="ClearType">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome
CaptionHeight="32"
ResizeBorderThickness="0"
CornerRadius="0"
GlassFrameThickness="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Caption -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="About DragonISO"
Style="{StaticResource Wd.Text.Caption}"
Margin="20,12,0,0"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.CaptionClose}"
Click="OnClose"
shell:WindowChrome.IsHitTestVisibleInChrome="True">
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"/>
</Button>
</Grid>
<!-- Body -->
<StackPanel Grid.Row="1"
Margin="32,16,32,16"
VerticalAlignment="Top">
<Image Source="/Assets/dragon-mark.png"
Width="80" Height="80"
HorizontalAlignment="Center"
Margin="0,0,0,16"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="DragonISO"
Style="{StaticResource Wd.Text.Title}"
FontSize="28"
HorizontalAlignment="Center"/>
<TextBlock Text="Per-participant NDI ISO Controller for Microsoft Teams"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
HorizontalAlignment="Center"
TextAlignment="Center"
Margin="0,4,0,0"
TextWrapping="Wrap"/>
<Border Style="{StaticResource Wd.Card}"
Margin="0,20,0,0"
Padding="16">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Version"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,0,16,4"/>
<TextBlock Grid.Row="0" Grid.Column="1"
x:Name="VersionText"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Primary}"
TextWrapping="Wrap"
Margin="0,0,0,4"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text=".NET"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,0,16,4"/>
<TextBlock Grid.Row="1" Grid.Column="1"
x:Name="RuntimeText"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Primary}"
Margin="0,0,0,4"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="OS"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,0,16,4"/>
<TextBlock Grid.Row="2" Grid.Column="1"
x:Name="OsText"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Primary}"
Margin="0,0,0,4"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="NDI runtime"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,0,16,0"/>
<TextBlock Grid.Row="3" Grid.Column="1"
x:Name="NdiText"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Primary}"
TextWrapping="Wrap"/>
</Grid>
</Border>
<!-- Quick-jump shortcuts to the data directories -->
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center"
Margin="0,12,0,0">
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Logs"
Click="OnOpenLogs"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Open %LOCALAPPDATA%\DragonISO\Logs in Explorer"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Notes"
Click="OnOpenNotes"
Padding="14,6"
ToolTip="Open %LOCALAPPDATA%\DragonISO\Notes in Explorer"/>
</StackPanel>
</StackPanel>
<!-- Footer -->
<Border Grid.Row="2"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,1,0,0"
Padding="20,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Style="{StaticResource Wd.Text.Mono}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
VerticalAlignment="Center">
<Hyperlink x:Name="WebsiteLink"
Foreground="{DynamicResource Wd.Accent.Cyan}"
TextDecorations="None"
Click="OnWebsiteClick">
wilddragon.net
</Hyperlink>
<Run Text=" · © Wild Dragon LLC"/>
</TextBlock>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Export diagnostics"
Click="OnExportDiagnostics"
MinWidth="150"
Margin="0,0,8,0"
ToolTip="Bundle logs + config + presets into a zip in your Downloads folder. Attach the zip to a bug report."/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Check for updates"
Click="OnCheckUpdate"
x:Name="UpdateButton"
MinWidth="140"
Margin="0,0,8,0"
ToolTip="Ask forge.wilddragon.net whether a newer release tag exists than the one you're running."/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Show welcome"
Click="OnShowOnboarding"
MinWidth="120"
Margin="0,0,8,0"
ToolTip="Re-open the first-launch welcome dialog with the setup checklist."/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Close"
Click="OnClose"
MinWidth="80"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Border>
</Window>

View file

@ -1,214 +0,0 @@
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;
namespace DragonISO.App;
/// <summary>
/// Modal "About" dialog. Surfaces enough info that a user filing a support ticket
/// can paste version + NDI runtime + OS in a single screenshot.
/// </summary>
public partial class AboutWindow : Window
{
public AboutWindow()
{
InitializeComponent();
PopulateText();
}
private void PopulateText()
{
var asm = typeof(App).Assembly;
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? asm.GetName().Version?.ToString()
?? "unknown";
VersionText.Text = info;
RuntimeText.Text = $".NET {Environment.Version}";
OsText.Text = Environment.OSVersion.ToString();
NdiText.Text = TryGetNdiVersion();
}
[SupportedOSPlatform("windows")]
private static string TryGetNdiVersion()
{
try
{
using var interop = new NdiInteropPInvoke(
Microsoft.Extensions.Logging.Abstractions.NullLogger<NdiInteropPInvoke>.Instance);
return interop.GetRuntimeVersion();
}
catch (Exception ex)
{
return $"not initialized ({ex.Message})";
}
}
private void OnClose(object sender, RoutedEventArgs e) => Close();
/// <summary>
/// Re-open the first-launch welcome dialog from About so users can revisit
/// the setup checklist without having to delete the suppression flag file
/// by hand. The "Don't show again" checkbox in the welcome dialog defaults
/// to checked so a re-shown welcome won't unset the suppression on close.
/// </summary>
private void OnShowOnboarding(object sender, RoutedEventArgs e)
{
var onboarding = new OnboardingWindow { Owner = this };
onboarding.ShowDialog();
}
/// <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.
/// </summary>
private static void OpenInExplorer(string path)
{
try
{
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
Process.Start(new ProcessStartInfo
{
FileName = path,
UseShellExecute = true,
});
}
catch
{
// No-op: shell launch failed (path inaccessible / Explorer crashed)
}
}
private void OnOpenLogs(object sender, RoutedEventArgs e) =>
OpenInExplorer(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO", "Logs"));
// OnOpenRecordings removed — recording feature axed.
private void OnOpenNotes(object sender, RoutedEventArgs e) =>
OpenInExplorer(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO", "Notes"));
/// <summary>
/// Build the diagnostic bundle and tell the operator where it landed. The
/// 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)
{
try
{
var path = DiagnosticsBundle.Export();
var open = MessageBox.Show(
this,
$"Diagnostic bundle written to:\n\n{path}\n\nOpen the containing folder?",
"Dragon-ISO — Diagnostics exported",
MessageBoxButton.YesNo,
MessageBoxImage.Information);
if (open == MessageBoxResult.Yes)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"/select,\"{path}\"",
UseShellExecute = true,
});
}
catch { /* shell launch failure is best-effort */ }
}
}
catch (Exception ex)
{
MessageBox.Show(
this,
$"Diagnostic export failed.\n\n{ex.Message}",
"Dragon-ISO — Diagnostic export",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
/// <summary>
/// Click handler for "Check for updates". Disables the button while the
/// HTTP call is in flight (so a second click doesn't spawn parallel
/// requests), then surfaces the result via MessageBox. On
/// <see cref="UpdateChecker.UpdateStatus.UpdateAvailable"/> we offer
/// to open the releases page so the operator can grab the new MSI.
/// </summary>
private async void OnCheckUpdate(object sender, RoutedEventArgs e)
{
UpdateButton.IsEnabled = false;
try
{
var result = await UpdateChecker.CheckAsync();
switch (result.Status)
{
case UpdateChecker.UpdateStatus.UpdateAvailable:
var open = MessageBox.Show(
this,
$"{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",
MessageBoxButton.YesNo,
MessageBoxImage.Information);
if (open == MessageBoxResult.Yes)
UpdateChecker.OpenReleasesPage();
break;
case UpdateChecker.UpdateStatus.UpToDate:
MessageBox.Show(
this,
result.Message ?? "You're on the latest release.",
"Dragon-ISO — Up to date",
MessageBoxButton.OK,
MessageBoxImage.Information);
break;
case UpdateChecker.UpdateStatus.Error:
default:
MessageBox.Show(
this,
$"Couldn't check for updates.\n\n{result.Message}",
"Dragon-ISO — Update check failed",
MessageBoxButton.OK,
MessageBoxImage.Warning);
break;
}
}
finally
{
UpdateButton.IsEnabled = true;
}
}
/// <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
/// "tell me more" link, not a workflow.
/// </summary>
private void OnWebsiteClick(object sender, RoutedEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = "https://wilddragon.net",
UseShellExecute = true,
});
}
catch
{
// best-effort; if shell launch fails the click is a no-op
}
}
}

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,23 +0,0 @@
<Application x:Class="DragonISO.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!--
Theme color brushes — DARK by default. ThemeManager.Apply()
swaps this entry to Theme.Light.xaml at runtime; the brushes
are referenced via DynamicResource from WildDragonTheme.xaml
so the visual tree re-resolves without an app restart.
The DEFAULT here is dark so the app boots into a
deterministic state before ThemeManager runs on startup;
if the operator's preference is Light or system app-mode
is Light, the dictionary swap happens before MainWindow
is shown so there's no visible flash.
-->
<ResourceDictionary Source="/Themes/Theme.Dark.xaml"/>
<ResourceDictionary Source="/Themes/WildDragonTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

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);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

View file

@ -1,18 +0,0 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace DragonISO.App.Converters;
[ValueConversion(typeof(bool), typeof(Visibility))]
public sealed class BoolToVisibilityConverter : IValueConverter
{
public Visibility TrueValue { get; set; } = Visibility.Visible;
public Visibility FalseValue { get; set; } = Visibility.Collapsed;
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is true ? TrueValue : FalseValue;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}

View file

@ -1,32 +0,0 @@
using System.Collections;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace DragonISO.App.Converters;
/// <summary>
/// Maps a collection (or its count) to <see cref="Visibility"/>. Pass
/// <c>"empty"</c> as the converter parameter to invert the sense (Visible when
/// count == 0). Used to swap an empty-state placeholder in for the participants
/// DataGrid when no Teams sources are visible yet.
/// </summary>
public sealed class CountToVisibilityConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var count = value switch
{
int n => n,
ICollection c => c.Count,
null => 0,
_ => 1, // anything else: treat as non-empty
};
var invert = string.Equals(parameter as string, "empty", StringComparison.OrdinalIgnoreCase);
var visible = invert ? count == 0 : count > 0;
return visible ? Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}

View file

@ -1,52 +0,0 @@
using System.Globalization;
using System.Windows.Data;
using DragonISO.Engine.Domain;
namespace DragonISO.App.Converters;
/// <summary>
/// Renders engine enum values into operator-friendly strings.
/// </summary>
public sealed class EnumDescriptionConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value switch
{
TargetFramerate fr => fr switch
{
TargetFramerate.Fps23_976 => "23.976 fps",
TargetFramerate.Fps24 => "24 fps",
TargetFramerate.Fps25 => "25 fps",
TargetFramerate.Fps29_97 => "29.97 fps",
TargetFramerate.Fps30 => "30 fps",
TargetFramerate.Fps50 => "50 fps",
TargetFramerate.Fps59_94 => "59.94 fps",
TargetFramerate.Fps60 => "60 fps",
_ => fr.ToString()
},
TargetResolution r => r switch
{
TargetResolution.R720p => "720p",
TargetResolution.R1080p => "1080p",
TargetResolution.R4K => "4K",
_ => r.ToString()
},
AspectMode a => a switch
{
AspectMode.Pillarbox => "Pillarbox",
AspectMode.Letterbox => "Letterbox",
AspectMode.Stretch => "Stretch",
_ => a.ToString()
},
AudioMode m => m switch
{
AudioMode.Auto => "Auto (isolated → mixed fallback)",
AudioMode.Isolated => "Isolated",
AudioMode.Mixed => "Mixed",
_ => m.ToString()
},
_ => value
};
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}

View file

@ -1,30 +0,0 @@
using System.Globalization;
using System.Windows.Data;
namespace DragonISO.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.
/// </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 "·";
// 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 "·";
var parts = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
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])}";
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View file

@ -1,42 +0,0 @@
using System.Globalization;
using System.Windows.Data;
namespace DragonISO.App.Converters;
/// <summary>
/// Maps an audio level (0.0–1.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
/// exceeds its threshold; below that it dims to a faint silhouette so the
/// inactive segments still read as "the meter has 5 steps" rather than
/// blank space.
///
/// Designed for the v2 "Studio Terminal" participants table's audio meter.
/// Broadcast engineers expect instantaneous (non-averaged) bars; the
/// converter is stateless and trusts the caller to push raw levels.
/// </summary>
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>
public double InactiveOpacity { get; set; } = 0.18;
public object Convert(object value, System.Type targetType, object parameter, CultureInfo culture)
{
var level = value switch
{
double d => d,
float f => f,
_ => 0.0,
};
if (!double.TryParse(parameter?.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var threshold))
threshold = 1.0;
return level >= threshold ? ActiveOpacity : InactiveOpacity;
}
public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) =>
System.Windows.Data.Binding.DoNothing;
}

View file

@ -1,24 +0,0 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace DragonISO.App.Converters;
/// <summary>
/// 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.
/// </summary>
public sealed class NullToCollapsedConverter : IValueConverter
{
public object Convert(object value, System.Type targetType, object parameter, CultureInfo culture)
{
if (value is null) return Visibility.Collapsed;
if (value is string s && string.IsNullOrEmpty(s)) return Visibility.Collapsed;
return Visibility.Visible;
}
public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) =>
System.Windows.Data.Binding.DoNothing;
}

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,14 +0,0 @@
// Project-wide using aliases.
//
// Why: enabling <UseWindowsForms>true</UseWindowsForms> for the system-tray
// NotifyIcon brings in System.Windows.Forms.Application and
// System.Windows.Forms.MessageBox, both of which collide with their WPF
// counterparts (System.Windows.*). Every existing call site was written
// for the WPF type. Aliasing globally here is one declaration that keeps
// all the call sites compiling without per-file pollution.
//
// If you ever need the WinForms types, qualify them explicitly as
// `System.Windows.Forms.MessageBox` etc.
global using Application = System.Windows.Application;
global using MessageBox = System.Windows.MessageBox;

View file

@ -1,223 +0,0 @@
<Window x:Class="DragonISO.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"
Width="540" Height="560"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
ResizeMode="NoResize"
Background="{DynamicResource Wd.Canvas}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="ClearType">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome
CaptionHeight="32"
ResizeBorderThickness="0"
CornerRadius="0"
GlassFrameThickness="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
<Grid Margin="28,18,28,22">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Caption -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="HELP"
Style="{StaticResource Wd.Text.Caption}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.CaptionClose}"
Click="OnClose"
shell:WindowChrome.IsHitTestVisibleInChrome="True">
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"/>
</Button>
</Grid>
<!-- Header -->
<StackPanel Grid.Row="1" Margin="0,16,0,16">
<TextBlock Text="DragonISO cheat sheet"
Style="{StaticResource Wd.Text.Title}"/>
<TextBlock Text="Keyboard shortcuts, file locations, and quick links."
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,4,0,0"/>
</StackPanel>
<!-- Body -->
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
<StackPanel>
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="KEYBOARD SHORTCUTS"
Style="{StaticResource Wd.Text.Caption}"
Margin="0,0,0,12"/>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0"
Text="F1"
Style="{StaticResource Wd.Text.Mono}"
Foreground="{DynamicResource Wd.Accent.Cyan}"
Margin="0,0,16,6"/>
<TextBlock Grid.Row="0" Grid.Column="1"
Text="Open this help"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="1" Grid.Column="0"
Text="Ctrl + M"
Style="{StaticResource Wd.Text.Mono}"
Foreground="{DynamicResource Wd.Accent.Cyan}"
Margin="0,0,16,6"/>
<TextBlock Grid.Row="1" Grid.Column="1"
Text="Drop a timestamped marker into every active recording"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="2" Grid.Column="0"
Text="Ctrl + Shift + S"
Style="{StaticResource Wd.Text.Mono}"
Foreground="{DynamicResource Wd.Accent.Cyan}"
Margin="0,0,16,6"/>
<TextBlock Grid.Row="2" Grid.Column="1"
Text="Stop every running ISO (emergency)"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="3" Grid.Column="0"
Text="Ctrl + R"
Style="{StaticResource Wd.Text.Mono}"
Foreground="{DynamicResource Wd.Accent.Cyan}"
Margin="0,0,16,6"/>
<TextBlock Grid.Row="3" Grid.Column="1"
Text="Refresh NDI discovery (rebuild finder)"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Margin="0,0,0,6"/>
</Grid>
<TextBlock Margin="0,12,0,0"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
Text="Numpad 1-9 (or top-row 1-9) toggles the Nth visible participant's ISO. Sort + filter aware — the index matches what you see in the DataGrid, not the underlying storage order."/>
</StackPanel>
</Border>
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="FILE LOCATIONS"
Style="{StaticResource Wd.Text.Caption}"
Margin="0,0,0,12"/>
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
LineHeight="20">
<Run Text="Engine config" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
<LineBreak/>
<Run Text="%APPDATA%\DragonISO\config.json"/>
<LineBreak/>
<LineBreak/>
<Run Text="Operator presets" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
<LineBreak/>
<Run Text="%LOCALAPPDATA%\DragonISO\presets.json"/>
<LineBreak/>
<LineBreak/>
<Run Text="Diagnostic logs (rolling daily)" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
<LineBreak/>
<Run Text="%LOCALAPPDATA%\DragonISO\Logs\"/>
<LineBreak/>
<LineBreak/>
<Run Text="Recordings (default)" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
<LineBreak/>
<Run Text="%USERPROFILE%\Videos\DragonISO\&lt;date&gt;\"/>
<LineBreak/>
<LineBreak/>
<Run Text="NDI Access Manager config" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
<LineBreak/>
<Run Text="%APPDATA%\NDI\ndi-config.v1.json"/>
</TextBlock>
</StackPanel>
</Border>
<Border Style="{StaticResource Wd.Card}" Padding="16">
<StackPanel>
<TextBlock Text="EXTERNAL CONTROL"
Style="{StaticResource Wd.Text.Caption}"
Margin="0,0,0,12"/>
<TextBlock Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
Text="REST API (default :9755) and OSC bridge (default :9000) are off by default. Enable from Settings → DISPLAY. Stream Deck / Companion / TouchOSC can drive ISO toggles, presets, recording, mute/camera/leave, and marker drops."
Margin="0,0,0,8"/>
<TextBlock Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
Text="LAN-reachable mode (DISPLAY tab) makes the surfaces reachable from other machines on the same network — useful for headless host PC + thin client setups. Closed-network only; first-time use requires a one-shot 'netsh http add urlacl' (see docs/CONTROL-SURFACE.md)."
Margin="0,0,0,8"/>
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}">
<Hyperlink x:Name="DocsLink"
Foreground="{DynamicResource Wd.Accent.Cyan}"
TextDecorations="None"
Click="OnDocsClick">
forge.wilddragon.net/zgaetano/DragonISO
</Hyperlink>
</TextBlock>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- Footer -->
<Grid Grid.Row="3" Margin="0,18,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.Primary}"
Content="Got it"
Click="OnClose"
Padding="22,8"/>
</Grid>
</Grid>
</Border>
</Window>

View file

@ -1,32 +0,0 @@
using System.Diagnostics;
using System.Windows;
namespace DragonISO.App;
/// <summary>
/// F1 cheat-sheet dialog. Lists keyboard shortcuts, file locations, and a
/// link to the public documentation. Same chromeless style as the rest of
/// the host's modal dialogs.
/// </summary>
public partial class HelpWindow : Window
{
public HelpWindow() => InitializeComponent();
private void OnClose(object sender, RoutedEventArgs e) => Close();
private void OnDocsClick(object sender, RoutedEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = "https://forge.wilddragon.net/zgaetano/Dragon-ISO",
UseShellExecute = true,
});
}
catch
{
// Best-effort browser launch
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,287 +0,0 @@
using System;
using System.Linq;
using System.Windows;
using System.Windows.Input;
using DragonISO.App.Services;
using DragonISO.App.ViewModels;
namespace DragonISO.App;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
SourceInitialized += OnSourceInitialized;
Closing += OnClosing;
// Esc dismisses the settings drawer when it's open. Bound at the
// window level so any focused control inside the drawer also gets
// the affordance.
PreviewKeyDown += OnPreviewKeyDown;
}
public MainWindow(MainViewModel viewModel) : this()
{
DataContext = viewModel;
// Hand the view-model the palette-opener callback so Ctrl+K's
// KeyBinding (which lives on the VM as an ICommand) can reach
// back into the view layer to materialize the window.
viewModel.RegisterCommandPaletteOpener(() => OnCommandPaletteClick(this, new RoutedEventArgs()));
}
/// <summary>
/// Restore the window's previous placement after the HWND is created (so
/// SetWindowPos / WindowState transitions actually take effect). Falls
/// silently back to the XAML-default startup location if no snapshot exists.
/// </summary>
private void OnSourceInitialized(object? sender, EventArgs e)
{
WindowStateStore.TryApply(this);
}
/// <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 */ }
}
/// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary>
private void OnAboutClick(object sender, RoutedEventArgs e)
{
var about = new AboutWindow { Owner = this };
about.ShowDialog();
}
/// <summary>
/// Opens the operator-presets dialog. Hands it the current participants
/// snapshot (so Save captures live state) and the engine controller (so
/// Apply can reconcile enable/disable). Owner is set so the chromeless
/// dialog centers over the main window and inherits z-order.
/// </summary>
private void OnPresetsClick(object sender, RoutedEventArgs e)
{
if (DataContext is not MainViewModel vm) return;
var dialog = new PresetsDialog(vm.Controller, vm.Participants.ToList(), vm.Toast)
{
Owner = this,
};
dialog.ShowDialog();
}
/// <summary>
/// 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.
/// </summary>
private bool _teamsWindowsHidden;
/// <summary>
/// Phase E.2 toggle. Hides every visible top-level Teams window on first
/// click; shows them again on the next. Surfaces the result via the toast
/// so the operator gets feedback even though the affected windows aren't
/// visible anymore.
/// </summary>
private void OnToggleTeamsWindowClick(object sender, RoutedEventArgs e)
{
if (!TeamsLauncher.IsRunning())
{
MessageBox.Show(
Properties.Strings.HideShowTeams_NotRunning_Message,
Properties.Strings.HideShowTeams_Title,
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
}
var toast = (DataContext as MainViewModel)?.Toast;
if (_teamsWindowsHidden)
{
var shown = TeamsLauncher.ShowWindows();
_teamsWindowsHidden = false;
toast?.Show(shown > 0 ? $"Restored {shown} Teams window(s)" : "No Teams windows to restore");
}
else
{
var hidden = TeamsLauncher.HideWindows();
_teamsWindowsHidden = hidden > 0;
toast?.Show(hidden > 0 ? $"Hid {hidden} Teams window(s)" : "Teams has no visible windows yet");
}
}
/// <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.
/// (Stopping Teams is a right-click action; see OnLaunchTeamsRightClick.)
/// </summary>
private void OnLaunchTeamsClick(object sender, RoutedEventArgs e)
{
var toast = (DataContext as MainViewModel)?.Toast;
if (!TeamsLauncher.IsRunning())
{
if (!TeamsLauncher.TryLaunch(out var error))
{
MessageBox.Show(
string.Format(Properties.Strings.LaunchTeams_Failed_MessageFormat, error),
Properties.Strings.LaunchTeams_Title,
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
else
{
var autoHide = (DataContext as MainViewModel)?.Settings.AutoHideTeamsWindows ?? false;
toast?.Show(autoHide
? "Launching Microsoft Teams (will hide windows automatically)…"
: "Launching Microsoft Teams…");
if (autoHide)
{
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
_teamsWindowsHidden = true;
}
else
{
_teamsWindowsHidden = false;
}
}
return;
}
var shown = TeamsLauncher.ShowWindows();
_teamsWindowsHidden = false;
toast?.Show(shown > 0
? $"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.
/// </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,
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes) return;
var asked = TeamsLauncher.StopAll();
if (TeamsLauncher.IsRunning())
{
MessageBox.Show(
asked == 0
? Properties.Strings.StopTeams_NoneResponded
: string.Format(Properties.Strings.StopTeams_AskedFormat, asked),
Properties.Strings.StopTeams_Title,
MessageBoxButton.OK,
MessageBoxImage.Information);
}
e.Handled = true;
}
/// <summary>
/// Open the experimental Teams embed window. Operator enables the
/// preference first; this button materializes the host.
/// </summary>
private void OnOpenEmbedWindowClick(object sender, RoutedEventArgs e)
{
var w = new TeamsEmbedWindow { Owner = this };
w.Show();
}
/// <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
/// points open / close it.
/// </summary>
private void OnSettingsToggleClick(object sender, RoutedEventArgs e)
{
if (SettingsDrawerOverlay is null) return;
SettingsDrawerOverlay.Visibility = SettingsDrawerOverlay.Visibility == Visibility.Visible
? Visibility.Collapsed
: Visibility.Visible;
}
/// <summary>
/// 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)
{
if (SettingsDrawerOverlay is null) return;
SettingsDrawerOverlay.Visibility = Visibility.Collapsed;
}
/// <summary>
/// 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
/// fresh view-model each time so the filter starts empty.
/// </summary>
private void OnCommandPaletteClick(object sender, RoutedEventArgs e)
{
if (DataContext is not MainViewModel vm) return;
var paletteVm = new ViewModels.CommandPaletteViewModel(vm, Dispatcher);
var palette = new Views.CommandPaletteWindow(paletteVm) { Owner = this };
palette.ShowDialog();
}
/// <summary>
/// Open the per-participant ISO override editor. Bound to the gear button
/// in the participant row. The dialog reads the engine's current override
/// (if any) and lets the operator edit framerate / resolution / aspect /
/// audio for that specific pipeline; Apply / Clear / Cancel are handled by
/// the dialog's view-model, so this handler is just plumbing.
/// </summary>
private void OnIsoOverrideClick(object sender, RoutedEventArgs e)
{
if (DataContext is not MainViewModel vm) return;
if (sender is not FrameworkElement fe) return;
if (fe.DataContext is not ParticipantViewModel p) return;
var currentOverride = vm.Controller.GetIsoOverride(p.Id);
var dialogVm = new ViewModels.IsoOverrideDialogViewModel(
vm.Controller,
vm.Settings,
p.Id,
p.DisplayName,
currentOverride,
vm.Toast);
var dialog = new Views.IsoOverrideDialog(dialogVm) { Owner = this };
dialog.ShowDialog();
}
/// <summary>
/// Esc closes the drawer when it's open. We use PreviewKeyDown rather than
/// KeyDown so the drawer's nested inputs (TextBox, ComboBox) don't swallow
/// the key before this handler sees it.
/// </summary>
private void OnPreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key != Key.Escape) return;
if (SettingsDrawerOverlay?.Visibility == Visibility.Visible)
{
SettingsDrawerOverlay.Visibility = Visibility.Collapsed;
e.Handled = true;
}
}
}

View file

@ -1,129 +0,0 @@
<Window x:Class="DragonISO.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"
Width="540" Height="560"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
ResizeMode="CanResize"
Background="{DynamicResource Wd.Canvas}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="ClearType">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome
CaptionHeight="32"
ResizeBorderThickness="6"
CornerRadius="0"
GlassFrameThickness="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
<Grid Margin="24,16,24,20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Caption -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="SHOW NOTES"
Style="{StaticResource Wd.Text.Caption}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.CaptionClose}"
Click="OnClose"
shell:WindowChrome.IsHitTestVisibleInChrome="True">
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"/>
</Button>
</Grid>
<TextBlock Grid.Row="1"
x:Name="DateLine"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
FontSize="12"
Margin="0,12,0,12"/>
<!-- Notes view -->
<Border Grid.Row="2" Style="{StaticResource Wd.Card}" Padding="0">
<ScrollViewer x:Name="Scroller"
VerticalScrollBarVisibility="Auto"
Padding="14,12">
<TextBox x:Name="NotesText"
IsReadOnly="True"
AcceptsReturn="True"
Background="Transparent"
BorderThickness="0"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Primary}"
TextWrapping="Wrap"/>
</ScrollViewer>
</Border>
<!-- Inline note input — quick stamping without leaving the dialog -->
<Grid Grid.Row="3" Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
x:Name="NewNoteBox"
Padding="10,7"
FontSize="12"
VerticalAlignment="Center"
KeyDown="OnNewNoteKey"
ToolTip="Type a note and press Enter (or click 'Add'). Lands in today's file with a HH:mm:ss timestamp."/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.Primary}"
Content="Add"
Click="OnAddNote"
Margin="8,0,0,0"
Padding="20,8"/>
</Grid>
<!-- Footer -->
<Grid Grid.Row="4" Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Style="{StaticResource Wd.Button.Ghost}"
Content="Open in editor"
Click="OnOpenInEditor"
Padding="14,8"
ToolTip="Launch the notes file in the system default editor."/>
<Button Grid.Column="2"
Style="{StaticResource Wd.Button.Ghost}"
Content="Refresh"
Click="OnRefresh"
Margin="0,0,8,0"
Padding="14,8"/>
<Button Grid.Column="3"
Style="{StaticResource Wd.Button.Ghost}"
Content="Close"
Click="OnClose"
Padding="20,8"/>
</Grid>
</Grid>
</Border>
</Window>

View file

@ -1,137 +0,0 @@
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Threading;
using DragonISO.App.Services;
namespace DragonISO.App;
/// <summary>
/// Inline viewer for the daily show-notes file. Reads
/// <see cref="NotesService.TodayPath"/> on open and polls every 2s while
/// 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
/// (operator stamps, post-show review). If someone wants to edit, they
/// click "Open in editor" and use Notepad.
/// </summary>
public partial class NotesWindow : Window
{
private readonly DispatcherTimer _refreshTimer;
private long _lastFileSize = -1;
private DateTime _lastFileWrite = DateTime.MinValue;
public NotesWindow()
{
InitializeComponent();
_refreshTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(2),
};
_refreshTimer.Tick += (_, _) => RefreshIfChanged();
Loaded += (_, _) =>
{
DateLine.Text = $"{DateTimeOffset.Now:dddd, MMMM d, yyyy} · {NotesService.TodayPath}";
ReloadFromDisk();
_refreshTimer.Start();
};
Closed += (_, _) => _refreshTimer.Stop();
}
private void OnClose(object sender, RoutedEventArgs e) => Close();
private void OnRefresh(object sender, RoutedEventArgs e) => ReloadFromDisk();
/// <summary>
/// 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).
/// </summary>
private void RefreshIfChanged()
{
try
{
var path = NotesService.TodayPath;
if (!File.Exists(path)) return;
var info = new FileInfo(path);
if (info.Length != _lastFileSize || info.LastWriteTimeUtc != _lastFileWrite)
ReloadFromDisk();
}
catch
{
// Disk hiccups shouldn't stop the timer.
}
}
private void ReloadFromDisk()
{
try
{
var path = NotesService.TodayPath;
if (!File.Exists(path))
{
NotesText.Text = "No notes yet. Stamp one via the REST or OSC endpoint and refresh.";
return;
}
var info = new FileInfo(path);
_lastFileSize = info.Length;
_lastFileWrite = info.LastWriteTimeUtc;
NotesText.Text = File.ReadAllText(path);
// Scroll to bottom so the latest stamp is visible — operators are
// typically reading "what just happened" not "what happened first."
Dispatcher.BeginInvoke(new Action(() =>
{
Scroller.ScrollToEnd();
}), DispatcherPriority.Background);
}
catch (Exception ex)
{
NotesText.Text = "Couldn't read notes file: " + ex.Message;
}
}
/// <summary>
/// Append the input box's text to today's notes file via NotesService,
/// then clear the box and refresh the view. Bound to the "Add" button +
/// Enter key in the input. Empty/whitespace input is a no-op.
/// </summary>
private void OnAddNote(object sender, RoutedEventArgs e)
{
var text = NewNoteBox.Text?.Trim();
if (string.IsNullOrEmpty(text)) return;
if (NotesService.Append(text))
{
NewNoteBox.Clear();
ReloadFromDisk();
NewNoteBox.Focus();
}
}
/// <summary>Enter key in the input commits the note, same as the Add button.</summary>
private void OnNewNoteKey(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key == System.Windows.Input.Key.Enter)
{
OnAddNote(sender, e);
e.Handled = true;
}
}
private void OnOpenInEditor(object sender, RoutedEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = NotesService.TodayPath,
UseShellExecute = true,
});
}
catch
{
// Best-effort; if no .md handler is registered the OS shows its own dialog.
}
}
}

View file

@ -1,294 +0,0 @@
<Window x:Class="DragonISO.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"
Width="560" Height="600"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
ResizeMode="NoResize"
Background="{DynamicResource Wd.Canvas}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="ClearType">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome
CaptionHeight="32"
ResizeBorderThickness="0"
CornerRadius="0"
GlassFrameThickness="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
<Grid Margin="32,20,32,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Caption -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="WELCOME"
Style="{StaticResource Wd.Text.Caption}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.CaptionClose}"
Click="OnDismiss"
shell:WindowChrome.IsHitTestVisibleInChrome="True">
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"/>
</Button>
</Grid>
<!-- Hero -->
<StackPanel Grid.Row="1" Margin="0,16,0,20">
<Image Source="/Assets/dragon-mark.png"
Width="56" Height="56"
HorizontalAlignment="Left"
Margin="0,0,0,12"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="DragonISO 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."
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,6,0,0"/>
</StackPanel>
<!-- Body: numbered checklist -->
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- Step 1 — NDI runtime -->
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Border Width="22" Height="22"
CornerRadius="11"
Background="{DynamicResource Wd.Accent.CyanMuted}"
VerticalAlignment="Center"
Margin="0,0,12,0">
<TextBlock Text="1"
Foreground="{DynamicResource Wd.Accent.Cyan}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Install the NDI 6 runtime"
Style="{StaticResource Wd.Text.Heading}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock TextWrapping="Wrap"
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."/>
</StackPanel>
</Border>
<!-- Step 2 — Teams NDI permission -->
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Border Width="22" Height="22"
CornerRadius="11"
Background="{DynamicResource Wd.Accent.CyanMuted}"
VerticalAlignment="Center"
Margin="0,0,12,0">
<TextBlock Text="2"
Foreground="{DynamicResource Wd.Accent.Cyan}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Enable broadcast in Teams"
Style="{StaticResource Wd.Text.Heading}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock TextWrapping="Wrap"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
Text="In Microsoft Teams: Settings → Devices → 'Broadcast over NDI / SDI'. Your Teams admin may need to enable this at the tenant level (Teams admin center → Meetings → Meeting policies → 'Allow NDI broadcasting')."/>
</StackPanel>
</Border>
<!-- Step 3 — Transcoder topology -->
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Border Width="22" Height="22"
CornerRadius="11"
Background="{DynamicResource Wd.Accent.CyanMuted}"
VerticalAlignment="Center"
Margin="0,0,12,0">
<TextBlock Text="3"
Foreground="{DynamicResource Wd.Accent.Cyan}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Click 'Apply transcoder topology'"
Style="{StaticResource Wd.Text.Heading}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock TextWrapping="Wrap"
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."/>
</StackPanel>
</Border>
<!-- Step 4 — Save a preset -->
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Border Width="22" Height="22"
CornerRadius="11"
Background="{DynamicResource Wd.Accent.CyanMuted}"
VerticalAlignment="Center"
Margin="0,0,12,0">
<TextBlock Text="4"
Foreground="{DynamicResource Wd.Accent.Cyan}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Save a preset for recurring shows"
Style="{StaticResource Wd.Text.Heading}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock TextWrapping="Wrap"
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."/>
</StackPanel>
</Border>
<!-- Step 5 — Headless Teams ("I only see DragonISO") -->
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Border Width="22" Height="22"
CornerRadius="11"
Background="{DynamicResource Wd.Accent.CyanMuted}"
VerticalAlignment="Center"
Margin="0,0,12,0">
<TextBlock Text="5"
Foreground="{DynamicResource Wd.Accent.Cyan}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Run Teams headless (optional)"
Style="{StaticResource Wd.Text.Heading}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock TextWrapping="Wrap"
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."/>
</StackPanel>
</Border>
<!-- Step 6 — Where things live -->
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Border Width="22" Height="22"
CornerRadius="11"
Background="{DynamicResource Wd.Accent.CyanMuted}"
VerticalAlignment="Center"
Margin="0,0,12,0">
<TextBlock Text="6"
Foreground="{DynamicResource Wd.Accent.Cyan}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Drive from another machine (optional)"
Style="{StaticResource Wd.Text.Heading}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock TextWrapping="Wrap"
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."/>
</StackPanel>
</Border>
<!-- Step 7 — Where things live -->
<Border Style="{StaticResource Wd.Card}" Padding="16">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Border Width="22" Height="22"
CornerRadius="11"
Background="{DynamicResource Wd.Accent.CyanMuted}"
VerticalAlignment="Center"
Margin="0,0,12,0">
<TextBlock Text="7"
Foreground="{DynamicResource Wd.Accent.Cyan}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="If something breaks…"
Style="{StaticResource Wd.Text.Heading}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock TextWrapping="Wrap"
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."/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- Footer -->
<Grid Grid.Row="3" Margin="0,20,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<CheckBox Grid.Column="0"
x:Name="SuppressBox"
Content="Don't show this again"
IsChecked="True"
VerticalAlignment="Center"/>
<Button Grid.Column="2"
Style="{StaticResource Wd.Button.Primary}"
Content="Get started"
Click="OnDismiss"
Padding="22,8"/>
</Grid>
</Grid>
</Border>
</Window>

View file

@ -1,57 +0,0 @@
using System.IO;
using System.Windows;
namespace DragonISO.App;
/// <summary>
/// First-launch welcome dialog. Walks the user through the once-per-machine
/// setup that's not derivable from the UI alone (NDI runtime install, Teams
/// admin permission, transcoder topology) and points them at where logs and
/// 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
/// the dialog by deleting that file.
/// </summary>
public partial class OnboardingWindow : Window
{
private static string FlagPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO", "onboarding.flag");
public OnboardingWindow() => InitializeComponent();
/// <summary>
/// Returns true on first launch (and on launches where the user previously
/// unchecked "Don't show this again" so the marker file was never created).
/// </summary>
public static bool ShouldShow()
{
try { return !File.Exists(FlagPath); }
catch { return false; } // permission errors → assume already shown
}
private void OnDismiss(object sender, RoutedEventArgs e)
{
if (SuppressBox.IsChecked == true)
{
try
{
var dir = Path.GetDirectoryName(FlagPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(FlagPath,
"Onboarding dialog dismissed at " + DateTimeOffset.UtcNow.ToString("o") + ". " +
"Delete this file to see the welcome dialog again on next launch.");
}
catch
{
// Disk full / permission denied — show the dialog again next launch
// rather than fail noisily.
}
}
DialogResult = true;
Close();
}
}

View file

@ -1,209 +0,0 @@
<Window x:Class="DragonISO.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"
Width="460" Height="520"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
ResizeMode="NoResize"
Background="{DynamicResource Wd.Canvas}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="ClearType">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome
CaptionHeight="32"
ResizeBorderThickness="0"
CornerRadius="0"
GlassFrameThickness="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
<Grid Margin="24,16,24,20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Caption -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="OPERATOR PRESETS"
Style="{StaticResource Wd.Text.Caption}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.CaptionClose}"
Click="OnCancel"
shell:WindowChrome.IsHitTestVisibleInChrome="True">
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"/>
</Button>
</Grid>
<TextBlock Grid.Row="1"
Text="Save the current ISO assignments as a named preset, or load an existing preset to restore them."
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
FontSize="12"
TextWrapping="Wrap"
Margin="0,12,0,16"/>
<!-- Save row: name textbox + Save button -->
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="NameBox"
ToolTip="Name for the new preset (or pick an existing one to overwrite)"
VerticalContentAlignment="Center"
Margin="0,0,12,0"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.Primary}"
Content="Save"
Click="OnSave"
Padding="20,8"/>
</Grid>
<!-- Existing presets list -->
<Border Grid.Row="3"
Style="{StaticResource Wd.Card}"
Padding="0"
Margin="0,16,0,0">
<Grid>
<ListBox x:Name="PresetsList"
Background="Transparent"
BorderThickness="0"
SelectionMode="Single"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
SelectionChanged="OnSelectionChanged">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="12,8"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}"
CornerRadius="4"
Margin="4,2">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.SurfaceHover}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.SurfaceElevated}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding Name}"
Style="{StaticResource Wd.Text.Body}"
FontWeight="Medium"/>
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="10"
Foreground="{DynamicResource Wd.Text.Tertiary}">
<Run Text="{Binding SavedAtDisplay, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding AssignmentCount, Mode=OneWay}"/>
<Run Text=" assignments"/>
</TextBlock>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Empty-state inside the card -->
<TextBlock x:Name="EmptyState"
Text="No presets yet. Type a name above and click Save."
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
FontSize="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="Collapsed"/>
</Grid>
</Border>
<!-- Footer buttons -->
<Grid Grid.Row="4" Margin="0,16,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Style="{StaticResource Wd.Button.Ghost}"
Content="Delete"
Click="OnDelete"
IsEnabled="False"
x:Name="DeleteButton"
Padding="14,8"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.Ghost}"
Content="Duplicate"
Click="OnDuplicate"
IsEnabled="False"
x:Name="DuplicateButton"
Margin="8,0,0,0"
Padding="14,8"
ToolTip="Copy the selected preset to a new name. Useful when iterating on variants of a recurring show."/>
<Button Grid.Column="2"
Style="{StaticResource Wd.Button.Ghost}"
Content="Export…"
Click="OnExport"
Margin="8,0,0,0"
Padding="14,8"
ToolTip="Save every preset as a single .json bundle. Useful for moving a curated library between machines, or sharing with a colleague."/>
<Button Grid.Column="3"
Style="{StaticResource Wd.Button.Ghost}"
Content="Import…"
Click="OnImport"
Margin="8,0,0,0"
Padding="14,8"
ToolTip="Load presets from a .json bundle. Existing presets with the same name are skipped unless you confirm overwrite."/>
<Button Grid.Column="5"
Style="{StaticResource Wd.Button.Ghost}"
Content="Cancel"
Click="OnCancel"
Margin="0,0,8,0"
Padding="14,8"/>
<Button Grid.Column="6"
Style="{StaticResource Wd.Button.Primary}"
Content="Apply"
Click="OnApply"
IsEnabled="False"
x:Name="ApplyButton"
Padding="20,8"/>
</Grid>
</Grid>
</Border>
</Window>

View file

@ -1,426 +0,0 @@
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using DragonISO.App.Services;
using DragonISO.App.ViewModels;
using DragonISO.Engine.Controller;
namespace DragonISO.App;
/// <summary>
/// Modal dialog for saving and loading operator presets. Owned by
/// <see cref="MainWindow"/>; given a snapshot of the current
/// <see cref="ParticipantViewModel"/> list and the
/// <see cref="IIsoController"/> so it can re-apply assignments
/// (which requires calling EnableIsoAsync/DisableIsoAsync on the engine).
/// </summary>
public partial class PresetsDialog : Window
{
private readonly IIsoController _controller;
private readonly IReadOnlyList<ParticipantViewModel> _participants;
private readonly ToastViewModel? _toast;
/// <summary>
/// Display-side wrapper for an <see cref="OperatorPresetStore.Preset"/>.
/// Adds derived presentation-only properties so the ListBox template can
/// render without inline converters or value-conversion logic.
/// </summary>
public sealed class PresetRow
{
public OperatorPresetStore.Preset Preset { get; }
public string Name => Preset.Name;
public string SavedAtDisplay => Preset.SavedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm");
public int AssignmentCount => Preset.Assignments.Count(a => a.Enabled);
public PresetRow(OperatorPresetStore.Preset preset) => Preset = preset;
}
public ObservableCollection<PresetRow> Rows { get; } = new();
public PresetsDialog(
IIsoController controller,
IReadOnlyList<ParticipantViewModel> participants,
ToastViewModel? toast = null)
{
InitializeComponent();
_controller = controller;
_participants = participants;
_toast = toast;
PresetsList.ItemsSource = Rows;
ReloadPresets();
}
/// <summary>Refresh the ListBox from disk and reflect emptiness in the empty-state TextBlock.</summary>
private void ReloadPresets()
{
Rows.Clear();
foreach (var p in OperatorPresetStore.LoadAll().OrderByDescending(p => p.SavedAt))
Rows.Add(new PresetRow(p));
EmptyState.Visibility = Rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
UpdateButtonStates();
}
private void UpdateButtonStates()
{
var hasSelection = PresetsList.SelectedItem is PresetRow;
ApplyButton.IsEnabled = hasSelection;
DeleteButton.IsEnabled = hasSelection;
DuplicateButton.IsEnabled = hasSelection;
}
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (PresetsList.SelectedItem is PresetRow row)
{
// Mirror the selected name into the textbox so a re-save overwrites
// by default; operator can still type a new name to fork.
NameBox.Text = row.Name;
}
UpdateButtonStates();
}
private void OnSave(object sender, RoutedEventArgs e)
{
var name = NameBox.Text?.Trim();
if (string.IsNullOrWhiteSpace(name))
{
_toast?.Warn("Enter a name for the preset");
NameBox.Focus();
return;
}
var assignments = _participants
.Select(p => new OperatorPresetStore.Assignment(
DisplayName: p.DisplayName,
CustomOutputName: string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName,
Enabled: p.IsEnabled))
.ToList();
var existing = OperatorPresetStore.Find(name);
if (existing is not null)
{
var confirm = MessageBox.Show(
this,
$"A preset named \"{name}\" already exists. Overwrite it?",
"Dragon-ISO — Overwrite preset",
MessageBoxButton.YesNo,
MessageBoxImage.Question,
MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
}
try
{
OperatorPresetStore.Save(new OperatorPresetStore.Preset(
Name: name,
SavedAt: DateTimeOffset.Now,
Assignments: assignments));
_toast?.Show($"Saved preset \"{name}\"");
ReloadPresets();
}
catch (Exception ex)
{
MessageBox.Show(
this,
$"Could not save preset.\n\n{ex.Message}",
"Dragon-ISO — Save preset",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
/// <summary>
/// Apply the selected preset: walks the current participants list, matching
/// 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
/// current meeting are silently skipped (and reported in the toast).
/// </summary>
private async void OnApply(object sender, RoutedEventArgs e)
{
if (PresetsList.SelectedItem is not PresetRow row) return;
ApplyButton.IsEnabled = false;
try
{
// 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)";
_toast?.Show(summary);
DialogResult = true;
Close();
}
finally
{
ApplyButton.IsEnabled = true;
}
}
/// <summary>
/// Duplicate the selected preset under a new name. We auto-suggest
/// "&lt;original&gt; (copy)" but pop a tiny input dialog so the operator
/// can pick something meaningful. WPF doesn't ship an InputBox; we
/// use a quick custom prompt below.
/// </summary>
private void OnDuplicate(object sender, RoutedEventArgs e)
{
if (PresetsList.SelectedItem is not PresetRow row) return;
var defaultName = SuggestCopyName(row.Name);
var newName = PromptForName("Duplicate preset", "New name:", defaultName);
if (string.IsNullOrWhiteSpace(newName)) return;
try
{
var existing = OperatorPresetStore.Find(newName);
if (existing is not null)
{
var confirm = MessageBox.Show(
this,
$"A preset named \"{newName}\" already exists. Overwrite it?",
"Dragon-ISO — Duplicate preset",
MessageBoxButton.YesNo,
MessageBoxImage.Question,
MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
}
// 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,
SavedAt: DateTimeOffset.Now,
Assignments: row.Preset.Assignments));
_toast?.Show($"Duplicated to \"{newName}\"");
ReloadPresets();
}
catch (Exception ex)
{
MessageBox.Show(this,
$"Could not duplicate preset.\n\n{ex.Message}",
"Dragon-ISO — Duplicate preset",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
/// <summary>
/// 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)
{
if (!original.EndsWith(")", StringComparison.Ordinal))
return original + " (copy)";
var match = System.Text.RegularExpressions.Regex.Match(original, @" \(copy(?: (\d+))?\)$");
if (!match.Success) return original + " (copy)";
var n = match.Groups[1].Success && int.TryParse(match.Groups[1].Value, out var parsed) ? parsed + 1 : 2;
return original[..(original.Length - match.Length)] + $" (copy {n})";
}
/// <summary>
/// Quick input dialog for a single string. WPF doesn't ship one, so we
/// build a minimal modal here. Keeps the dialog dependency-free.
/// </summary>
private string? PromptForName(string title, string prompt, string defaultValue)
{
var dlg = new System.Windows.Window
{
Title = title,
Owner = this,
Width = 400,
Height = 170,
WindowStartupLocation = System.Windows.WindowStartupLocation.CenterOwner,
ResizeMode = System.Windows.ResizeMode.NoResize,
Background = (System.Windows.Media.Brush)FindResource("Wd.Canvas"),
};
var stack = new System.Windows.Controls.StackPanel { Margin = new System.Windows.Thickness(20) };
stack.Children.Add(new System.Windows.Controls.TextBlock
{
Text = prompt,
Margin = new System.Windows.Thickness(0, 0, 0, 8),
Foreground = (System.Windows.Media.Brush)FindResource("Wd.Text.Primary"),
});
var tb = new System.Windows.Controls.TextBox { Text = defaultValue, Padding = new System.Windows.Thickness(8, 6, 8, 6) };
stack.Children.Add(tb);
var buttons = new System.Windows.Controls.StackPanel
{
Orientation = System.Windows.Controls.Orientation.Horizontal,
HorizontalAlignment = System.Windows.HorizontalAlignment.Right,
Margin = new System.Windows.Thickness(0, 16, 0, 0),
};
var ok = new System.Windows.Controls.Button { Content = "OK", IsDefault = true, Padding = new System.Windows.Thickness(20, 6, 20, 6), Style = (System.Windows.Style)FindResource("Wd.Button.Primary") };
var cancel = new System.Windows.Controls.Button { Content = "Cancel", IsCancel = true, Padding = new System.Windows.Thickness(14, 6, 14, 6), Margin = new System.Windows.Thickness(0, 0, 8, 0), Style = (System.Windows.Style)FindResource("Wd.Button.Ghost") };
ok.Click += (_, _) => { dlg.DialogResult = true; dlg.Close(); };
buttons.Children.Add(cancel);
buttons.Children.Add(ok);
stack.Children.Add(buttons);
dlg.Content = stack;
tb.Focus();
tb.SelectAll();
var result = dlg.ShowDialog();
return result == true ? tb.Text.Trim() : null;
}
private void OnDelete(object sender, RoutedEventArgs e)
{
if (PresetsList.SelectedItem is not PresetRow row) return;
var confirm = MessageBox.Show(
this,
$"Delete preset \"{row.Name}\"? This cannot be undone.",
"Dragon-ISO — Delete preset",
MessageBoxButton.YesNo,
MessageBoxImage.Warning,
MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
try
{
OperatorPresetStore.Delete(row.Name);
_toast?.Show($"Deleted preset \"{row.Name}\"");
ReloadPresets();
}
catch (Exception ex)
{
MessageBox.Show(
this,
$"Could not delete preset.\n\n{ex.Message}",
"Dragon-ISO — Delete preset",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
private void OnCancel(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
/// <summary>
/// Save every preset as a single .json bundle to a path the user picks via
/// SaveFileDialog. We use Microsoft.Win32.SaveFileDialog because it doesn't
/// drag in WinForms; the WPF host doesn't ship a built-in alternative.
/// </summary>
private void OnExport(object sender, RoutedEventArgs e)
{
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",
DefaultExt = "json",
};
if (dlg.ShowDialog(this) != true) return;
try
{
var json = OperatorPresetStore.ExportAllAsJson();
System.IO.File.WriteAllText(dlg.FileName, json);
_toast?.Show($"Exported {Rows.Count} preset(s)");
}
catch (Exception ex)
{
MessageBox.Show(this,
$"Could not export presets.\n\n{ex.Message}",
"Dragon-ISO — Export presets",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
/// <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
/// be exhausting for a 20-preset bundle. The Y/N here drives the overwrite
/// flag passed to <see cref="OperatorPresetStore.ImportBundle"/>.
/// </summary>
private void OnImport(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = "Import Dragon-ISO presets",
Filter = "Dragon-ISO preset bundle (*.json)|*.json|All files (*.*)|*.*",
};
if (dlg.ShowDialog(this) != true) return;
string json;
try { json = System.IO.File.ReadAllText(dlg.FileName); }
catch (Exception ex)
{
MessageBox.Show(this,
$"Could not read the file.\n\n{ex.Message}",
"Dragon-ISO — Import presets",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
// Quick parse to sniff for collisions before asking the operator anything.
OperatorPresetStore.Bundle? bundle;
try { bundle = System.Text.Json.JsonSerializer.Deserialize<OperatorPresetStore.Bundle>(json); }
catch
{
MessageBox.Show(this,
"That file isn't a valid Dragon-ISO preset bundle.",
"Dragon-ISO — Import presets",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
if (bundle is null || bundle.Presets is null || bundle.Presets.Count == 0)
{
MessageBox.Show(this,
"The bundle is empty.",
"Dragon-ISO — Import presets",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
}
var existingNames = OperatorPresetStore.LoadAll()
.Select(p => p.Name)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var collisions = bundle.Presets.Count(p => existingNames.Contains(p.Name));
var overwrite = false;
if (collisions > 0)
{
var choice = MessageBox.Show(
this,
$"{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",
MessageBoxButton.YesNoCancel,
MessageBoxImage.Question,
MessageBoxResult.No);
if (choice == MessageBoxResult.Cancel) return;
overwrite = choice == MessageBoxResult.Yes;
}
var result = OperatorPresetStore.ImportBundle(json, overwrite);
if (result.Error is not null)
{
MessageBox.Show(this,
$"Import failed.\n\n{result.Error}",
"Dragon-ISO — Import presets",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
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);
ReloadPresets();
}
}

View file

@ -1,68 +0,0 @@
<Window x:Class="DragonISO.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"
Width="640" Height="400"
MinWidth="320" MinHeight="200"
Background="Black"
WindowStyle="None"
ResizeMode="CanResize"
UseLayoutRounding="True">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome
CaptionHeight="32"
ResizeBorderThickness="6"
CornerRadius="0"
GlassFrameThickness="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Caption -->
<Grid Grid.Row="0" Background="{DynamicResource Wd.Surface}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TitleText"
Text="Preview"
Style="{StaticResource Wd.Text.Caption}"
Margin="14,0,0,0"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="1"
x:Name="ResolutionText"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
VerticalAlignment="Center"
Margin="0,0,12,0"/>
<Button Grid.Column="2"
Style="{StaticResource Wd.Button.CaptionClose}"
Click="OnClose"
shell:WindowChrome.IsHitTestVisibleInChrome="True">
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"/>
</Button>
</Grid>
<!-- Live preview -->
<Image Grid.Row="1"
x:Name="PreviewImage"
Stretch="Uniform"
RenderOptions.BitmapScalingMode="HighQuality"/>
</Grid>
</Border>
</Window>

View file

@ -1,95 +0,0 @@
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Pipeline;
namespace DragonISO.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.
///
/// 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
/// straight into the bitmap without scaling. WPF's Image control with
/// Stretch=Uniform handles aspect-correct fit to the window size.
/// </summary>
public partial class PreviewWindow : Window
{
private readonly IIsoController _controller;
private readonly Guid _participantId;
private readonly DispatcherTimer _refreshTimer;
private WriteableBitmap? _bitmap;
private int _lastWidth;
private int _lastHeight;
public PreviewWindow(IIsoController controller, Guid participantId, string displayName)
{
InitializeComponent();
_controller = controller;
_participantId = participantId;
TitleText.Text = displayName;
_refreshTimer = new DispatcherTimer(DispatcherPriority.Background, Dispatcher)
{
// 50ms = 20Hz. High enough for a smooth-feeling preview without
// hogging the dispatcher; still cheap because each refresh is just
// a memcpy from the engine's last frame into our pinned BackBuffer.
Interval = TimeSpan.FromMilliseconds(50),
};
_refreshTimer.Tick += OnTick;
Loaded += (_, _) => _refreshTimer.Start();
Closed += (_, _) =>
{
_refreshTimer.Stop();
_refreshTimer.Tick -= OnTick;
};
}
private void OnClose(object sender, RoutedEventArgs e) => Close();
private void OnTick(object? sender, EventArgs e)
{
ProcessedFrame? frame;
try { frame = _controller.GetLatestProcessedFrame(_participantId); }
catch { return; }
if (frame is null || frame.Pixels.IsEmpty || frame.Width <= 0 || frame.Height <= 0) return;
if (frame.Pixels.Length < frame.Width * frame.Height * 4) return;
// (Re)allocate the WriteableBitmap when the source resolution changes.
// FrameProcessor normalizes to a configured target so this happens at
// most once per session, but we still defend against switches.
if (_bitmap is null || frame.Width != _lastWidth || frame.Height != _lastHeight)
{
_bitmap = new WriteableBitmap(
frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
PreviewImage.Source = _bitmap;
_lastWidth = frame.Width;
_lastHeight = 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
// byte-array overload is simpler and the compiler picks the right
// ToArray-free path because the engine already allocates a fresh
// array per frame.
unsafe
{
fixed (byte* p = frame.Pixels.Span)
{
_bitmap.WritePixels(
new Int32Rect(0, 0, frame.Width, frame.Height),
(IntPtr)p,
frame.Pixels.Length,
frame.Width * 4);
}
}
}
}

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,449 +0,0 @@
namespace DragonISO.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
/// build step, no React. Phone-friendly remote that connects via WebSocket
/// to <c>/ws</c> and posts to the existing REST endpoints.
///
/// v2 additions:
/// - 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
/// 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.
/// </summary>
internal static class ControlPanelHtml
{
private const string Html = @"<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<title>Dragon-ISO Control</title>
<style>
:root {
--bg: #0a0a0a;
--surface: #141414;
--surface-elev: #1c1c1c;
--border: #262626;
--border-strong: #3a3b40;
--text: #f5f5f5;
--text-2: #a3a3a3;
--text-3: #6b6b6b;
--cyan: #97edf0;
--cyan-mute: #1b3537;
--cyan-text: #97edf0;
--coral: #fb819c;
--coral-bg: #3a1922;
--green: #4ade80;
--amber: #fbbf24;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--text);
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
body { padding: 16px; max-width: 920px; margin: 0 auto; }
h1 {
font-size: 11px; letter-spacing: 0.12em; font-weight: 600;
text-transform: uppercase; color: var(--text-3); margin: 0 0 14px;
}
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 14px; margin-bottom: 12px;
}
.row { display: flex; align-items: center; gap: 10px; }
.row + .row { margin-top: 10px; }
.grow { flex: 1; min-width: 0; }
button {
background: var(--surface-elev); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 14px; cursor: pointer;
font: inherit; font-size: 13px;
transition: background 80ms ease, border-color 80ms ease;
}
button:hover { background: #242424; border-color: var(--border-strong); }
button.primary { background: var(--cyan); color: #042830; border-color: var(--cyan); font-weight: 500; }
button.primary:hover { background: #b5f2f4; }
button.danger { background: transparent; color: var(--coral); border-color: var(--coral); }
button.danger:hover { background: var(--coral-bg); }
button.live { background: var(--cyan-mute); color: var(--cyan-text); border-color: var(--cyan); }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.dot.cyan { background: var(--cyan); }
.dot.coral { background: var(--coral); }
.dot.green { background: var(--green); }
.dot.amber { background: var(--amber); }
.dot.gray { background: var(--text-3); }
.name { font-weight: 500; font-size: 14px; }
.sub { color: var(--text-3); font-size: 11px;
font-family: 'JetBrains Mono', 'Cascadia Mono', monospace;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.label-caps { font-size: 10px; font-weight: 600; letter-spacing: 0.08em;
color: var(--text-3); text-transform: uppercase; }
.status {
display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
font-family: 'JetBrains Mono', 'Cascadia Mono', monospace;
font-size: 11px; color: var(--text-3);
}
.status .ok { color: var(--green); }
.status .err { color: var(--coral); }
.empty { color: var(--text-3); font-size: 12px; padding: 18px; text-align: center; }
.global-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
@media (min-width: 480px) { .global-actions { grid-template-columns: repeat(4, 1fr); } }
.participant-wrap { border-radius: 10px; }
.participant-wrap + .participant-wrap { margin-top: 6px; }
.participant-wrap.override { box-shadow: inset 3px 0 0 var(--cyan); }
.participant-row { display: flex; align-items: center; gap: 14px; padding: 10px; border-radius: 10px; }
.participant-row.speaking { background: var(--cyan-mute); }
.preview {
width: 112px; height: 63px; flex-shrink: 0; border-radius: 6px;
background: var(--surface-elev); border: 1px solid var(--border);
object-fit: cover; display: block;
}
.preview.empty { display: flex; align-items: center; justify-content: center;
color: var(--text-3); font-family: 'JetBrains Mono', monospace; font-size: 18px; }
.ovr-pill { display: inline-block; margin-left: 6px; padding: 1px 6px;
border-radius: 999px; background: var(--cyan-mute); color: var(--cyan-text);
font-size: 9px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase;
vertical-align: middle; }
.cfg-caption { font-family: 'JetBrains Mono', 'Cascadia Mono', monospace;
font-size: 10px; color: var(--text-3); margin-right: 6px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 140px; }
.gear-btn { padding: 6px 10px; font-size: 12px; }
.row-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.override-panel { display: none; padding: 12px 10px 14px;
border-top: 1px solid var(--border); background: var(--surface-elev);
border-bottom-left-radius: 10px; border-bottom-right-radius: 10px; }
.override-panel.open { display: block; }
.override-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 560px) {
.override-grid { grid-template-columns: repeat(4, 1fr); }
}
.override-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.override-field label { font-size: 10px; font-weight: 600; letter-spacing: 0.08em;
color: var(--text-3); text-transform: uppercase; }
.override-field select {
background: var(--surface); color: var(--text);
border: 1px solid var(--border); border-radius: 8px;
padding: 8px 10px; font: inherit; font-size: 12px;
appearance: none; -webkit-appearance: none;
}
.override-field select:focus { outline: none; border-color: var(--cyan); }
.override-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.topology-card { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.topology-state { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 200px; }
.topology-state strong { font-size: 13px; color: var(--text); }
.topology-banner { margin: 10px 0 0; padding: 10px 12px; border-radius: 8px;
background: var(--cyan-mute); color: var(--cyan-text); font-size: 12px; display: none; }
.topology-banner.show { display: block; }
</style>
</head>
<body>
<h1>Dragon-ISO 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 id='count' class='sub'></span>
</div>
</div>
<div class='card topology-card'>
<div class='topology-state'>
<span id='topo-dot' class='dot gray'></span>
<div>
<div class='label-caps'>Network topology</div>
<strong id='topo-label'>â</strong>
<div id='topo-detail' class='sub' style='margin-top: 2px;'></div>
</div>
</div>
<div style='display: flex; gap: 8px;'>
<button id='topo-apply' onclick='applyTopology()'>Hide Teams sources</button>
<button id='topo-restore' onclick='restoreTopology()'>Restore defaults</button>
</div>
</div>
<div id='topo-banner' class='topology-banner'></div>
<div class='card'>
<div class='global-actions'>
<button onclick='post(""/teams/mute"")'>Mute</button>
<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='post(""/presets/refresh-discovery"")'>Refresh</button>
<button class='danger' onclick='post(""/presets/stop-all"")'>Stop all ISOs</button>
</div>
</div>
<div id='participants'></div>
<script>
const list = document.getElementById('participants');
const conn = document.getElementById('conn');
const connText = document.getElementById('conn-text');
const count = document.getElementById('count');
const topoDot = document.getElementById('topo-dot');
const topoLabel = document.getElementById('topo-label');
const topoDetail = document.getElementById('topo-detail');
const topoBanner = document.getElementById('topo-banner');
function setConn(state, text) {
conn.className = 'dot ' + state;
connText.textContent = text;
}
async function post(path, body) {
try {
const opts = { method: 'POST' };
if (body) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); }
const r = await fetch(path, opts);
return r.ok ? await r.json().catch(() => null) : null;
} catch (e) { console.warn(e); return null; }
}
function dropNote() {
const text = prompt('Note (will be timestamped in the day file):');
if (text && text.trim()) post('/notes', { text: text.trim() });
}
async function fetchTopology() {
try {
const r = await fetch('/topology');
if (!r.ok) return;
const t = await r.json();
paintTopology(t);
} catch (e) { console.warn(e); }
}
function paintTopology(t) {
if (!t) return;
if (t.mode === 'hidden') {
topoDot.className = 'dot cyan';
topoLabel.textContent = 'Teams hidden from LAN';
} else if (t.mode === 'public') {
topoDot.className = 'dot amber';
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;
}
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.classList.add('show');
setTimeout(() => topoBanner.classList.remove('show'), 8000);
}
fetchTopology();
}
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.classList.add('show');
setTimeout(() => topoBanner.classList.remove('show'), 8000);
}
fetchTopology();
}
// Enum options for the per-participant override selects. Values match the
// .NET enum names so they round-trip through POST /participants/{id}/override
// without translation.
const FRAMERATE_OPTS = [
['Fps23_976', '23.976'], ['Fps24', '24'], ['Fps25', '25'],
['Fps29_97', '29.97'], ['Fps30', '30'], ['Fps50', '50'],
['Fps59_94', '59.94'], ['Fps60', '60'],
];
const RESOLUTION_OPTS = [
['R720p', '720p'], ['R1080p', '1080p'], ['R4K', '4K'],
];
const ASPECT_OPTS = [
['Pillarbox', 'Pillarbox'], ['Letterbox', 'Letterbox'], ['Stretch', 'Stretch'],
];
const AUDIO_OPTS = [
['Auto', 'Auto'], ['Isolated', 'Isolated'], ['Mixed', 'Mixed'],
];
// Track which participant rows have the override panel expanded so it
// survives re-renders driven by the WS state push (otherwise every
// 1Hz snapshot would collapse it under the operator's finger).
const openPanels = new Set();
function shortFps(v) {
for (const [k, label] of FRAMERATE_OPTS) if (k === v) return label;
return v || 'â';
}
function shortRes(v) {
for (const [k, label] of RESOLUTION_OPTS) if (k === v) return label;
return v || 'â';
}
function shortAudio(v) {
for (const [k, label] of AUDIO_OPTS) if (k === v) return label;
return v || 'â';
}
function buildSelect(opts, current) {
let html = '';
for (const [val, label] of opts) {
const sel = (val === current) ? ' selected' : '';
html += ""<option value='"" + val + ""'"" + sel + "">"" + label + ""</option>"";
}
return html;
}
function render(participants) {
if (!participants || participants.length === 0) {
list.innerHTML = ""<div class='card empty'>No participants visible. Open Teams and join a meeting; sources will populate within seconds.</div>"";
count.textContent = '';
return;
}
const live = participants.filter(p => p.isEnabled).length;
count.textContent = live + ' / ' + participants.length + ' live';
list.innerHTML = '';
const card = document.createElement('div');
card.className = 'card';
for (const p of participants) {
const wrap = document.createElement('div');
wrap.className = 'participant-wrap';
const eff = p.effective || {};
const isOverride = !!eff.isOverride;
if (isOverride) wrap.classList.add('override');
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
// 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='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='enable-btn'></button>"" +
""</div>"";
const img = row.querySelector('img.preview');
img.src = previewUrl;
row.querySelector('.name').textContent = p.displayName;
const subEl = row.querySelector('.sub');
subEl.textContent =
(p.stateLabel || (p.isOnline ? 'online' : 'offline')) +
(p.customName ? ' · ' + p.customName : '');
if (isOverride) {
const pill = document.createElement('span');
pill.className = 'ovr-pill';
pill.textContent = 'OVR';
subEl.appendChild(pill);
}
row.querySelector('.cfg-caption').textContent =
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.onclick = () => post('/participants/iso', {
displayName: p.displayName,
enabled: !p.isEnabled,
});
const panel = document.createElement('div');
panel.className = 'override-panel' + (openPanels.has(p.id) ? ' open' : '');
panel.innerHTML =
""<div class='override-grid'>"" +
""<div class='override-field'><label>Framerate</label>"" +
""<select data-k='framerate'>"" + buildSelect(FRAMERATE_OPTS, eff.framerate) + ""</select></div>"" +
""<div class='override-field'><label>Resolution</label>"" +
""<select data-k='resolution'>"" + buildSelect(RESOLUTION_OPTS, eff.resolution) + ""</select></div>"" +
""<div class='override-field'><label>Aspect</label>"" +
""<select data-k='aspect'>"" + buildSelect(ASPECT_OPTS, eff.aspect) + ""</select></div>"" +
""<div class='override-field'><label>Audio</label>"" +
""<select data-k='audio'>"" + buildSelect(AUDIO_OPTS, eff.audio) + ""</select></div>"" +
""</div>"" +
""<div class='override-actions'>"" +
""<button class='primary apply-btn'>Apply</button>"" +
""<button class='danger clear-btn'>Clear (use global)</button>"" +
""</div>"";
const gearBtn = row.querySelector('.gear-btn');
gearBtn.onclick = () => {
if (openPanels.has(p.id)) {
openPanels.delete(p.id);
panel.classList.remove('open');
} else {
openPanels.add(p.id);
panel.classList.add('open');
}
};
panel.querySelector('.apply-btn').onclick = async () => {
const body = {};
panel.querySelectorAll('select[data-k]').forEach(s => { body[s.dataset.k] = s.value; });
await fetch('/participants/' + p.id + '/override', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).catch(e => console.warn(e));
openPanels.delete(p.id);
panel.classList.remove('open');
};
panel.querySelector('.clear-btn').onclick = async () => {
await fetch('/participants/' + p.id + '/override', { method: 'DELETE' })
.catch(e => console.warn(e));
openPanels.delete(p.id);
panel.classList.remove('open');
};
wrap.appendChild(row);
wrap.appendChild(panel);
card.appendChild(wrap);
}
list.appendChild(card);
}
function connect() {
setConn('gray', 'connectingâ¦');
const ws = new WebSocket(
(location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
ws.onopen = () => { setConn('green', 'live'); fetchTopology(); };
ws.onmessage = (ev) => {
try {
const m = JSON.parse(ev.data);
if (m.type === 'participants') render(m.participants);
} catch (e) { console.warn(e); }
};
ws.onclose = () => {
setConn('coral', 'disconnected â retry in 3s');
setTimeout(connect, 3000);
};
ws.onerror = () => setConn('coral', 'error');
}
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.
setInterval(fetchTopology, 30000);
</script>
</body>
</html>
";
public static string Get() => Html;
}

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,134 +0,0 @@
using System.IO;
using System.IO.Compression;
using System.Reflection;
using System.Text;
namespace DragonISO.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 —
/// 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
/// 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.
/// </summary>
public static string Export()
{
var ts = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
var fileName = $"Dragon-ISO-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 ~/
var outPath = Path.Combine(downloads, fileName);
using var fs = new FileStream(outPath, FileMode.Create, FileAccess.Write, FileShare.None);
using var zip = new ZipArchive(fs, ZipArchiveMode.Create, leaveOpen: false);
WriteEnvironmentTxt(zip);
TryCopyDirectory(zip, "logs", LogsDirectory);
TryCopyFile(zip, "config.json", AppDataPath("config.json"));
TryCopyFile(zip, "presets.json", LocalAppDataPath("presets.json"));
TryCopyFile(zip, "window.json", LocalAppDataPath("window.json"));
TryCopyFile(zip, "ndi-config.v1.json", NdiConfigPath());
TryCopyFile(zip, "output-name-template.txt", LocalAppDataPath("output-name-template.txt"));
return outPath;
}
private static void WriteEnvironmentTxt(ZipArchive zip)
{
var asm = typeof(DiagnosticsBundle).Assembly;
var version = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? asm.GetName().Version?.ToString()
?? "unknown";
var sb = new StringBuilder();
sb.AppendLine("Dragon-ISO diagnostic bundle");
sb.AppendLine($"Generated: {DateTimeOffset.Now:o}");
sb.AppendLine($"Dragon-ISO version: {version}");
sb.AppendLine($".NET runtime: {Environment.Version}");
sb.AppendLine($"OS: {Environment.OSVersion}");
sb.AppendLine($"Machine: {Environment.MachineName}");
sb.AppendLine($"User: {Environment.UserName}");
sb.AppendLine($"Process bits: {(Environment.Is64BitProcess ? "64" : "32")}");
sb.AppendLine($"OS bits: {(Environment.Is64BitOperatingSystem ? "64" : "32")}");
sb.AppendLine($"Working set: {Environment.WorkingSet / (1024 * 1024)} MB");
sb.AppendLine();
sb.AppendLine("Files included (when present):");
sb.AppendLine(" logs/ Serilog rolling daily logs");
sb.AppendLine(" config.json Engine settings (framerate, NDI groups, etc.)");
sb.AppendLine(" presets.json Saved operator presets");
sb.AppendLine(" window.json Last main-window placement");
sb.AppendLine(" ndi-config.v1.json NDI Access Manager config (group routing)");
sb.AppendLine(" output-name-template.txt NDI source name template override");
var entry = zip.CreateEntry("environment.txt");
using var w = new StreamWriter(entry.Open(), Encoding.UTF8);
w.Write(sb.ToString());
}
private static void TryCopyFile(ZipArchive zip, string entryName, string sourcePath)
{
try
{
if (!File.Exists(sourcePath)) return;
zip.CreateEntryFromFile(sourcePath, entryName);
}
catch
{
// One missing or locked file shouldn't kill the rest of the bundle.
}
}
private static void TryCopyDirectory(ZipArchive zip, string prefix, string sourceDir)
{
if (!Directory.Exists(sourceDir)) return;
try
{
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
{
try
{
var rel = Path.GetRelativePath(sourceDir, file).Replace('\\', '/');
zip.CreateEntryFromFile(file, $"{prefix}/{rel}");
}
catch
{
// Skip locked files (e.g., today's actively-written log).
}
}
}
catch
{
// Permission denied on the dir as a whole; nothing more to do.
}
}
private static string LogsDirectory =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO", "Logs");
private static string LocalAppDataPath(string fileName) =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO", fileName);
private static string AppDataPath(string fileName) =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Dragon-ISO", fileName);
private static string NdiConfigPath() =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"NDI", "ndi-config.v1.json");
}

View file

@ -1,210 +0,0 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace DragonISO.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
/// 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
/// outputs continue to broadcast on the standard <c>Public</c> group that downstream
/// switchers and recorders default to.
///
/// The shape of ndi-config.v1.json is documented in the NDI 6 SDK headers; we work in
/// terms of <see cref="JsonNode"/> trees so we don't clobber unrelated keys (e.g. RUDP
/// settings the user may have customized in Access Manager).
/// </summary>
public static class NdiAccessManagerConfig
{
/// <summary>
/// Path to the NDI Access Manager config. <c>%APPDATA%\NDI\ndi-config.v1.json</c>.
/// </summary>
public static string ConfigPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"NDI",
"ndi-config.v1.json");
/// <summary>
/// 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";
/// <summary>
/// Result of an apply attempt. <see cref="Success"/> indicates the file was
/// written or already had the desired groups. <see cref="BackupPath"/> is set
/// to the path of the saved-aside copy of the prior config (when one existed),
/// so the user can revert if they don't like the change.
/// </summary>
public sealed record ApplyResult(
bool Success,
string ConfigPath,
string? BackupPath,
string? ErrorMessage);
/// <summary>
/// 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>
/// <item>All local receivers see both <paramref name="senderGroup"/> and
/// <c>public</c> so Dragon-ISO 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
/// 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>
public static ApplyResult ApplyTranscoderTopology(string senderGroup = TranscoderInputGroup)
{
try
{
var root = LoadOrCreate();
var ndi = EnsureObject(root, "ndi");
var groups = EnsureObject(ndi, "groups");
groups["send"] = new JsonArray(senderGroup);
groups["recv"] = new JsonArray("public", senderGroup);
var backupPath = WriteWithBackup(root);
return new ApplyResult(Success: true, ConfigPath: ConfigPath, BackupPath: backupPath, ErrorMessage: null);
}
catch (Exception ex)
{
return new ApplyResult(Success: false, ConfigPath: ConfigPath, BackupPath: null, ErrorMessage: ex.Message);
}
}
/// <summary>
/// Restores defaults: senders on <c>public</c>, receivers on <c>public</c>.
/// Equivalent to undoing <see cref="ApplyTranscoderTopology"/>.
/// </summary>
public static ApplyResult RestoreDefaults()
{
try
{
var root = LoadOrCreate();
var ndi = EnsureObject(root, "ndi");
var groups = EnsureObject(ndi, "groups");
groups["send"] = new JsonArray("public");
groups["recv"] = new JsonArray("public");
var backupPath = WriteWithBackup(root);
return new ApplyResult(Success: true, ConfigPath: ConfigPath, BackupPath: backupPath, ErrorMessage: null);
}
catch (Exception ex)
{
return new ApplyResult(Success: false, ConfigPath: ConfigPath, BackupPath: null, ErrorMessage: ex.Message);
}
}
/// <summary>
/// Reads the current sender / receiver group lists, or null if the config doesn't
/// exist yet (NDI Access Manager has never been opened on this machine).
/// </summary>
public static (IReadOnlyList<string>? Send, IReadOnlyList<string>? Recv) ReadCurrentGroups()
{
if (!File.Exists(ConfigPath)) return (null, null);
try
{
using var stream = File.OpenRead(ConfigPath);
var root = JsonNode.Parse(stream);
var groups = root?["ndi"]?["groups"];
return (
AsStringList(groups?["send"]),
AsStringList(groups?["recv"]));
}
catch
{
return (null, null);
}
}
private static IReadOnlyList<string>? AsStringList(JsonNode? node) =>
node is JsonArray arr ? arr.Select(n => n?.GetValue<string>() ?? string.Empty).ToArray() : null;
/// <summary>
/// One-call shape for the control surface's <c>GET /topology</c>: returns
/// the current sender + receiver group lists alongside a computed
/// <c>mode</c> string. "hidden" when senders are confined to the private
/// transcoder-input group (raw Teams sources invisible on the LAN);
/// "public" when senders are on the default group; "unknown" when the
/// config file is missing or malformed (treated by callers as "public"
/// because NDI's runtime defaults to public when no config is present).
/// </summary>
public static (string Mode, IReadOnlyList<string> Senders, IReadOnlyList<string> Receivers) ReadCurrent()
{
var (send, recv) = ReadCurrentGroups();
var senders = send ?? Array.Empty<string>();
var receivers = recv ?? Array.Empty<string>();
string mode;
if (send is null)
{
mode = "unknown";
}
else if (send.Count == 1 && string.Equals(send[0], TranscoderInputGroup, StringComparison.OrdinalIgnoreCase))
{
mode = "hidden";
}
else
{
mode = "public";
}
return (mode, senders, receivers);
}
private static JsonObject LoadOrCreate()
{
if (File.Exists(ConfigPath))
{
using var stream = File.OpenRead(ConfigPath);
var existing = JsonNode.Parse(stream) as JsonObject;
if (existing is not null) return existing;
}
return new JsonObject();
}
private static JsonObject EnsureObject(JsonNode? parent, string key)
{
if (parent is not JsonObject obj)
throw new InvalidOperationException($"Cannot ensure key '{key}' on a non-object parent.");
if (obj[key] is JsonObject existing) return existing;
var fresh = new JsonObject();
obj[key] = fresh;
return fresh;
}
/// <summary>
/// Writes the config atomically (temp file + replace) and saves a backup of the
/// prior contents next to the original with a timestamp suffix. Returns the
/// backup path if a prior file existed; null on first-write.
/// </summary>
private static string? WriteWithBackup(JsonNode root)
{
var dir = Path.GetDirectoryName(ConfigPath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
string? backupPath = null;
if (File.Exists(ConfigPath))
{
var stamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
backupPath = ConfigPath + $".bak-{stamp}";
File.Copy(ConfigPath, backupPath, overwrite: true);
}
var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
var tempPath = ConfigPath + ".tmp";
File.WriteAllText(tempPath, json);
if (File.Exists(ConfigPath)) File.Replace(tempPath, ConfigPath, destinationBackupFileName: null);
else File.Move(tempPath, ConfigPath);
return backupPath;
}
}

View file

@ -1,68 +0,0 @@
using System.IO;
using System.Text;
namespace DragonISO.App.Services;
/// <summary>
/// Append-only show-notes log. Each call writes a timestamped line to a daily
/// markdown file at <c>%LOCALAPPDATA%\Dragon-ISO\Notes\&lt;YYYY-MM-DD&gt;.md</c>.
/// Operators stamp notes via the REST <c>POST /notes</c> endpoint or the OSC
/// <c>/Dragon-ISO/notes "..."</c> address — typically wired to a Stream Deck
/// button so a note can be left without leaving the show.
///
/// We deliberately don't surface the notes inside the WPF UI: the file is
/// trivial to open in any editor, and inline note-taking would be a much
/// bigger feature (textarea, scrollback, autosave). The endpoint is the
/// minimum-viable affordance for live note capture.
/// </summary>
public static class NotesService
{
private static readonly object _gate = new();
/// <summary>
/// Test-only seam — when set, overrides the default
/// %LOCALAPPDATA%\Dragon-ISO\Notes path. Lets tests write to a
/// tempdir without polluting the dev's real notes folder.
/// InternalsVisibleTo grants DragonISO.App.Tests access.
/// </summary>
internal static string? DirectoryOverride { get; set; }
private static string NotesDirectory =>
DirectoryOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO", "Notes");
/// <summary>Today's notes file path (created lazily on first append).</summary>
public static string TodayPath =>
Path.Combine(NotesDirectory, $"{DateTimeOffset.Now:yyyy-MM-dd}.md");
/// <summary>
/// Append a single timestamped line. Concurrent callers serialize through
/// the static gate so we don't end up with interleaved writes from the
/// REST handler thread vs. the OSC dispatcher.
/// </summary>
public static bool Append(string text)
{
if (string.IsNullOrWhiteSpace(text)) return false;
try
{
lock (_gate)
{
Directory.CreateDirectory(NotesDirectory);
var path = TodayPath;
var line = $"- **{DateTimeOffset.Now:HH:mm:ss}** — {text.Trim()}{Environment.NewLine}";
if (!File.Exists(path))
{
var header = $"# Dragon-ISO show notes — {DateTimeOffset.Now:yyyy-MM-dd}{Environment.NewLine}{Environment.NewLine}";
File.WriteAllText(path, header, Encoding.UTF8);
}
File.AppendAllText(path, line, Encoding.UTF8);
}
return true;
}
catch
{
return false;
}
}
}

View file

@ -1,273 +0,0 @@
using System.IO;
using System.Text.Json;
namespace DragonISO.App.Services;
/// <summary>
/// Persistent named snapshots of which participants should have ISOs enabled and
/// what their custom output names are. Useful for recurring shows: an operator
/// can save the assignment they spent 5 minutes setting up, and on the next
/// meeting load the same preset and auto-enable everyone whose display name
/// matches.
///
/// Persisted as JSON at <c>%LOCALAPPDATA%\Dragon-ISO\presets.json</c>. We key by
/// participant <see cref="DisplayName"/> rather than <see cref="Guid"/> Id
/// because the Id is freshly generated for every meeting (Teams' NDI source
/// identity isn't stable across sessions); display name is the operator's
/// natural identifier and is what they see in the UI anyway.
/// </summary>
public static class OperatorPresetStore
{
/// <summary>
/// Test-only override for the presets file path. Tests set this to a temp
/// path so they don't pollute the operator's real %LOCALAPPDATA% store.
/// Null in production. <see cref="System.Runtime.CompilerServices.InternalsVisibleToAttribute"/>
/// in the project file grants the test assembly access.
/// </summary>
internal static string? PathOverride { get; set; }
private static string PresetsPath =>
PathOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO",
"presets.json");
/// <summary>
/// One operator preset: a name, when it was saved, and a list of
/// per-participant assignments keyed by display name.
/// </summary>
public sealed record Preset(
string Name,
DateTimeOffset SavedAt,
IReadOnlyList<Assignment> Assignments);
/// <summary>
/// Single participant's assignment within a preset. Both fields are stable
/// across meetings; <see cref="DisplayName"/> is the join key when applying.
/// </summary>
public sealed record Assignment(
string DisplayName,
string? CustomOutputName,
bool Enabled);
/// <summary>
/// On-disk shape: a list of presets indexed by name. Wrapped in an object so
/// we can grow the schema (versioning, defaults, last-used) without breaking
/// existing files. <see cref="LastAppliedName"/> + <see cref="AutoApplyOnStartup"/>
/// drive the "auto-apply on startup" feature; reading older files (which lack
/// these fields) falls back to default values via the records' default ctor.
/// </summary>
private sealed record File(
int Version,
IReadOnlyList<Preset> Presets,
string? LastAppliedName = null,
bool AutoApplyOnStartup = false);
/// <summary>
/// Operator-level preferences that travel inside the same JSON envelope as the
/// presets themselves. Currently used for the "auto-apply last preset on launch"
/// feature so the host can decide on startup whether to silently re-apply the
/// most recent preset and which one to apply.
/// </summary>
public sealed record StartupPreference(string? LastAppliedName, bool AutoApplyOnStartup);
/// <summary>Returns all stored presets, oldest first. Empty list if no file exists.</summary>
public static IReadOnlyList<Preset> LoadAll() => LoadFile().Presets ?? Array.Empty<Preset>();
/// <summary>
/// Returns the operator's startup preference (which preset, if any, should be
/// auto-applied on launch). Defaults to <c>(null, false)</c> when no file exists
/// or the file predates the field — older preset.json files deserialize cleanly
/// because both fields are optional with default values.
/// </summary>
public static StartupPreference GetStartupPreference()
{
var file = LoadFile();
return new StartupPreference(file.LastAppliedName, file.AutoApplyOnStartup);
}
/// <summary>
/// Records that <paramref name="name"/> was just successfully applied. Combined
/// with <see cref="SetAutoApplyOnStartup"/>, drives the auto-apply-on-launch flow.
/// Preserves the rest of the file (presets, AutoApplyOnStartup flag) intact.
/// </summary>
public static void MarkApplied(string name)
{
var file = LoadFile();
WriteFile(file with { LastAppliedName = name });
}
/// <summary>
/// Toggles whether the host should auto-apply <see cref="StartupPreference.LastAppliedName"/>
/// on next launch. Independent of <see cref="MarkApplied"/> so the operator can flip
/// the toggle without losing the most-recent name.
/// </summary>
public static void SetAutoApplyOnStartup(bool enabled)
{
var file = LoadFile();
WriteFile(file with { AutoApplyOnStartup = enabled });
}
/// <summary>
/// Adds (or replaces) a preset by name. Atomic write: writes to a temp file
/// then File.Replace so a crash mid-write doesn't corrupt the existing file.
/// Preserves <see cref="StartupPreference"/> across writes.
/// </summary>
public static void Save(Preset preset)
{
var file = LoadFile();
var presets = (file.Presets ?? Array.Empty<Preset>())
.Where(p => !string.Equals(p.Name, preset.Name, StringComparison.OrdinalIgnoreCase))
.Append(preset)
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
WriteFile(file with { Presets = presets });
}
/// <summary>
/// Removes a preset by name. No-op if not present. If the deleted preset was
/// the last-applied one, clears that field so we don't try to re-apply a missing
/// preset on next launch.
/// </summary>
public static void Delete(string name)
{
var file = LoadFile();
var existing = file.Presets ?? Array.Empty<Preset>();
var remaining = existing
.Where(p => !string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (remaining.Length == existing.Count) return; // not present
var clearedLastApplied =
string.Equals(file.LastAppliedName, name, StringComparison.OrdinalIgnoreCase)
? null
: file.LastAppliedName;
WriteFile(file with { Presets = remaining, LastAppliedName = clearedLastApplied });
}
/// <summary>Looks up a preset by name (case-insensitive). Null if not present.</summary>
public static Preset? Find(string name) =>
LoadAll().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Bundle format for the export/import surface. Wraps the preset list with
/// a version stamp + an export timestamp so a future-format-aware importer
/// can migrate the data. We deliberately export a flat preset list — not
/// the full <see cref="File"/> envelope — because StartupPreference is
/// machine-local (operator A's "auto-apply Friday Show" shouldn't follow
/// the bundle to operator B's machine).
/// </summary>
public sealed record Bundle(
string Schema,
DateTimeOffset ExportedAt,
IReadOnlyList<Preset> Presets)
{
public const string CurrentSchema = "Dragon-ISO-presets-bundle/v1";
}
/// <summary>
/// Serialize every preset to a JSON string suitable for writing to disk.
/// The shape is human-readable (WriteIndented) so an operator can diff
/// two bundles in their editor.
/// </summary>
public static string ExportAllAsJson()
{
var bundle = new Bundle(
Schema: Bundle.CurrentSchema,
ExportedAt: DateTimeOffset.Now,
Presets: LoadAll());
return JsonSerializer.Serialize(bundle, new JsonSerializerOptions { WriteIndented = true });
}
/// <summary>
/// Result of an import attempt — counts so the UI can toast a clear summary.
/// </summary>
public sealed record ImportResult(int Added, int Overwritten, int Skipped, string? Error)
{
public static ImportResult Failed(string error) => new(0, 0, 0, error);
}
/// <summary>
/// Import a bundle JSON. Per-preset name collision policy is determined by
/// <paramref name="overwrite"/>: when true, identically-named presets in the
/// bundle replace local ones; when false they're skipped. Returns counts
/// so the caller can toast a "added X, overwrote Y, skipped Z" summary.
/// </summary>
public static ImportResult ImportBundle(string json, bool overwrite)
{
Bundle? bundle;
try
{
bundle = JsonSerializer.Deserialize<Bundle>(json);
}
catch (Exception ex)
{
return ImportResult.Failed("Could not parse bundle: " + ex.Message);
}
if (bundle is null || bundle.Presets is null)
return ImportResult.Failed("Bundle was empty or malformed.");
var existingNames = new HashSet<string>(
LoadAll().Select(p => p.Name),
StringComparer.OrdinalIgnoreCase);
var added = 0;
var overwritten = 0;
var skipped = 0;
foreach (var p in bundle.Presets)
{
if (existingNames.Contains(p.Name))
{
if (!overwrite) { skipped++; continue; }
overwritten++;
}
else
{
added++;
}
try { Save(p); }
catch
{
// One bad preset shouldn't abort the rest. Count as skipped so
// the user knows their import wasn't 100% clean.
if (overwrite && existingNames.Contains(p.Name)) overwritten--;
else added--;
skipped++;
}
}
return new ImportResult(added, overwritten, skipped, null);
}
private static File LoadFile()
{
try
{
if (!System.IO.File.Exists(PresetsPath))
return new File(1, Array.Empty<Preset>());
var json = System.IO.File.ReadAllText(PresetsPath);
return JsonSerializer.Deserialize<File>(json) ?? new File(1, Array.Empty<Preset>());
}
catch
{
return new File(1, Array.Empty<Preset>());
}
}
private static void WriteFile(File file)
{
var dir = Path.GetDirectoryName(PresetsPath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var json = JsonSerializer.Serialize(file, new JsonSerializerOptions { WriteIndented = true });
var temp = PresetsPath + ".tmp";
System.IO.File.WriteAllText(temp, json);
if (System.IO.File.Exists(PresetsPath))
System.IO.File.Replace(temp, PresetsPath, destinationBackupFileName: null);
else
System.IO.File.Move(temp, PresetsPath);
}
}

View file

@ -1,368 +0,0 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging;
using DragonISO.App.ViewModels;
using DragonISO.Engine.Controller;
namespace DragonISO.App.Services;
/// <summary>
/// OSC over UDP. Companion, TouchOSC, Bome, and most lighting consoles speak
/// OSC natively, so wrapping the same command surface in OSC opens the
/// product to the broader live-show ecosystem without a Companion bridge.
///
/// Protocol — minimal OSC 1.0:
/// - Address pattern (null-terminated string, padded to 4-byte boundary)
/// - Type tag (",iiisf" etc., null-terminated, padded to 4)
/// - Args in order
///
/// We don't implement bundles, time tags, blob args, or pattern matching
/// — none are needed for the verbs we support. If a sender uses bundles
/// we ignore them; if a sender uses a wildcard address ("/Dragon-ISO/*") we
/// ignore it. Operators get a clear log line in either case.
///
/// Routes:
/// /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)
/// </summary>
public sealed class OscBridge : IAsyncDisposable
{
public const int DefaultPort = 9000;
private readonly IIsoController _controller;
private readonly Func<MainViewModel?> _viewModel;
private readonly ILogger<OscBridge>? _logger;
private UdpClient? _udp;
private CancellationTokenSource? _cts;
private Task? _receiveTask;
public bool IsRunning { get; private set; }
public int Port { get; private set; } = DefaultPort;
/// <summary>True when the listener is bound to all interfaces rather than just loopback.</summary>
public bool BoundToLan { get; private set; }
public OscBridge(
IIsoController controller,
Func<MainViewModel?> viewModel,
ILogger<OscBridge>? logger = null)
{
_controller = controller;
_viewModel = viewModel;
_logger = logger;
}
/// <summary>
/// Start the OSC listener on the given UDP port. <paramref name="bindToLan"/>
/// flag selects between loopback (default — only this machine) and any-
/// interface binding (LAN-reachable, for thin-client controllers).
/// Unlike the REST surface, UDP doesn't need a URL ACL — binding 0.0.0.0
/// is just an unprivileged port reservation.
/// </summary>
public void Start(int port, bool bindToLan = false)
{
if (IsRunning && Port == port && BoundToLan == bindToLan) return;
Stop();
Port = port;
BoundToLan = bindToLan;
var bindAddr = bindToLan ? IPAddress.Any : IPAddress.Loopback;
try
{
_udp = new UdpClient(new IPEndPoint(bindAddr, port));
}
catch (SocketException ex)
{
_logger?.LogWarning(ex, "Could not bind OSC bridge to udp://{Addr}:{Port}.", bindAddr, port);
_udp = null;
return;
}
_cts = new CancellationTokenSource();
_receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token));
IsRunning = true;
_logger?.LogInformation("OSC bridge listening on udp://{Addr}:{Port}/", bindAddr, port);
}
public void Stop()
{
if (!IsRunning) return;
try { _cts?.Cancel(); } catch { /* ignore */ }
try { _udp?.Close(); } catch { /* ignore */ }
try { _receiveTask?.Wait(TimeSpan.FromSeconds(2)); } catch { /* ignore */ }
_udp?.Dispose();
_udp = null;
_cts?.Dispose();
_cts = null;
_receiveTask = null;
IsRunning = false;
}
public async ValueTask DisposeAsync()
{
Stop();
await Task.CompletedTask;
}
private async Task ReceiveLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested && _udp is not null)
{
UdpReceiveResult result;
try { result = await _udp.ReceiveAsync(ct); }
catch (OperationCanceledException) { break; }
catch (ObjectDisposedException) { break; }
catch (SocketException ex)
{
_logger?.LogWarning(ex, "OSC receive failed; continuing.");
continue;
}
try
{
var msg = OscMessage.TryParse(result.Buffer);
if (msg is null) continue;
await DispatchAsync(msg);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "OSC dispatch failed for packet from {Endpoint}.", result.RemoteEndPoint);
}
}
}
// Internal so unit tests can construct an OscMessage and verify
// route dispatch reaches the right controller / TeamsControlBridge /
// NotesService call without driving the full UDP receive loop.
internal async Task DispatchAsync(OscMessage msg)
{
var addr = msg.Address;
switch (addr)
{
case "/Dragon-ISO/teams/mute": InvokeTeams(TeamsControlBridge.ToggleMute); return;
case "/Dragon-ISO/teams/camera": InvokeTeams(TeamsControlBridge.ToggleCamera); return;
case "/Dragon-ISO/teams/leave": InvokeTeams(TeamsControlBridge.LeaveCall); return;
case "/Dragon-ISO/teams/share": InvokeTeams(TeamsControlBridge.OpenShareTray); return;
case "/Dragon-ISO/teams/raise-hand":InvokeTeams(TeamsControlBridge.ToggleRaiseHand); return;
case "/Dragon-ISO/refresh-discovery":_controller.RefreshDiscovery(); return;
case "/Dragon-ISO/stop-all": await StopAllAsync(); return;
// /Dragon-ISO/recording routes removed alongside the rest of the recording surface.
case "/Dragon-ISO/notes": AppendNote(msg); return;
case "/Dragon-ISO/iso": await ToggleByNameAsync(msg); return;
case "/Dragon-ISO/iso/by-id": await ToggleByIdAsync(msg); return;
case "/Dragon-ISO/preset": await ApplyPresetAsync(msg); return;
default:
_logger?.LogDebug("Ignoring unknown OSC address {Addr}", addr);
return;
}
}
// ─── handler helpers ────────────────────────────────────────────────
private static void InvokeTeams(Func<TeamsControlBridge.InvokeResult> action) => _ = action();
private async Task StopAllAsync()
{
var vm = _viewModel();
if (vm is null) return;
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null) return;
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);
}
}
// SetRecording / DropMarker / RollRecordingAsync handlers removed alongside
// the rest of the recording surface.
private static void AppendNote(OscMessage msg)
{
var text = msg.GetStringArg(0);
if (!string.IsNullOrWhiteSpace(text)) NotesService.Append(text);
}
private async Task ToggleByNameAsync(OscMessage msg)
{
var name = msg.GetStringArg(0);
if (string.IsNullOrEmpty(name)) return;
var enabled = msg.GetBoolArg(1);
var vm = _viewModel();
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (vm is null || dispatcher is null) return;
var p = await dispatcher.InvokeAsync(() =>
vm.Participants.FirstOrDefault(x =>
string.Equals(x.DisplayName, name, StringComparison.OrdinalIgnoreCase)));
if (p is null) return;
await ApplyToggleAsync(p, enabled, dispatcher);
}
private async Task ToggleByIdAsync(OscMessage msg)
{
var idStr = msg.GetStringArg(0);
if (!Guid.TryParse(idStr, out var id)) return;
var enabled = msg.GetBoolArg(1);
var vm = _viewModel();
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (vm is null || dispatcher is null) return;
var p = await dispatcher.InvokeAsync(() =>
vm.Participants.FirstOrDefault(x => x.Id == id));
if (p is null) return;
await ApplyToggleAsync(p, enabled, dispatcher);
}
private async Task ApplyToggleAsync(ParticipantViewModel p, bool? enabled, System.Windows.Threading.Dispatcher dispatcher)
{
var target = enabled ?? !p.IsEnabled;
if (target == p.IsEnabled) return;
try
{
if (target)
{
await _controller.EnableIsoAsync(p.Id,
string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName,
CancellationToken.None);
await dispatcher.InvokeAsync(() => p.IsEnabled = true);
}
else
{
await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
await dispatcher.InvokeAsync(() => p.IsEnabled = false);
}
}
catch
{
/* defensive: OSC senders are typically fire-and-forget */
}
}
private async Task ApplyPresetAsync(OscMessage msg)
{
var name = msg.GetStringArg(0);
if (string.IsNullOrEmpty(name)) return;
var preset = OperatorPresetStore.Find(name);
if (preset is null) return;
var vm = _viewModel();
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (vm is null || dispatcher is null) return;
var snapshot = await dispatcher.InvokeAsync(() => vm.Participants.ToList());
await PresetApplier.ApplyAsync(preset, snapshot, _controller, dispatcher);
}
}
// ─── OSC message parser ─────────────────────────────────────────────────
/// <summary>
/// Minimal OSC 1.0 message parser. Supports the subset we care about:
/// integer (i), float (f), string (s) args. Bundles / time tags / blobs are
/// not implemented — incoming packets that look like bundles return null
/// and the caller logs + skips them.
/// </summary>
internal sealed class OscMessage
{
public string Address { get; init; } = "";
public string TypeTag { get; init; } = "";
public IReadOnlyList<object> Args { get; init; } = Array.Empty<object>();
/// <summary>Parse a single OSC packet. Returns null if malformed or a bundle.</summary>
public static OscMessage? TryParse(byte[] bytes)
{
if (bytes.Length < 8) return null;
// Bundle marker — we don't support bundles. Skip.
if (bytes[0] == '#') return null;
var idx = 0;
var address = ReadOscString(bytes, ref idx);
if (address is null || !address.StartsWith('/')) return null;
if (idx >= bytes.Length) return new OscMessage { Address = address };
var typeTag = ReadOscString(bytes, ref idx);
if (typeTag is null || !typeTag.StartsWith(',')) return null;
var args = new List<object>();
for (var i = 1; i < typeTag.Length; i++)
{
switch (typeTag[i])
{
case 'i':
if (idx + 4 > bytes.Length) return null;
args.Add(ReadInt32BE(bytes, idx));
idx += 4;
break;
case 'f':
if (idx + 4 > bytes.Length) return null;
args.Add(ReadFloat32BE(bytes, idx));
idx += 4;
break;
case 's':
var s = ReadOscString(bytes, ref idx);
if (s is null) return null;
args.Add(s);
break;
case 'T': args.Add(true); break;
case 'F': args.Add(false); break;
default:
// Unknown type — bail rather than mis-aligning subsequent args.
return null;
}
}
return new OscMessage { Address = address, TypeTag = typeTag, Args = args };
}
public string? GetStringArg(int idx) =>
idx < Args.Count && Args[idx] is string s ? s : null;
public bool? GetBoolArg(int idx)
{
if (idx >= Args.Count) return null;
return Args[idx] switch
{
bool b => b,
int i => i != 0,
float f => f != 0f,
string s => s.Equals("true", StringComparison.OrdinalIgnoreCase) || s == "1",
_ => null,
};
}
private static string? ReadOscString(byte[] bytes, ref int idx)
{
var start = idx;
while (idx < bytes.Length && bytes[idx] != 0) idx++;
if (idx >= bytes.Length) return null;
var s = Encoding.ASCII.GetString(bytes, start, idx - start);
// Advance past the trailing null and align to 4-byte boundary.
idx++;
var pad = (4 - (idx - start) % 4) % 4;
idx += pad;
return s;
}
private static int ReadInt32BE(byte[] bytes, int offset) =>
(bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
private static float ReadFloat32BE(byte[] bytes, int offset)
{
Span<byte> tmp = stackalloc byte[4];
tmp[0] = bytes[offset + 3];
tmp[1] = bytes[offset + 2];
tmp[2] = bytes[offset + 1];
tmp[3] = bytes[offset];
return BitConverter.ToSingle(tmp);
}
}

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,104 +0,0 @@
using System.Windows.Threading;
using DragonISO.App.ViewModels;
using DragonISO.Engine.Controller;
namespace DragonISO.App.Services;
/// <summary>
/// Shared preset-application logic. Originally lived inline in
/// <c>PresetsDialog.OnApply</c>; lifted out so the REST control surface
/// (<see cref="ControlSurfaceServer"/>) and the auto-apply-on-launch path
/// (<see cref="MainViewModel.TryAutoApplyPendingPreset"/>) can call the same
/// implementation. Single source of truth for "what does Apply mean."
///
/// Application proceeds participant-by-participant, matching by display name
/// (the only stable join key across meetings since Ids regen each session).
/// For each match, the custom output name is updated and IsEnabled is
/// reconciled with the preset's value via <see cref="IIsoController.EnableIsoAsync"/>
/// / <see cref="IIsoController.DisableIsoAsync"/>. Per-participant failures are
/// caught and counted; one bad row never aborts applying the rest.
/// </summary>
public static class PresetApplier
{
/// <summary>Result counts from an apply pass.</summary>
public sealed record ApplyResult(int Matched, int Changed, int Skipped);
/// <summary>
/// Apply <paramref name="preset"/> to the live <paramref name="participants"/>
/// list. <paramref name="dispatcher"/>, when supplied, is used to marshal
/// IsEnabled / CustomName property writes onto the UI thread; pass null in
/// contexts that already run on the UI thread (e.g. the dialog's button click).
/// </summary>
public static async Task<ApplyResult> ApplyAsync(
Services.OperatorPresetStore.Preset preset,
IReadOnlyList<ParticipantViewModel> participants,
IIsoController controller,
Dispatcher? dispatcher = null,
CancellationToken cancellationToken = default)
{
// Build the lookup once, case-insensitive — Teams display names are
// human-typed, so "Jane" and "jane" should match the same row.
var byName = preset.Assignments.ToDictionary(
a => a.DisplayName,
StringComparer.OrdinalIgnoreCase);
var matched = 0;
var changed = 0;
foreach (var p in participants)
{
cancellationToken.ThrowIfCancellationRequested();
if (!byName.TryGetValue(p.DisplayName, out var assignment)) continue;
matched++;
await SetOnUiAsync(dispatcher, () => p.CustomName = assignment.CustomOutputName ?? string.Empty);
if (assignment.Enabled && !p.IsEnabled)
{
try
{
await controller.EnableIsoAsync(
p.Id,
string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName,
cancellationToken);
await SetOnUiAsync(dispatcher, () => p.IsEnabled = true);
changed++;
}
catch
{
// Per-participant best-effort: the rest still get applied.
}
}
else if (!assignment.Enabled && p.IsEnabled)
{
try
{
await controller.DisableIsoAsync(p.Id, cancellationToken);
await SetOnUiAsync(dispatcher, () => p.IsEnabled = false);
changed++;
}
catch
{
/* defensive */
}
}
}
// Mark applied so auto-apply-on-launch picks the right preset next time.
try { Services.OperatorPresetStore.MarkApplied(preset.Name); }
catch { /* preference write is best-effort */ }
var skipped = preset.Assignments.Count - matched;
return new ApplyResult(matched, changed, skipped);
}
private static Task SetOnUiAsync(Dispatcher? dispatcher, Action action)
{
if (dispatcher is null || dispatcher.CheckAccess())
{
action();
return Task.CompletedTask;
}
return dispatcher.InvokeAsync(action).Task;
}
}

View file

@ -1,398 +0,0 @@
using System.Diagnostics;
using System.Windows.Automation;
namespace DragonISO.App.Services;
/// <summary>
/// Phase E.3 — UIAutomation bridge for the in-call controls (mute, camera,
/// leave, share screen). Walks Teams' automation tree to locate the relevant
/// buttons and invokes their <see cref="InvokePattern"/> or <see cref="TogglePattern"/>.
///
/// This is intentionally tolerant of Teams' UI volatility: we search by a
/// chain of (AutomationId, Name, LocalizedControlType) candidates rather than
/// pinning to a single identifier. When Teams ships a new build that renames a
/// button, the operator gets a clear "control not found" toast rather than a
/// crash, and we add the new identifier to the candidate list.
///
/// Limitations:
/// - Requires Teams' main window to be present (not minimized to the system tray
/// in a way that detaches its automation peers; minimized to taskbar is fine).
/// - Some Teams builds host the call UI in a separate WebView2-backed top-level
/// window; we enumerate every top-level window owned by every Teams process,
/// so we'll find it wherever it lives.
/// - Hidden windows (after <see cref="TeamsLauncher.HideWindows"/>) are still
/// traversable by UIAutomation — the buttons exist in the automation tree
/// even when their HWND is SW_HIDDEN. This is what makes the "hide Teams,
/// drive it from Dragon-ISO" workflow viable.
/// </summary>
public static class TeamsControlBridge
{
// ────────────────────────────────────────────────────────────────────
// Localized candidate-name lists.
//
// Teams localizes the AutomationElement.Name we match against. The lookup
// strategy is: ALL candidate strings across all locales are tried for each
// command, and the first match wins. This gives us a single binary that
// works regardless of the Teams UI language without needing to detect it
// — at the cost of a slightly broader match surface (a non-mute button
// with the German word "Stumm" in its name would false-positive). In
// practice Teams' button Names are highly distinctive and we haven't seen
// false positives during development.
//
// Adding a locale: append the localized strings to each command's array.
// Order doesn't matter for correctness; for performance we put the most
// common locales first since the array is iterated in order.
// ────────────────────────────────────────────────────────────────────
private static readonly string[] MuteCandidates =
{
// English (US/UK)
"Mute", "Unmute", "Microphone", "Toggle mute",
// German
"Stumm", "Stummschaltung", "Stummschalten", "Stummschaltung aufheben", "Mikrofon",
// Spanish
"Silenciar", "Activar audio", "Micrófono",
// French
"Désactiver le micro", "Activer le micro", "Micro", "Microphone",
// Portuguese
"Desativar áudio", "Ativar áudio", "Microfone",
// Japanese
"ミュート", "ミュート解除", "マイク",
};
private static readonly string[] CameraCandidates =
{
"Camera", "Turn camera on", "Turn camera off", "Video",
// German
"Kamera", "Kamera einschalten", "Kamera ausschalten", "Video",
// Spanish
"Cámara", "Activar cámara", "Desactivar cámara", "Vídeo",
// French
"Caméra", "Activer la caméra", "Désactiver la caméra", "Vidéo",
// Portuguese
"Câmera", "Ativar câmera", "Desativar câmera",
// Japanese
"カメラ", "ビデオ",
};
private static readonly string[] LeaveCandidates =
{
"Leave", "Hang up", "End call", "Leave call",
// German
"Verlassen", "Auflegen", "Anruf beenden",
// Spanish
"Salir", "Colgar", "Finalizar llamada",
// French
"Quitter", "Raccrocher", "Terminer l'appel",
// Portuguese
"Sair", "Desligar", "Encerrar chamada",
// Japanese
"退出", "通話を終了",
};
private static readonly string[] ShareCandidates =
{
"Share", "Share content", "Share screen", "Open share tray",
// German
"Teilen", "Inhalt teilen", "Bildschirm teilen",
// Spanish
"Compartir", "Compartir contenido", "Compartir pantalla",
// French
"Partager", "Partager du contenu", "Partager l'écran",
// Portuguese
"Compartilhar", "Compartilhar conteúdo", "Compartilhar tela",
// Japanese
"共有", "コンテンツの共有", "画面を共有",
};
private static readonly string[] RaiseHandCandidates =
{
"Raise", "Raise hand", "Lower hand",
// German
"Hand heben", "Hand senken",
// Spanish
"Levantar la mano", "Bajar la mano",
// French
"Lever la main", "Baisser la main",
// Portuguese
"Levantar a mão", "Abaixar a mão",
// Japanese
"手を挙げる", "手を下ろす",
};
private static readonly string[] ToggleChatCandidates =
{
"Show conversation", "Hide conversation", "Chat", "Show chat",
// German
"Unterhaltung anzeigen", "Unterhaltung ausblenden", "Chat",
// Spanish
"Mostrar conversación", "Ocultar conversación", "Chat",
// French
"Afficher la conversation", "Masquer la conversation", "Conversation",
// Portuguese
"Mostrar conversa", "Ocultar conversa", "Chat",
// Japanese
"会話を表示", "会話を非表示", "チャット",
};
private static readonly string[] BackgroundBlurCandidates =
{
"Background effects", "Apply background effects", "Background filters",
// German
"Hintergrundeffekte", "Hintergrundfilter",
// Spanish
"Efectos de fondo", "Filtros de fondo",
// French
"Effets d'arrière-plan", "Filtres d'arrière-plan",
// Portuguese
"Efeitos de plano de fundo", "Filtros de plano de fundo",
// Japanese
"背景効果", "背景フィルター",
};
/// <summary>Result of attempting one of the in-call commands.</summary>
public enum InvokeResult
{
/// <summary>The control was found and invoked successfully.</summary>
Invoked,
/// <summary>Teams isn't running, or its automation root couldn't be located.</summary>
TeamsNotRunning,
/// <summary>Teams is running but the matching button isn't currently exposed (maybe not in a call).</summary>
ControlNotFound,
/// <summary>The button was found but didn't expose a usable invoke / toggle pattern.</summary>
InvokeFailed,
}
public static InvokeResult ToggleMute() => InvokeFirstMatch(MuteCandidates);
public static InvokeResult ToggleCamera() => InvokeFirstMatch(CameraCandidates);
public static InvokeResult LeaveCall() => InvokeFirstMatch(LeaveCandidates);
public static InvokeResult OpenShareTray() => InvokeFirstMatch(ShareCandidates);
public static InvokeResult ToggleRaiseHand() => InvokeFirstMatch(RaiseHandCandidates);
public static InvokeResult ToggleChat() => InvokeFirstMatch(ToggleChatCandidates);
public static InvokeResult OpenBackgroundEffects() => InvokeFirstMatch(BackgroundBlurCandidates);
/// <summary>
/// Snapshot of the current call's local-user state. Read via a single
/// UIA traversal in <see cref="DetectCallState"/>; null sub-fields when
/// the call isn't active or the button isn't in the tree.
/// </summary>
public sealed record CallStateSnapshot(bool IsInCall, bool? IsMuted, bool? IsCameraOff);
/// <summary>
/// One-shot UIA probe of Teams' in-call controls. The Mute and Camera
/// buttons toggle their Name between "Mute"/"Unmute" and "Turn camera
/// on"/"Turn camera off" depending on state, so reading the Name tells
/// us whether the operator is currently muted / camera-off.
///
/// Returns IsInCall=false if Teams isn't running or no Leave button
/// exists. Returns IsMuted/IsCameraOff as null if those buttons aren't
/// found in this build (defensive — Teams sometimes uses different
/// candidate names across locales).
/// </summary>
public static CallStateSnapshot DetectCallState()
{
var roots = GetTeamsAutomationRoots();
if (roots.Count == 0) return new CallStateSnapshot(false, null, null);
var inCall = false;
bool? muted = null;
bool? camOff = null;
foreach (var root in roots)
{
AutomationElementCollection allButtons;
try
{
allButtons = root.FindAll(
TreeScope.Descendants,
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
}
catch { continue; }
foreach (AutomationElement btn in allButtons)
{
var name = SafeGetName(btn);
if (string.IsNullOrEmpty(name)) continue;
var lower = name.ToLowerInvariant();
if (!inCall && LeaveCandidates.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
inCall = true;
// Mute button: name is "Mute" when active-can-mute, "Unmute"
// when currently muted. Detect by checking for "unmute" first
// (more specific) before falling to "mute" (more general).
if (muted is null)
{
if (lower.Contains("unmute") || lower.Contains("stummschaltung aufheben") ||
lower.Contains("activar audio") || lower.Contains("activer le micro") ||
lower.Contains("ativar áudio") || lower.Contains("ミュート解除"))
muted = true;
else if (lower.Contains("mute") || lower.Contains("stummschalten") ||
lower.Contains("silenciar") || lower.Contains("désactiver le micro") ||
lower.Contains("desativar áudio") || lower.Contains("ミュート"))
muted = false;
}
// Camera button: name is "Turn camera off" when on, "Turn
// camera on" when off.
if (camOff is null)
{
if (lower.Contains("turn camera on") || lower.Contains("kamera einschalten") ||
lower.Contains("activar cámara") || lower.Contains("activer la caméra") ||
lower.Contains("ativar câmera"))
camOff = true;
else if (lower.Contains("turn camera off") || lower.Contains("kamera ausschalten") ||
lower.Contains("desactivar cámara") || lower.Contains("désactiver la caméra") ||
lower.Contains("desativar câmera"))
camOff = false;
}
}
}
return new CallStateSnapshot(inCall, muted, camOff);
}
/// <summary>
/// True if Teams is currently in an active call. The Leave / Hang-up
/// button only exists in the automation tree when a call is in progress,
/// so its presence is a reliable in-call signal across Teams versions.
/// Returns false if Teams isn't running, isn't in a call, or the call
/// UI is in a state we don't recognize.
///
/// This is the "tell me what Teams is doing without me having to look
/// at it" probe — operators using auto-hide Teams want a status pill
/// that says "In call · ready" without having to restore the Teams
/// window. Safe to call from any thread (UIA traversal is thread-safe);
/// not free (walks the descendant tree) so callers should poll at most
/// a few times per second.
/// </summary>
public static bool IsInCall()
{
var roots = GetTeamsAutomationRoots();
if (roots.Count == 0) return false;
foreach (var root in roots)
{
AutomationElementCollection allButtons;
try
{
allButtons = root.FindAll(
TreeScope.Descendants,
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
}
catch
{
// Window died mid-traversal; try the next root.
continue;
}
foreach (AutomationElement btn in allButtons)
{
var name = SafeGetName(btn);
if (string.IsNullOrEmpty(name)) continue;
if (LeaveCandidates.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
return true;
}
}
return false;
}
private static InvokeResult InvokeFirstMatch(IReadOnlyList<string> candidateNames)
{
var roots = GetTeamsAutomationRoots();
if (roots.Count == 0) return InvokeResult.TeamsNotRunning;
foreach (var root in roots)
{
// Search by Name first (most common case for Teams). Use a NameProperty
// contains-style match by collecting all Buttons in the subtree and then
// filtering manually — Condition only supports equality, and Teams'
// labels can include trailing state ("(unmuted)") that breaks equality.
var allButtons = root.FindAll(
TreeScope.Descendants,
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
foreach (AutomationElement btn in allButtons)
{
var name = SafeGetName(btn);
if (string.IsNullOrEmpty(name)) continue;
if (!candidateNames.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
continue;
if (TryInvoke(btn)) return InvokeResult.Invoked;
}
}
return InvokeResult.ControlNotFound;
}
/// <summary>
/// Returns the AutomationElement root for every top-level window owned by
/// any running Teams process. Multiple roots is the normal case for new
/// MSTeams (which uses one window per call/chat).
/// </summary>
private static List<AutomationElement> GetTeamsAutomationRoots()
{
var teamsPids = new HashSet<int>(
Process.GetProcessesByName("ms-teams")
.Concat(Process.GetProcessesByName("msteams"))
.Concat(Process.GetProcessesByName("Teams"))
.Select(p => { try { return p.Id; } finally { p.Dispose(); } }));
if (teamsPids.Count == 0) return new List<AutomationElement>();
// Filter the desktop's children to windows whose ProcessId matches.
var desktop = AutomationElement.RootElement;
var allWindows = desktop.FindAll(
TreeScope.Children,
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Window));
var roots = new List<AutomationElement>();
foreach (AutomationElement w in allWindows)
{
try
{
var pid = (int)w.Current.ProcessId;
if (teamsPids.Contains(pid)) roots.Add(w);
}
catch
{
// Window died between enumeration and property read; skip.
}
}
return roots;
}
private static string SafeGetName(AutomationElement el)
{
try { return el.Current.Name ?? string.Empty; }
catch { return string.Empty; }
}
/// <summary>
/// Try Invoke first (most buttons), then Toggle (mute/camera are usually
/// toggle-pattern). Returns true if either succeeded.
/// </summary>
private static bool TryInvoke(AutomationElement el)
{
try
{
if (el.TryGetCurrentPattern(InvokePattern.Pattern, out var invoke))
{
((InvokePattern)invoke).Invoke();
return true;
}
if (el.TryGetCurrentPattern(TogglePattern.Pattern, out var toggle))
{
((TogglePattern)toggle).Toggle();
return true;
}
}
catch
{
// ElementNotEnabledException, ElementNotAvailableException — Teams
// disabled the button mid-traversal (e.g. mute is disabled before
// joining a call). Treat as "found but couldn't invoke" so the
// caller can surface a useful message.
}
return false;
}
}

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,510 +0,0 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
namespace DragonISO.App.Services;
/// <summary>
/// Small Win32 wrapper that launches the Microsoft Teams desktop client as a
/// subprocess of DragonISO. First step toward Phase E.1 of the embedded-Teams
/// roadmap (see docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md):
/// the operator can launch Teams from within Dragon-ISO so they don't have to
/// switch apps to start a meeting.
///
/// The launcher tries (in order):
/// 1. ms-teams: URI (works for both classic and new Teams)
/// 2. MSTeams.exe in %LOCALAPPDATA%\Microsoft\WindowsApps\
/// 3. Teams.exe in %LOCALAPPDATA%\Microsoft\Teams\Update.exe (legacy)
///
/// Group-routing automation (writing NDI Access Manager config so Teams
/// broadcasts on a private group) is deferred to a follow-up — for v1.0 we
/// document the manual steps in RELEASING.md and trust the operator to set
/// them once per machine.
/// </summary>
public static class TeamsLauncher
{
/// <summary>
/// Heuristic process-name candidates we'll consider as "the Teams process" when
/// the rail toggle wants to find a running instance. New MSTeams comes first.
/// </summary>
private static readonly string[] TeamsProcessNames =
{
"ms-teams", // new MSTeams binary basename
"msteams", // alternate basename observed on some installs
"Teams", // classic Teams desktop client
};
/// <summary>
/// True if any process matching the known Teams binary basenames is running.
/// Used by the rail to decide whether to show "Launch Teams" vs "Stop Teams".
/// </summary>
public static bool IsRunning() =>
TeamsProcessNames.Any(n => Process.GetProcessesByName(n).Length > 0);
/// <summary>
/// Launches Teams. Returns true if a launch was started successfully (the
/// process may take a few seconds to actually appear). False if every
/// fallback path failed; <paramref name="errorMessage"/> includes the
/// reasons each attempt was rejected so the operator can see why.
///
/// Path order matters:
/// 1. <c>ms-teams:</c> URI — new Teams (MSTeams AppX) registers this
/// handler at install. Activates through the AppX shell so the
/// stub <c>ms-teams.exe</c> in WindowsApps gets the right context.
/// 2. AppsFolder shell verb — direct AppX activation. Belt-and-braces
/// fallback if a misconfigured registry breaks the URI handler.
/// 3. Classic Teams Update.exe — pre-2024 Teams installations.
/// We deliberately DON'T try the bare <c>ms-teams.exe</c> WindowsApps
/// path: it's a 0-byte AppX placeholder that fails silently when invoked
/// without AppX activation context. Looked plausible, never worked.
/// </summary>
public static bool TryLaunch(out string? errorMessage)
{
errorMessage = null;
var attempts = new List<string>();
// Path 1: URI scheme. The shell handler picks the registered Teams
// (new MSTeams takes priority on modern Windows). UseShellExecute=true
// is required — Win32 Process creation can't open URIs directly.
if (TryStart("ms-teams:", useShell: true, out var err1)) return true;
attempts.Add($"ms-teams: URI → {err1}");
// Path 2: AppX activation via the explorer.exe shell. Modern Teams
// ships as MSTeams_8wekyb3d8bbwe; if other code on the box has
// clobbered the URI registration, this still works because it goes
// through the AppsFolder verb the OS itself uses for Start menu launches.
if (TryStart("explorer.exe", false, out var err2,
arguments: "shell:appsFolder\\MSTeams_8wekyb3d8bbwe!MSTeams"))
return true;
attempts.Add($"AppsFolder shell → {err2}");
// Path 3: classic Teams Update.exe with --processStart hands off to
// the actual Teams.exe via Squirrel.
var classicUpdater = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft", "Teams", "Update.exe");
if (File.Exists(classicUpdater))
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = classicUpdater,
Arguments = "--processStart \"Teams.exe\"",
UseShellExecute = false,
CreateNoWindow = true,
});
return true;
}
catch (Exception ex)
{
attempts.Add($"classic Update.exe → {ex.Message}");
}
}
else
{
attempts.Add($"classic Update.exe → not found at {classicUpdater}");
}
errorMessage = "No Microsoft Teams installation could be launched. " +
"Install Teams from https://www.microsoft.com/microsoft-teams and try again.\n\n" +
"Attempts:\n • " + string.Join("\n • ", attempts);
return false;
}
/// <summary>
/// Asks every running Teams process to close gracefully via WM_CLOSE
/// (CloseMainWindow). Returns the count of processes that exited cleanly within
/// <paramref name="gracePeriod"/>. Stragglers are NOT force-killed — Teams' own
/// "are you sure" prompt may legitimately keep a process alive briefly, and we
/// don't want to nuke the user's call mid-transition.
/// </summary>
public static int StopAll(TimeSpan? gracePeriod = null)
{
var grace = gracePeriod ?? TimeSpan.FromSeconds(3);
var deadline = DateTime.UtcNow + grace;
var asked = 0;
foreach (var name in TeamsProcessNames)
{
foreach (var p in Process.GetProcessesByName(name))
{
try
{
if (p.HasExited) { p.Dispose(); continue; }
if (p.MainWindowHandle != IntPtr.Zero)
{
p.CloseMainWindow();
asked++;
}
}
catch { /* defensive: process may have died between enumeration and signal */ }
finally { p.Dispose(); }
}
}
// Best-effort wait so the rail can flip its icon promptly.
while (DateTime.UtcNow < deadline && IsRunning())
Thread.Sleep(150);
return asked;
}
/// <summary>
/// Hand a meeting URL off to the Teams shell handler. Accepts both the
/// <c>https://teams.microsoft.com/l/meetup-join/...</c> web format and
/// the <c>msteams:/l/meetup-join/...</c> deep-link form (either causes
/// Teams to launch + join the meeting in one shot — the OS shell maps
/// teams.microsoft.com URLs to the registered ms-teams: handler).
///
/// Use case: operator pastes a meeting link they got over email / chat
/// into Dragon-ISO's quick-join field instead of opening Teams,
/// hunting down the calendar entry, and clicking Join. With auto-hide
/// on, the Teams window flashes briefly then disappears; the operator
/// is now in the meeting, driving routing from DragonISO.
///
/// Returns true if the shell accepted the URL; false if URL is malformed
/// or rejected. errorMessage populated on failure.
/// </summary>
public static bool TryJoinMeeting(string url, out string? errorMessage)
{
errorMessage = null;
if (string.IsNullOrWhiteSpace(url))
{
errorMessage = "URL is empty.";
return false;
}
var trimmed = url.Trim();
// Defensive sanity-check: only accept URLs that obviously target
// Teams. We don't want to invoke arbitrary shell handlers from a
// clipboard paste — if someone pastes "calc.exe" into the input we
// shouldn't launch it. Specifically: http(s) URLs must contain
// "teams.microsoft.com" or "teams.live.com"; otherwise must start
// with "msteams:".
var lower = trimmed.ToLowerInvariant();
var looksLikeTeams =
lower.StartsWith("msteams:") ||
(lower.StartsWith("http://") || lower.StartsWith("https://")) &&
(lower.Contains("teams.microsoft.com") || lower.Contains("teams.live.com"));
if (!looksLikeTeams)
{
errorMessage = "Not a Microsoft Teams meeting URL. " +
"Expected a https://teams.microsoft.com/l/meetup-join/... " +
"or msteams:/l/meetup-join/... link.";
return false;
}
if (TryStart(trimmed, useShell: true, out var err))
return true;
errorMessage = err;
return false;
}
private static bool TryStart(string target, bool useShell, out string error, string? arguments = null)
{
error = string.Empty;
try
{
var info = new ProcessStartInfo
{
FileName = target,
UseShellExecute = useShell,
CreateNoWindow = true,
};
if (arguments is not null) info.Arguments = arguments;
Process.Start(info);
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
// ════════════════════════════════════════════════════════════════════════
// Phase E.2 — window orchestration
//
// Once Teams is running, we want to be able to hide its main window so the
// operator only sees DragonISO. We do this by enumerating top-level windows,
// matching against each Teams process Id, and ShowWindow(SW_HIDE)-ing each
// match. To restore we ShowWindow(SW_SHOW) and SetForegroundWindow.
//
// We deliberately don't use the Process.MainWindowHandle convenience because
// new MSTeams (WebView2-hosted) creates several top-level windows per
// process and Process picks an inconsistent one across launches; iterating
// via EnumWindows + GetWindowThreadProcessId catches every visible window
// owned by the process.
// ════════════════════════════════════════════════════════════════════════
private const int SW_HIDE = 0;
private const int SW_SHOWNORMAL = 1;
private const int SW_SHOW = 5;
private const int SW_RESTORE = 9;
private const uint GW_OWNER = 4;
[DllImport("user32.dll")]
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll", SetLastError = true)]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int GetWindowTextW(IntPtr hWnd, [Out] System.Text.StringBuilder lpString, int nMaxCount);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int GetWindowTextLengthW(IntPtr hWnd);
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
/// <summary>
/// Returns the title bar text of Teams' most-recently-used top-level
/// window, or empty string if Teams isn't running. Modern Teams puts
/// the meeting title in the window title while in a call ("Meeting with
/// Alice | Microsoft Teams"), so this is the cheapest way to surface
/// meeting context to Dragon-ISO's UI without burning a UIA traversal.
///
/// Includes hidden windows — operators using auto-hide still get the
/// title surfaced, which is the whole point.
/// </summary>
public static string GetActiveWindowTitle()
{
try
{
var teamsPids = new HashSet<uint>(
TeamsProcessNames
.SelectMany(n => Process.GetProcessesByName(n))
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
if (teamsPids.Count == 0) return string.Empty;
string longestTitle = string.Empty;
EnumWindows((hWnd, _) =>
{
if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true;
GetWindowThreadProcessId(hWnd, out var pid);
if (!teamsPids.Contains(pid)) return true;
var len = GetWindowTextLengthW(hWnd);
if (len <= 0) return true;
var sb = new System.Text.StringBuilder(len + 1);
GetWindowTextW(hWnd, sb, sb.Capacity);
var title = sb.ToString();
// Teams creates a few top-level windows per process; the
// call/meeting window has the longest title (other windows
// tend to just be "Microsoft Teams"). Pick the longest one
// as a heuristic for "most informative".
if (title.Length > longestTitle.Length) longestTitle = title;
return true;
}, IntPtr.Zero);
return longestTitle;
}
catch { return string.Empty; }
}
/// <summary>
/// Enumerate every visible top-level window owned by any running Teams
/// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is
/// not a tooltip or popup of another). Used by Hide/Show.
/// </summary>
/// <summary>
/// Return the visible top-level windows owned by any Teams process.
/// Exposed internal so <see cref="TeamsEmbedHost"/> can pick the
/// "best" candidate to reparent without re-implementing the
/// enumeration. Keep this in TeamsLauncher because the launch /
/// hide / show paths use the same list.
/// </summary>
internal static List<IntPtr> EnumerateTopLevelTeamsWindows()
{
var teamsPids = new HashSet<uint>(
TeamsProcessNames
.SelectMany(n => Process.GetProcessesByName(n))
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
if (teamsPids.Count == 0) return new List<IntPtr>();
var windows = new List<IntPtr>();
EnumWindows((hWnd, _) =>
{
if (!IsWindowVisible(hWnd)) return true;
if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true; // not top-level
GetWindowThreadProcessId(hWnd, out var pid);
if (teamsPids.Contains(pid)) windows.Add(hWnd);
return true;
}, IntPtr.Zero);
return windows;
}
/// <summary>
/// Hides every visible top-level Teams window. Returns the count hidden;
/// 0 means Teams isn't running or has no visible windows yet (it can take
/// a couple seconds after launch for the splash to materialize).
/// </summary>
public static int HideWindows()
{
var windows = EnumerateTopLevelTeamsWindows();
foreach (var w in windows) ShowWindow(w, SW_HIDE);
return windows.Count;
}
/// <summary>
/// Fire-and-forget background watcher that polls every 250ms for up to
/// <paramref name="timeout"/> and hides any visible top-level Teams
/// windows it finds. Used after launch so the operator never sees the
/// Teams UI flash on screen — Teams takes 2-5s to splash + render its
/// main window, and the splash arrives separately from the main window
/// (so we keep polling past the first hide to catch follow-up windows).
///
/// Returns the Task so callers can await completion if they want, but
/// production code should fire-and-forget. Exceptions are swallowed —
/// failure to hide is harmless (user just sees Teams briefly).
/// </summary>
public static Task AutoHideAfterLaunchAsync(TimeSpan? timeout = null, CancellationToken ct = default)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(15));
return Task.Run(async () =>
{
try
{
var hiddenAny = false;
while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline)
{
// Poll for visible windows. Each iteration may catch new
// ones — Teams sometimes opens a small splash, then a
// larger main window 1-2s later, then a "What's new"
// banner. Keep hiding until we've gone a full second
// with nothing new appearing.
var hidden = HideWindows();
if (hidden > 0)
{
hiddenAny = true;
// Settling delay: after we hide windows, wait a beat
// before polling again so we don't busy-loop while
// Teams' window manager catches up.
await Task.Delay(750, ct).ConfigureAwait(false);
}
else if (hiddenAny)
{
// We hid at least once; if the next poll finds
// nothing, Teams has settled. Bail early.
return;
}
else
{
// Teams hasn't materialized yet; keep waiting.
await Task.Delay(250, ct).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException) { /* expected on cancel */ }
catch { /* defensive — auto-hide is best-effort, never breaks the app */ }
}, ct);
}
// ────────────────────────────────────────────────────────────────────
// Keyboard-shortcut forwarding (PostMessage path).
//
// UIAutomation (TeamsControlBridge) is our preferred way to drive Teams
// because it works regardless of foreground/visibility state. PostMessage
// is a fallback for shortcuts that don't have a stable UIA-discoverable
// button — chat scroll, custom keymap actions, etc. Note: WebView2-hosted
// Teams (the modern client) frequently ignores PostMessage(WM_KEYDOWN) at
// its app-shortcut layer because shortcut routing happens after focus
// changes, not on raw key messages. Treat this as best-effort.
// ────────────────────────────────────────────────────────────────────
private const uint WM_KEYDOWN = 0x0100;
private const uint WM_KEYUP = 0x0101;
private const uint WM_CHAR = 0x0102;
private const uint WM_SYSKEYDOWN = 0x0104;
private const uint WM_SYSKEYUP = 0x0105;
[Flags]
public enum ShortcutModifiers
{
None = 0,
Ctrl = 1,
Shift = 2,
Alt = 4,
}
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
/// <summary>
/// Sends a synthesized key press (modifier-down, key-down, key-up,
/// modifier-up) to the most recently used top-level Teams window via
/// PostMessage. Returns true if a window was found to send to. Note that
/// returning true doesn't guarantee Teams reacted — modern WebView2-based
/// Teams sometimes ignores synthesized key messages at the app-shortcut
/// layer. Prefer UIA (<see cref="TeamsControlBridge"/>) when an equivalent
/// button exists.
/// </summary>
public static bool SendShortcut(ShortcutModifiers modifiers, int virtualKey)
{
var windows = EnumerateTopLevelTeamsWindows();
if (windows.Count == 0) return false;
var hwnd = windows[^1];
// Modifier key downs
if ((modifiers & ShortcutModifiers.Ctrl) != 0)
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)0x11, IntPtr.Zero); // VK_CONTROL
if ((modifiers & ShortcutModifiers.Shift) != 0)
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)0x10, IntPtr.Zero); // VK_SHIFT
if ((modifiers & ShortcutModifiers.Alt) != 0)
PostMessage(hwnd, WM_SYSKEYDOWN, (IntPtr)0x12, IntPtr.Zero); // VK_MENU
// Main key down + up
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)virtualKey, IntPtr.Zero);
PostMessage(hwnd, WM_KEYUP, (IntPtr)virtualKey, IntPtr.Zero);
// Modifier key ups (reverse order)
if ((modifiers & ShortcutModifiers.Alt) != 0)
PostMessage(hwnd, WM_SYSKEYUP, (IntPtr)0x12, IntPtr.Zero);
if ((modifiers & ShortcutModifiers.Shift) != 0)
PostMessage(hwnd, WM_KEYUP, (IntPtr)0x10, IntPtr.Zero);
if ((modifiers & ShortcutModifiers.Ctrl) != 0)
PostMessage(hwnd, WM_KEYUP, (IntPtr)0x11, IntPtr.Zero);
return true;
}
/// <summary>
/// Restores every Teams top-level window from hidden state and brings the
/// most recently used one to the foreground. Returns the count shown.
/// </summary>
public static int ShowWindows()
{
// To find hidden windows too we still enumerate, but our IsWindowVisible
// filter would skip them. Re-implement here with the visible check off.
var teamsPids = new HashSet<uint>(
TeamsProcessNames
.SelectMany(n => Process.GetProcessesByName(n))
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
var windows = new List<IntPtr>();
EnumWindows((hWnd, _) =>
{
if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true;
GetWindowThreadProcessId(hWnd, out var pid);
if (teamsPids.Contains(pid)) windows.Add(hWnd);
return true;
}, IntPtr.Zero);
foreach (var w in windows) ShowWindow(w, SW_SHOW);
if (windows.Count > 0) SetForegroundWindow(windows[^1]);
return windows.Count;
}
}

View file

@ -1,245 +0,0 @@
using System;
using System.Linq;
using System.Windows;
using Microsoft.Win32;
namespace DragonISO.App.Services;
/// <summary>
/// Owns the active theme for the WPF host. Three preferences:
/// <list type="bullet">
/// <item><c>System</c> — follows the Windows app-mode setting (default for new
/// users; re-reads on <see cref="SystemEvents.UserPreferenceChanged"/>).</item>
/// <item><c>Dark</c> — pin dark regardless of OS.</item>
/// <item><c>Light</c> — pin light regardless of OS.</item>
/// </list>
/// The two color files (<c>Theme.Dark.xaml</c> + <c>Theme.Light.xaml</c>) are
/// kept in lockstep on the same set of brush keys; this manager swaps the
/// MergedDictionaries entry at runtime. Styles + control templates in
/// <c>WildDragonTheme.xaml</c> reach the brushes via <see langword="DynamicResource"/>,
/// so the visual tree re-resolves without an app restart.
///
/// Preference is persisted via <see cref="UIPreferences"/>'s <c>Theme</c> field,
/// reserved on disk during the v1 → v2 rollout so the rebuild doesn't lose the
/// operator's choice.
/// </summary>
public sealed class ThemeManager
{
public static ThemeManager Current { get; } = new(
isSystemDark: ReadSystemDarkFromRegistry,
loadPreference: TryLoadPreferenceFromDisk,
savePreference: TrySavePreferenceToDisk,
subscribeToSystemPreference: true);
// Pack URIs (rather than relative "/Themes/…") so the resolution
// works equally well from production (where Application.Current's
// base URI is the Dragon-ISO entry assembly) and from xUnit tests
// (where it's the test assembly — relative URIs would miss).
private const string DarkUri = "pack://application:,,,/DragonISO;component/Themes/Theme.Dark.xaml";
private const string LightUri = "pack://application:,,,/DragonISO;component/Themes/Theme.Light.xaml";
private const string PreferenceKeySystem = "System";
private const string PreferenceKeyDark = "Dark";
private const string PreferenceKeyLight = "Light";
// Test seams. The production singleton wires these to the real
// registry / UIPreferences. Tests construct via the internal ctor
// with their own stubs so they don't touch HKCU or %LOCALAPPDATA%.
private readonly Func<bool> _isSystemDark;
private readonly Action<string> _savePreference;
internal ThemeManager(
Func<bool> isSystemDark,
Func<string?> loadPreference,
Action<string> savePreference,
bool subscribeToSystemPreference)
{
_isSystemDark = isSystemDark;
_savePreference = savePreference;
// Hydrate preference from the seam on first access. Disk / load
// failures fall back to defaults so the app always boots into a
// deterministic theme.
try
{
var loaded = loadPreference();
if (IsValidPreference(loaded))
{
_preference = loaded!;
}
}
catch
{
// Defensive — ctor must not throw or the app loses theming.
}
// Re-evaluate when Windows app-mode flips, but only when the
// operator hasn't pinned a preference. The explicit choice wins.
// Tests opt out so they don't latch into a process-wide event.
if (subscribeToSystemPreference)
{
SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged;
}
}
private string _preference = PreferenceKeySystem;
/// <summary>Current preference. One of "System", "Dark", "Light".</summary>
public string Preference => _preference;
/// <summary>Fires after a theme swap with the resolved (absolute) theme.</summary>
public event EventHandler<string>? Themed;
/// <summary>
/// Resolve the preference to an absolute theme name ("Dark" or "Light")
/// suitable for the dictionary lookup. "System" resolves to the OS
/// app-mode at the time of the call.
/// </summary>
public string ResolveTheme() => _preference switch
{
PreferenceKeyDark => PreferenceKeyDark,
PreferenceKeyLight => PreferenceKeyLight,
_ => _isSystemDark() ? PreferenceKeyDark : PreferenceKeyLight,
};
/// <summary>
/// Set the operator's preference, persist, and apply the resolved theme.
/// </summary>
public void Set(string preference)
{
if (!IsValidPreference(preference))
{
throw new ArgumentException(
"Preference must be 'System', 'Dark', or 'Light'.",
nameof(preference));
}
_preference = preference;
try { _savePreference(preference); }
catch { /* persistence is best-effort */ }
Apply();
}
/// <summary>
/// Cycle the theme between Dark and Light (one-click toggle from the header
/// theme icon). If the current preference is "System", the cycle pins to
/// the OPPOSITE of the currently-resolved theme so the click has a
/// visible effect.
/// </summary>
public void Toggle()
{
var current = ResolveTheme();
Set(current == PreferenceKeyDark ? PreferenceKeyLight : PreferenceKeyDark);
}
/// <summary>
/// Apply the current resolved theme. Should be called once during app
/// startup (after Application.Current.Resources is initialized) and
/// whenever <see cref="Preference"/> changes — <see cref="Set"/> already
/// does the latter for you.
/// </summary>
public void Apply()
{
var theme = ResolveTheme();
var uri = theme == PreferenceKeyDark ? DarkUri : LightUri;
SwapColorDictionary(uri);
Themed?.Invoke(this, theme);
}
private static void SwapColorDictionary(string newUri)
{
var app = Application.Current;
if (app is null) return;
var dicts = app.Resources.MergedDictionaries;
// Find the existing theme color dictionary by source URI. We
// distinguish "color" dictionaries from "WildDragonTheme" by name —
// the color files are at Theme.Dark.xaml / Theme.Light.xaml; the
// styles file is at WildDragonTheme.xaml. Replace in place to
// preserve merge order so DynamicResource refs resolve to the new
// brushes.
ResourceDictionary? old = null;
for (var i = 0; i < dicts.Count; i++)
{
var src = dicts[i].Source?.OriginalString ?? string.Empty;
if (src.EndsWith("Theme.Dark.xaml", StringComparison.OrdinalIgnoreCase) ||
src.EndsWith("Theme.Light.xaml", StringComparison.OrdinalIgnoreCase))
{
old = dicts[i];
break;
}
}
var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Absolute) };
if (old is null)
{
dicts.Insert(0, fresh);
}
else
{
var idx = dicts.IndexOf(old);
dicts.RemoveAt(idx);
dicts.Insert(idx, fresh);
}
}
/// <summary>
/// Read Windows' AppsUseLightTheme registry value. 1 = light, 0 = dark.
/// Returns true (dark) on any read failure — the dark scene is the
/// default per DESIGN.md so a missing value still lands somewhere
/// sensible. Backs the singleton's _isSystemDark seam.
/// </summary>
private static bool ReadSystemDarkFromRegistry()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize");
if (key?.GetValue("AppsUseLightTheme") is int value)
{
return value == 0;
}
}
catch
{
// Registry access can fail under unusual security contexts.
}
return true;
}
/// <summary>
/// Load the operator's persisted theme preference from
/// %LOCALAPPDATA%\Dragon-ISO\ui-prefs.json. Returns null on any read
/// failure (missing file, corrupt JSON, schema mismatch) so the
/// caller falls back to the in-memory default of "System". Backs
/// the singleton's loadPreference seam.
/// </summary>
private static string? TryLoadPreferenceFromDisk()
{
try { return UIPreferences.Load().Theme; }
catch { return null; }
}
/// <summary>
/// Persist the operator's theme preference to ui-prefs.json. Errors
/// are swallowed — persistence is best-effort and a single failed
/// save shouldn't break the in-session UI experience. Backs the
/// singleton's savePreference seam.
/// </summary>
private static void TrySavePreferenceToDisk(string preference)
{
try { UIPreferences.SetTheme(preference); }
catch { /* best-effort */ }
}
private void OnSystemPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
{
if (e.Category != UserPreferenceCategory.General) return;
if (_preference != PreferenceKeySystem) return;
// Marshal to the UI thread — registry events fire on a system pool
// thread and resource dictionary mutations require dispatcher access.
Application.Current?.Dispatcher.BeginInvoke(new Action(Apply));
}
private static bool IsValidPreference(string? value) =>
value is PreferenceKeySystem or PreferenceKeyDark or PreferenceKeyLight;
}

View file

@ -1,140 +0,0 @@
using System.Drawing;
using System.IO;
using System.Reflection;
using System.Runtime.Versioning;
using System.Windows;
using WinForms = System.Windows.Forms;
namespace DragonISO.App.Services;
/// <summary>
/// Wraps a WinForms <see cref="WinForms.NotifyIcon"/> so the WPF host can
/// minimize-to-tray during long shows. Operators with a Stream Deck setup
/// often want Dragon-ISO running but invisible — the tray icon keeps the
/// process alive (and the engine routing live) while the window stays
/// hidden.
///
/// Lifecycle pattern: instantiate from <c>App.OnStartup</c> after the main
/// window exists; dispose from <c>App.OnExit</c>. The host hooks the main
/// window's <c>StateChanged</c> to detect minimize and toggles
/// <c>WindowState.Minimized</c> + <c>ShowInTaskbar=false</c> + <c>Hide()</c>.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class TrayIconHost : IDisposable
{
private readonly Window _mainWindow;
private readonly WinForms.NotifyIcon _notifyIcon;
private bool _enabled;
public TrayIconHost(Window mainWindow)
{
_mainWindow = mainWindow;
_notifyIcon = new WinForms.NotifyIcon
{
Text = "Dragon-ISO",
Icon = LoadEmbeddedIcon(),
Visible = false,
};
_notifyIcon.DoubleClick += (_, _) => RestoreFromTray();
_notifyIcon.ContextMenuStrip = BuildMenu();
}
/// <summary>
/// Toggles the minimize-to-tray behavior. When on, minimizing the window
/// hides it and shows a tray icon; when off, minimize is normal Windows
/// behavior. Read by the operator's checkbox in DISPLAY settings; the
/// setting persists via <see cref="UIPreferences"/>.
/// </summary>
public bool Enabled
{
get => _enabled;
set
{
if (_enabled == value) return;
_enabled = value;
if (value)
{
_mainWindow.StateChanged += OnMainWindowStateChanged;
}
else
{
_mainWindow.StateChanged -= OnMainWindowStateChanged;
// If we're currently minimized + hidden, restore so the user
// doesn't lose the window when they disable the setting.
RestoreFromTray();
_notifyIcon.Visible = false;
}
}
}
private void OnMainWindowStateChanged(object? sender, EventArgs e)
{
if (_mainWindow.WindowState != WindowState.Minimized) return;
// Hide from taskbar + hide the window, show the tray icon.
_mainWindow.ShowInTaskbar = false;
_mainWindow.Hide();
_notifyIcon.Visible = true;
_notifyIcon.ShowBalloonTip(
timeout: 1500,
tipTitle: "Dragon-ISO is still running",
tipText: "Engine + control surface are live. Double-click the tray icon to restore the window.",
tipIcon: WinForms.ToolTipIcon.Info);
}
private void RestoreFromTray()
{
_mainWindow.Show();
_mainWindow.WindowState = WindowState.Normal;
_mainWindow.ShowInTaskbar = true;
_mainWindow.Activate();
_notifyIcon.Visible = false;
}
private WinForms.ContextMenuStrip BuildMenu()
{
var menu = new WinForms.ContextMenuStrip();
menu.Items.Add("Show Dragon-ISO", null, (_, _) => RestoreFromTray());
menu.Items.Add("-");
menu.Items.Add("Stop all ISOs", null, (_, _) =>
{
// Reach into the VM via the main window. Using string-keyed
// command lookup would be more decoupled but adds overhead.
if (_mainWindow.DataContext is ViewModels.MainViewModel vm
&& vm.StopAllIsosCommand.CanExecute(null))
{
vm.StopAllIsosCommand.Execute(null);
}
});
menu.Items.Add("-");
menu.Items.Add("Exit Dragon-ISO", null, (_, _) => System.Windows.Application.Current.Shutdown());
return menu;
}
/// <summary>
/// Load the bundled DragonISO.ico from this assembly's resources. We use
/// the embedded resource rather than the file-system path because the
/// app may be run from any CWD (via the MSI install or a developer dotnet run).
/// </summary>
private static Icon LoadEmbeddedIcon()
{
try
{
var asm = Assembly.GetExecutingAssembly();
var uri = new Uri("pack://application:,,,/Assets/DragonISO.ico");
using var stream = System.Windows.Application.GetResourceStream(uri)?.Stream;
if (stream is not null) return new Icon(stream);
}
catch
{
// Fall through to the OS default
}
return SystemIcons.Application;
}
public void Dispose()
{
try { _mainWindow.StateChanged -= OnMainWindowStateChanged; } catch { /* ignore */ }
try { _notifyIcon.Visible = false; } catch { /* ignore */ }
try { _notifyIcon.Dispose(); } catch { /* ignore */ }
}
}

View file

@ -1,106 +0,0 @@
using System.IO;
using System.Text.Json;
namespace DragonISO.App.Services;
/// <summary>
/// Persistent UI-side toggles that don't belong in <see cref="DragonISO.Engine.Persistence.ConfigStore"/>
/// (which is the engine's domain model — framerate, NDI groups, ISO assignments).
///
/// Each toggle is a property on a single record persisted as JSON at
/// <c>%LOCALAPPDATA%\Dragon-ISO\ui-prefs.json</c>. Defaults match the original
/// in-memory behavior: HideLocalSelf=true (filter the operator's own preview
/// out of the participants list) and AutoDisableOnDeparture=false (a participant
/// going offline doesn't tear down their pipeline by default — operators
/// usually want to keep the routing in case they reconnect).
///
/// Centralizing these here means the settings VM doesn't have to plumb
/// individual Set methods to dedicated services for every new bool.
/// </summary>
public static class UIPreferences
{
private static readonly object _gate = new();
private static string PrefsPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO", "ui-prefs.json");
/// <summary>
/// Sort modes for the participants DataGrid. <see cref="JoinOrder"/> is the default
/// and matches the engine's discovery order (operators with custom Stream Deck
/// layouts sometimes prefer Alphabetical for stability across meetings).
/// <see cref="LoudestFirst"/> resorts at the 1Hz stats tick so the active
/// speaker bubbles to the top — useful for operators reacting to who's talking.
/// </summary>
public enum SortMode { JoinOrder, Alphabetical, OnlineFirst, LoudestFirst }
/// <summary>The on-disk shape. New fields added here become opt-in for older files via default values.</summary>
public sealed record Prefs(
bool HideLocalSelf = true,
bool AutoDisableOnDeparture = false,
SortMode ParticipantSort = SortMode.JoinOrder,
bool MinimizeToTray = false,
bool ControlSurfaceLanReachable = false,
// Phase E.1 / E.2 quality-of-life. With both true, the operator launches
// Dragon-ISO and never sees the Teams UI — Teams auto-starts in the
// background and its windows are auto-hidden as soon as they materialize.
// All control happens via the IN-CALL bar + participants DataGrid.
bool LaunchTeamsOnStartup = false,
bool AutoHideTeamsWindows = false,
// Experimental Phase E.4. SetParent-reparents Teams' main window
// into a Dragon-ISO-owned host. WebView2 in modern Teams can render
// weirdly after reparent; if so the operator unticks and falls
// back to auto-hide mode. Off by default.
bool EmbedTeamsWindow = false,
// Theme preference for the v2 redesign. One of "System" (follow
// Windows app-mode), "Dark", or "Light". ThemeManager hydrates
// from this on startup and persists back here on toggle. Default
// "System" matches DESIGN.md's "Follow Windows" choice — the
// operator who doesn't care gets whatever Windows is set to.
string Theme = "System",
// REST + WebSocket control surface auto-start. When true, the
// server starts on app launch instead of waiting for the operator
// to click the toggle in settings each session. The desktop GUI's
// settings checkbox writes here and re-reads on launch.
bool ControlSurfaceEnabled = false);
/// <summary>Update just the Theme field without touching other prefs.</summary>
public static void SetTheme(string theme)
{
var current = Load();
Save(current with { Theme = theme });
}
public static Prefs Load()
{
try
{
if (!File.Exists(PrefsPath)) return new Prefs();
var json = File.ReadAllText(PrefsPath);
return JsonSerializer.Deserialize<Prefs>(json) ?? new Prefs();
}
catch
{
return new Prefs();
}
}
public static void Save(Prefs prefs)
{
try
{
lock (_gate)
{
var dir = Path.GetDirectoryName(PrefsPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var json = JsonSerializer.Serialize(prefs, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(PrefsPath, json);
}
}
catch
{
// Disk full / permission denied — in-memory state still holds for this session.
}
}
}

View file

@ -1,243 +0,0 @@
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Reflection;
using System.Text.Json;
namespace DragonISO.App.Services;
/// <summary>
/// Asks Forgejo's REST API whether a newer release tag exists than the one
/// we're running. Manual-only for v1 — there's no background polling. The
/// operator can click "Check for updates" in the About dialog whenever they
/// want, and a positive result opens the release page in their browser
/// (rather than auto-downloading; we don't want a long-running show
/// interrupted by a surprise installer).
///
/// We use the public release endpoint so no auth is needed:
/// GET /api/v1/repos/zgaetano/Dragon-ISO/releases?limit=1
///
/// On any error (offline, DNS failure, repo private, malformed response),
/// the caller gets <see cref="UpdateCheckResult.Failed"/> with a short
/// human-readable message rather than an exception.
/// </summary>
public static class UpdateChecker
{
private const string ReleasesApi =
"https://forge.wilddragon.net/api/v1/repos/zgaetano/Dragon-ISO/releases?limit=1";
private const string ReleasesPage =
"https://forge.wilddragon.net/zgaetano/Dragon-ISO/releases";
/// <summary>Outcome of a single check.</summary>
public sealed record UpdateCheckResult(
UpdateStatus Status,
string? LatestTag,
string? CurrentVersion,
string? Message)
{
public static UpdateCheckResult Failed(string message) =>
new(UpdateStatus.Error, null, null, message);
}
public enum UpdateStatus
{
UpToDate,
UpdateAvailable,
Error,
}
public static async Task<UpdateCheckResult> CheckAsync(CancellationToken ct = default)
{
var current = GetCurrentVersion();
try
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
client.DefaultRequestHeaders.Add("User-Agent", "Dragon-ISO/" + current);
using var res = await client.GetAsync(ReleasesApi, ct);
if (!res.IsSuccessStatusCode)
return UpdateCheckResult.Failed($"Server returned {(int)res.StatusCode}.");
var json = await res.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array || doc.RootElement.GetArrayLength() == 0)
return new UpdateCheckResult(UpdateStatus.UpToDate, null, current,
"No releases published yet.");
var first = doc.RootElement[0];
if (!first.TryGetProperty("tag_name", out var tagProp))
return UpdateCheckResult.Failed("Release record missing tag_name.");
var latestTag = tagProp.GetString();
if (string.IsNullOrWhiteSpace(latestTag))
return UpdateCheckResult.Failed("Latest tag was empty.");
var latestVersion = TryParseSemVer(latestTag);
var currentVersion = TryParseSemVer("v" + current);
if (latestVersion is null || currentVersion is null)
return new UpdateCheckResult(UpdateStatus.UpToDate, latestTag, current,
"Couldn't compare versions; latest tag is " + latestTag);
return latestVersion > currentVersion
? new UpdateCheckResult(UpdateStatus.UpdateAvailable, latestTag, current,
$"A newer version ({latestTag}) is available.")
: new UpdateCheckResult(UpdateStatus.UpToDate, latestTag, current,
"You're on the latest release.");
}
catch (TaskCanceledException)
{
return UpdateCheckResult.Failed("Update check timed out.");
}
catch (HttpRequestException ex)
{
return UpdateCheckResult.Failed("Couldn't reach the update server: " + ex.Message);
}
catch (Exception ex)
{
return UpdateCheckResult.Failed("Unexpected error: " + ex.Message);
}
}
/// <summary>
/// Open the releases page in the user's default browser. Used by the
/// "Update available" dialog button — we deliberately don't download/run
/// the MSI ourselves, so the operator decides when to install.
/// </summary>
public static void OpenReleasesPage()
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = ReleasesPage,
UseShellExecute = true,
});
}
catch
{
// best-effort; the dialog already shows the URL as text fallback
}
}
/// <summary>
/// Silent throttled launch-time check. Returns the result if a check actually
/// happened, or null if the cooldown window suppressed it. The cooldown lives
/// in <c>%LOCALAPPDATA%\Dragon-ISO\last-update-check.txt</c> as an ISO 8601
/// timestamp; a missing file means "never checked, do it now."
/// </summary>
public static async Task<UpdateCheckResult?> CheckIfDueAsync(TimeSpan cooldown, CancellationToken ct = default)
{
try
{
var path = CooldownPath;
if (File.Exists(path))
{
var raw = File.ReadAllText(path).Trim();
if (DateTimeOffset.TryParse(raw, out var last) &&
DateTimeOffset.UtcNow - last < cooldown)
{
return null;
}
}
}
catch
{
// Throttle check is best-effort; on read failures we just check now.
}
var result = await CheckAsync(ct);
try
{
var dir = Path.GetDirectoryName(CooldownPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(CooldownPath, DateTimeOffset.UtcNow.ToString("o"));
}
catch
{
// If we can't write the cooldown stamp, future launches will check
// again immediately. Annoying but not broken.
}
return result;
}
/// <summary>
/// Test-only seam — when set, overrides the default
/// %LOCALAPPDATA%\Dragon-ISO path that holds the cooldown stamp +
/// the opt-out flag. Tests use this to write to a tempdir so
/// CheckIfDueAsync's throttle path can be exercised without
/// hitting real disk paths or the real network (the throttle
/// short-circuits before the HTTP call).
/// </summary>
internal static string? StateDirectoryOverride { get; set; }
private static string StateDirectory => StateDirectoryOverride ??
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO");
private static string CooldownPath =>
Path.Combine(StateDirectory, "last-update-check.txt");
private static string OptOutPath =>
Path.Combine(StateDirectory, "no-update-check.flag");
/// <summary>
/// Whether launch-time update checks are enabled. Inverted-flag-file storage:
/// the absence of the file means "checks on" (default), the presence means
/// "checks off". Operators can ship the flag file via group policy / config-
/// management to suppress checks across a fleet.
/// </summary>
public static bool LaunchCheckEnabled
{
get => !File.Exists(OptOutPath);
set
{
try
{
if (value)
{
if (File.Exists(OptOutPath)) File.Delete(OptOutPath);
}
else
{
var dir = Path.GetDirectoryName(OptOutPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(OptOutPath, "Update checks suppressed by user. Delete this file to re-enable.");
}
}
catch
{
// Best-effort; toggle won't persist if disk is read-only, but
// the in-memory checkbox state still reflects the user's intent
// for this session.
}
}
}
private static string GetCurrentVersion()
{
var asm = typeof(UpdateChecker).Assembly;
return asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? asm.GetName().Version?.ToString()
?? "0.0.0";
}
/// <summary>
/// Parse a "vX.Y.Z" or "X.Y.Z(.N)" string into a Version. Strips any
/// pre-release suffix ("-alpha", "-beta") so the comparison is on
/// numeric components only — pre-release vs. release ordering is a
/// follow-up if we need it. Internal so tests can pin parsing
/// behaviour without HTTP.
/// </summary>
internal static Version? TryParseSemVer(string s)
{
var trimmed = s.TrimStart('v', 'V');
var dash = trimmed.IndexOf('-');
if (dash >= 0) trimmed = trimmed[..dash];
return Version.TryParse(trimmed, out var v) ? v : null;
}
}

View file

@ -1,124 +0,0 @@
using System.IO;
using System.Text.Json;
using System.Windows;
namespace DragonISO.App.Services;
/// <summary>
/// Saves / restores the main window's size, position, and state across launches.
/// Stored as JSON at <c>%LOCALAPPDATA%\Dragon-ISO\window.json</c>. Multi-monitor
/// friendly: a saved position that no longer falls inside any working area is
/// rejected on restore so the window doesn't disappear off-screen when a monitor
/// has been disconnected.
/// </summary>
public static class WindowStateStore
{
/// <summary>
/// Test-only seam — when set, overrides the default
/// %LOCALAPPDATA%\Dragon-ISO\window.json path. Lets tests verify
/// the serialization round-trip without polluting the dev's
/// real placement state.
/// </summary>
internal static string? PathOverride { get; set; }
private static string Path => PathOverride ??
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO",
"window.json");
public sealed record Snapshot(
double Left,
double Top,
double Width,
double Height,
WindowState State);
/// <summary>Save the current window placement.</summary>
public static void Save(Window window)
{
try
{
var snap = new Snapshot(
Left: window.Left,
Top: window.Top,
Width: window.ActualWidth,
Height: window.ActualHeight,
State: window.WindowState == WindowState.Minimized ? WindowState.Normal : window.WindowState);
var dir = System.IO.Path.GetDirectoryName(Path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(Path, JsonSerializer.Serialize(snap, new JsonSerializerOptions { WriteIndented = true }));
}
catch
{
// Best-effort persistence; never crash on shutdown for a UI nicety.
}
}
/// <summary>
/// Apply a previously-saved placement. Clamps onto a visible work area so a
/// monitor change doesn't strand the window off-screen. Returns true if a
/// valid snapshot was applied; false if no file existed or the snapshot was
/// rejected for being entirely outside any visible work area.
/// </summary>
public static bool TryApply(Window window)
{
try
{
if (!File.Exists(Path)) return false;
var json = File.ReadAllText(Path);
var snap = JsonSerializer.Deserialize<Snapshot>(json);
if (snap is null) return false;
// Sanity-check sizes (don't restore a 0×0 or absurdly large window).
if (snap.Width < 320 || snap.Height < 240) return false;
if (snap.Width > 16000 || snap.Height > 12000) return false;
// Reject if entirely off-screen (any working area on any screen contains
// a corner). System.Windows.Forms gives us per-monitor work areas here;
// we deliberately stick with WPF's SystemParameters which only reports the
// primary, so we use a generous on-screen check rather than refusing
// multi-monitor positions.
if (!IsAnyCornerOnScreen(snap)) return false;
window.WindowStartupLocation = WindowStartupLocation.Manual;
window.Left = snap.Left;
window.Top = snap.Top;
window.Width = snap.Width;
window.Height = snap.Height;
window.WindowState = snap.State;
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Approximate "is at least one corner of the saved rect within the virtual
/// screen?" check. Uses SystemParameters.VirtualScreen* which spans every
/// monitor.
/// </summary>
private static bool IsAnyCornerOnScreen(Snapshot snap)
{
var minX = SystemParameters.VirtualScreenLeft;
var minY = SystemParameters.VirtualScreenTop;
var maxX = minX + SystemParameters.VirtualScreenWidth;
var maxY = minY + SystemParameters.VirtualScreenHeight;
var corners = new[]
{
(snap.Left, snap.Top),
(snap.Left + snap.Width, snap.Top),
(snap.Left, snap.Top + snap.Height),
(snap.Left + snap.Width, snap.Top + snap.Height),
};
foreach (var (x, y) in corners)
{
if (x >= minX && x <= maxX && y >= minY && y <= maxY)
return true;
}
return false;
}
}

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

View file

@ -1,72 +0,0 @@
using System.Windows;
using System.Windows.Interop;
using DragonISO.App.Services;
namespace DragonISO.App;
/// <summary>
/// Phase E.4 experimental — hosts an embedded copy of the Teams main
/// window via SetParent. Operator opens this from Settings → DISPLAY →
/// 'Embed Teams window'. The host Border's HWND becomes Teams' parent on
/// Loaded; SizeChanged keeps Teams fitted; Closing always restores Teams
/// to a normal top-level window before we exit.
///
/// Failsafes:
/// • If no Teams window is found at Loaded, show a friendly message
/// instead of leaving the host blank.
/// • Restore-on-close runs in a finally block so a crash mid-host
/// can't leave Teams orphaned with stripped window styles.
/// • TeamsEmbedHost.RestoreEmbed is idempotent — safe to call even if
/// embedding never succeeded.
/// </summary>
public partial class TeamsEmbedWindow : Window
{
public TeamsEmbedWindow()
{
InitializeComponent();
Loaded += OnWindowLoaded;
Closed += OnWindowClosed;
}
private void OnWindowLoaded(object sender, RoutedEventArgs e)
{
var src = PresentationSource.FromVisual(EmbedHost) as HwndSource;
if (src is null || src.Handle == IntPtr.Zero)
{
MessageBox.Show(
"Couldn't obtain a host HWND for the embed window. " +
"Try closing and re-opening the embed window.",
"Dragon-ISO — embed",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var w = (int)EmbedHost.ActualWidth;
var h = (int)EmbedHost.ActualHeight;
if (!TeamsEmbedHost.EmbedTeamsInto(src.Handle, w, h))
{
MessageBox.Show(
"Couldn't find a Microsoft Teams window to embed. " +
"Launch Teams first (rail camera icon), then re-open this window.",
"Dragon-ISO — embed",
MessageBoxButton.OK, MessageBoxImage.Information);
}
}
private void OnHostSizeChanged(object sender, SizeChangedEventArgs e)
{
// Keep Teams sized to match the host as the embed window resizes.
// No-op when nothing is embedded.
TeamsEmbedHost.ResizeEmbedded((int)e.NewSize.Width, (int)e.NewSize.Height);
}
private void OnWindowClosed(object? sender, EventArgs e)
{
// ALWAYS restore Teams to top-level state when this window closes,
// even if the embed never succeeded. Idempotent.
try { TeamsEmbedHost.RestoreEmbed(); }
catch { /* defensive — restore is best-effort */ }
}
private void OnClose(object sender, RoutedEventArgs e) => Close();
}

View file

@ -1,63 +0,0 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Wild Dragon — Dark palette. Color resources ONLY, no styles.
Loaded into App.xaml's MergedDictionaries; swapped at runtime
for Theme.Light.xaml via Services/ThemeManager.cs.
Every key here MUST also exist in Theme.Light.xaml with the
same name. Keep the two files in lockstep — adding a new
brush in one without the other will break light-mode rendering.
References inside WildDragonTheme.xaml (which carries the styles
+ control templates) reach these brushes via {DynamicResource},
so the runtime swap re-resolves automatically.
-->
<SolidColorBrush x:Key="Wd.Canvas" Color="#0A0A0A"/>
<SolidColorBrush x:Key="Wd.Rail" Color="#080808"/>
<SolidColorBrush x:Key="Wd.Surface" Color="#141414"/>
<SolidColorBrush x:Key="Wd.SurfaceElevated" Color="#1C1C1C"/>
<SolidColorBrush x:Key="Wd.SurfaceHover" Color="#2A2A2A"/>
<SolidColorBrush x:Key="Wd.SurfaceActive" Color="#363636"/>
<SolidColorBrush x:Key="Wd.Border" Color="#262626"/>
<SolidColorBrush x:Key="Wd.BorderStrong" Color="#383838"/>
<SolidColorBrush x:Key="Wd.Button.HoverBg" Color="#33333A"/>
<SolidColorBrush x:Key="Wd.Button.PressBg" Color="#3F3F47"/>
<SolidColorBrush x:Key="Wd.Text.Primary" Color="#F5F5F5"/>
<SolidColorBrush x:Key="Wd.Text.Secondary" Color="#A3A3A3"/>
<SolidColorBrush x:Key="Wd.Text.Tertiary" Color="#6B6B6B"/>
<SolidColorBrush x:Key="Wd.Text.Disabled" Color="#404040"/>
<SolidColorBrush x:Key="Wd.Accent.Cyan" Color="#97EDF0"/>
<SolidColorBrush x:Key="Wd.Accent.CyanHover" Color="#B5F2F4"/>
<SolidColorBrush x:Key="Wd.Accent.CyanMuted" Color="#1B3537"/>
<SolidColorBrush x:Key="Wd.Accent.CyanText" Color="#97EDF0"/>
<SolidColorBrush x:Key="Wd.Accent.Blue" Color="#9AE0FD"/>
<SolidColorBrush x:Key="Wd.Accent.Coral" Color="#FB819C"/>
<SolidColorBrush x:Key="Wd.Accent.CoralBg" Color="#3A1922"/>
<SolidColorBrush x:Key="Wd.Status.Live" Color="#4ADE80"/>
<SolidColorBrush x:Key="Wd.Status.LiveBg" Color="#13261A"/>
<SolidColorBrush x:Key="Wd.Status.Warn" Color="#FBBF24"/>
<SolidColorBrush x:Key="Wd.Status.Error" Color="#FB819C"/>
<!--
Brand mark image, theme-flipped. Dark mode shows the WHITE dragon so it
reads against the near-black canvas. The light theme exposes the same
key pointing at the BLACK dragon. Consumers bind via
{DynamicResource Wd.BrandMark.Image} so the swap is automatic on
ThemeManager.Toggle().
CacheOption=OnLoad decodes the PNG at load time and releases the
underlying stream, which matters because the source files are 1243×1125
— without OnLoad the BitmapImage holds the stream open for the life
of the resource dictionary.
-->
<BitmapImage x:Key="Wd.BrandMark.Image"
UriSource="pack://application:,,,/DragonISO;component/Assets/dragon-mark-white.png"
CacheOption="OnLoad"/>
</ResourceDictionary>

View file

@ -1,60 +0,0 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Wild Dragon — Light palette. Color resources ONLY, no styles.
Mirror of Theme.Dark.xaml — same keys, light-mode values.
Light-palette discipline (from DESIGN.md):
- Neutrals are cyan-tinted off-whites, not pure white, so the
surface still reads as Wild Dragon brand, not as generic OS.
- Wd.Accent.Cyan stays at #97EDF0 because its primary use is as
a fill where text-on-top is near-black (LIVE pill works in
both modes unchanged).
- Wd.Accent.CyanText drops to a darker cyan (#0E7C82) for
contrast when cyan is used as text/icon foreground on the
light canvas. Use this key for "cyan as text"; use
Wd.Accent.Cyan for "cyan as background fill".
-->
<SolidColorBrush x:Key="Wd.Canvas" Color="#FAFAFB"/>
<SolidColorBrush x:Key="Wd.Rail" Color="#F0F1F3"/>
<SolidColorBrush x:Key="Wd.Surface" Color="#FFFFFF"/>
<SolidColorBrush x:Key="Wd.SurfaceElevated" Color="#FFFFFF"/>
<SolidColorBrush x:Key="Wd.SurfaceHover" Color="#ECEEF1"/>
<SolidColorBrush x:Key="Wd.SurfaceActive" Color="#E0E3E7"/>
<SolidColorBrush x:Key="Wd.Border" Color="#E5E7EB"/>
<SolidColorBrush x:Key="Wd.BorderStrong" Color="#D1D5DA"/>
<SolidColorBrush x:Key="Wd.Button.HoverBg" Color="#E0E3E7"/>
<SolidColorBrush x:Key="Wd.Button.PressBg" Color="#D1D5DA"/>
<SolidColorBrush x:Key="Wd.Text.Primary" Color="#0A0A0A"/>
<SolidColorBrush x:Key="Wd.Text.Secondary" Color="#4A4B50"/>
<SolidColorBrush x:Key="Wd.Text.Tertiary" Color="#71747A"/>
<SolidColorBrush x:Key="Wd.Text.Disabled" Color="#B3B6BC"/>
<SolidColorBrush x:Key="Wd.Accent.Cyan" Color="#97EDF0"/>
<SolidColorBrush x:Key="Wd.Accent.CyanHover" Color="#0890A0"/>
<SolidColorBrush x:Key="Wd.Accent.CyanMuted" Color="#E6F8F9"/>
<SolidColorBrush x:Key="Wd.Accent.CyanText" Color="#0E7C82"/>
<SolidColorBrush x:Key="Wd.Accent.Blue" Color="#3578A8"/>
<SolidColorBrush x:Key="Wd.Accent.Coral" Color="#D43E5C"/>
<SolidColorBrush x:Key="Wd.Accent.CoralBg" Color="#FDECF0"/>
<SolidColorBrush x:Key="Wd.Status.Live" Color="#15803D"/>
<SolidColorBrush x:Key="Wd.Status.LiveBg" Color="#DCFCE7"/>
<SolidColorBrush x:Key="Wd.Status.Warn" Color="#B45309"/>
<SolidColorBrush x:Key="Wd.Status.Error" Color="#D43E5C"/>
<!--
Brand mark image, theme-flipped. Light mode shows the BLACK dragon so it
reads against the cyan-tinted off-white canvas. Mirror of the Dark
theme's resource — same key, opposite silhouette. Consumers use
{DynamicResource Wd.BrandMark.Image} so the swap is automatic.
See Theme.Dark.xaml's comment for the CacheOption=OnLoad rationale.
-->
<BitmapImage x:Key="Wd.BrandMark.Image"
UriSource="pack://application:,,,/DragonISO;component/Assets/dragon-mark-black.png"
CacheOption="OnLoad"/>
</ResourceDictionary>

File diff suppressed because it is too large Load diff

View file

@ -1,31 +0,0 @@
using DragonISO.Engine.Domain;
namespace DragonISO.App.ViewModels;
public sealed class AlertBannerViewModel : ObservableObject
{
private EngineAlert? _current;
public EngineAlert? Current
{
get => _current;
set
{
if (SetField(ref _current, value))
{
OnPropertyChanged(nameof(IsVisible));
OnPropertyChanged(nameof(Message));
}
}
}
public bool IsVisible => _current is not null;
public string Message => _current?.Message ?? string.Empty;
public RelayCommand DismissCommand { get; }
public AlertBannerViewModel()
{
DismissCommand = new RelayCommand(() => Current = null);
}
}

View file

@ -1,210 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Threading;
using DragonISO.App.Services;
namespace DragonISO.App.ViewModels;
/// <summary>
/// View-model for the v2 Ctrl+K command palette. Owns the static list of
/// commands the operator can invoke, plus a free-text filter that whittles
/// the visible list down.
///
/// The palette is the v2 redesign's navigation surface — it replaces the
/// v1 rail's launch / hide / settings buttons (still discoverable in the
/// 32px header) AND the buried-in-tabs operator actions like "Apply
/// transcoder topology" or "Stop all ISOs" that previously needed
/// hunting through menus. Type two letters, press Enter, action invokes.
///
/// Match shape: case-insensitive Contains across Label + Category + the
/// optional Keywords list. Fuzzy (Sublime / Linear style) matching is a
/// future evolution if Contains proves insufficient; broadcasters have
/// short attention budgets and Contains is the predictable answer.
/// </summary>
public sealed class CommandPaletteViewModel : ObservableObject
{
private readonly MainViewModel _main;
private readonly Dispatcher _dispatcher;
private readonly List<PaletteCommand> _all;
private string _filter = string.Empty;
private PaletteCommand? _selected;
public CommandPaletteViewModel(MainViewModel main, Dispatcher dispatcher)
{
_main = main;
_dispatcher = dispatcher;
_all = BuildCommands();
Visible = new ObservableCollection<PaletteCommand>(_all);
Selected = Visible.FirstOrDefault();
}
/// <summary>Free-text filter. Empty string shows all commands.</summary>
public string Filter
{
get => _filter;
set
{
if (!SetField(ref _filter, value)) return;
ApplyFilter();
}
}
/// <summary>Filtered command list, bound to the palette ListBox.</summary>
public ObservableCollection<PaletteCommand> Visible { get; }
/// <summary>Currently-highlighted command. Driven by ↑/↓ in the search box and by mouse hover.</summary>
public PaletteCommand? Selected
{
get => _selected;
set => SetField(ref _selected, value);
}
/// <summary>
/// Move the selection up or down within the visible list, wrapping at the
/// edges. Called from the palette's PreviewKeyDown when the operator
/// presses ↑ / ↓ while focus is in the search box.
/// </summary>
public void MoveSelection(int direction)
{
if (Visible.Count == 0) return;
var idx = Selected is null ? -1 : Visible.IndexOf(Selected);
idx = (idx + direction + Visible.Count) % Visible.Count;
Selected = Visible[idx];
}
/// <summary>Invoke the current selection's action. Returns true if something fired.</summary>
public bool InvokeSelection()
{
var sel = Selected;
if (sel is null) return false;
try { sel.Invoke(); }
catch (Exception ex)
{
_main.Toast.Warn($"{sel.Label}: {ex.Message}");
return false;
}
return true;
}
private void ApplyFilter()
{
var query = _filter.Trim();
var prevSelected = Selected;
Visible.Clear();
if (string.IsNullOrEmpty(query))
{
foreach (var c in _all) Visible.Add(c);
}
else
{
foreach (var c in _all)
{
if (Matches(c, query)) Visible.Add(c);
}
}
Selected = Visible.Contains(prevSelected!)
? prevSelected
: Visible.FirstOrDefault();
}
internal static bool Matches(PaletteCommand c, string query)
{
if (c.Label.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
if (c.Category.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
// Keywords is a single space-separated string of synonyms — Contains
// over the whole blob suffices for the operator's short-token typing.
if (!string.IsNullOrEmpty(c.Keywords) &&
c.Keywords.Contains(query, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return false;
}
/// <summary>
/// Build the static command list. Order within a category matters for
/// keyboard-only operators: the most-frequent command of each category
/// goes first.
/// </summary>
private List<PaletteCommand> BuildCommands()
{
var vm = _main;
return new List<PaletteCommand>
{
// ─── QUICK ─── operator's top-of-mind verbs
new("Quick", "Enable all online", "ISOs enable everyone start everything live", "Ctrl+E",
() => InvokeIfReady(vm.EnableAllOnlineCommand)),
new("Quick", "Stop all ISOs", "panic stop everything kill disable", "Ctrl+Shift+S",
() => InvokeIfReady(vm.StopAllIsosCommand)),
new("Quick", "Refresh discovery", "rediscover sources rebuild finder NDI", "Ctrl+R",
() => InvokeIfReady(vm.RefreshDiscoveryCommand)),
// ─── TEAMS ─── direct UIA orchestration
new("Teams", "Mute / unmute", "microphone audio silence toggle", null,
() => InvokeIfReady(vm.ToggleMuteCommand)),
new("Teams", "Toggle camera", "video webcam on off", null,
() => InvokeIfReady(vm.ToggleCameraCommand)),
new("Teams", "Open share tray", "screen share present", null,
() => InvokeIfReady(vm.OpenShareTrayCommand)),
new("Teams", "Leave call", "exit end disconnect quit", null,
() => InvokeIfReady(vm.LeaveCallCommand)),
new("Teams", "Launch Microsoft Teams", "start open run app", null,
() => RunOnUi(() =>
{
if (!TeamsLauncher.IsRunning() && TeamsLauncher.TryLaunch(out _))
vm.Toast.Show("Launching Microsoft Teams…");
else
TeamsLauncher.ShowWindows();
})),
new("Teams", "Hide Teams windows", "minimize cloak", null,
() => RunOnUi(() =>
{
var n = TeamsLauncher.HideWindows();
vm.Toast.Show(n > 0 ? $"Hid {n} Teams window(s)" : "No Teams windows to hide");
})),
new("Teams", "Show Teams windows", "restore unhide", null,
() => RunOnUi(() =>
{
var n = TeamsLauncher.ShowWindows();
vm.Toast.Show(n > 0 ? $"Restored {n} Teams window(s)" : "No Teams windows to restore");
})),
// ─── NETWORK ───
new("Network", "Apply transcoder topology", "ndi groups isolate Dragon-ISO-input private", null,
() => InvokeIfReady(vm.Settings.ApplyTranscoderTopologyCommand)),
// ─── APP ───
new("App", "Theme: dark", "appearance night mode", null,
() => RunOnUi(() => ThemeManager.Current.Set("Dark"))),
new("App", "Theme: light", "appearance day mode bright", null,
() => RunOnUi(() => ThemeManager.Current.Set("Light"))),
new("App", "Theme: follow Windows", "system auto", null,
() => RunOnUi(() => ThemeManager.Current.Set("System"))),
new("App", "Help", "shortcuts cheatsheet f1", "F1",
() => InvokeIfReady(vm.ShowHelpCommand)),
new("App", "Show notes", "show notes daily journal", null,
() => InvokeIfReady(vm.ShowNotesCommand)),
};
}
private void RunOnUi(Action action) => _dispatcher.BeginInvoke(action);
private static void InvokeIfReady(System.Windows.Input.ICommand cmd)
{
if (cmd?.CanExecute(null) == true) cmd.Execute(null);
}
}
/// <summary>
/// One command in the Ctrl+K palette. <see cref="Keywords"/> is an optional
/// space of additional search terms — the operator might type "ndi" or
/// "private" and still match "Apply transcoder topology".
/// </summary>
public sealed record PaletteCommand(
string Category,
string Label,
string? Keywords,
string? Shortcut,
Action Invoke);

View file

@ -1,632 +0,0 @@
using System.IO;
using System.Windows;
using DragonISO.App.Services;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Domain;
namespace DragonISO.App.ViewModels;
/// <summary>
/// Bindings for the global settings panel: framerate, resolution, aspect, audio,
/// NDI groups (discovery + output), and the participant-list hide-Local toggle.
/// </summary>
public sealed class GlobalSettingsViewModel : ObservableObject
{
private readonly IIsoController _controller;
private readonly ToastViewModel? _toast;
private TargetFramerate _framerate;
private TargetResolution _resolution;
private AspectMode _aspect;
private AudioMode _audio;
private string _discoveryGroups;
private string _outputGroups;
private bool _hideLocalSelf = true;
private bool _autoDisableOnDeparture = false;
private bool _autoApplyLastPreset;
// Recording-related fields removed alongside the rest of the recording surface.
private bool _controlSurfaceEnabled;
private int _controlSurfacePort = ControlSurfaceServer.DefaultPort;
private bool _oscBridgeEnabled;
private int _oscBridgePort = OscBridge.DefaultPort;
private bool _updateCheckOnLaunch = UpdateChecker.LaunchCheckEnabled;
private string _outputNameTemplate = DragonISO.App.Services.OutputNameTemplate.Get();
private UIPreferences.SortMode _participantSort = UIPreferences.SortMode.JoinOrder;
private bool _minimizeToTray;
private bool _controlSurfaceLanReachable;
private bool _launchTeamsOnStartup;
private bool _autoHideTeamsWindows;
// _autoRecordOnCall removed — recording surface axed.
private bool _embedTeamsWindow;
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
{
_controller = controller;
_toast = toast;
var current = controller.GlobalSettings;
_framerate = current.Framerate;
_resolution = current.Resolution;
_aspect = current.Aspect;
_audio = current.Audio;
var groups = controller.GroupSettings;
_discoveryGroups = groups.DiscoveryGroups ?? string.Empty;
_outputGroups = groups.OutputGroups ?? string.Empty;
// Restore persisted UI toggles so the operator's preference survives
// process restarts. UIPreferences keeps a tiny JSON file under
// %LOCALAPPDATA%\Dragon-ISO\ui-prefs.json — defaults match the original
// in-memory init (HideLocalSelf=true, AutoDisableOnDeparture=false).
var uiPrefs = UIPreferences.Load();
_hideLocalSelf = uiPrefs.HideLocalSelf;
_autoDisableOnDeparture = uiPrefs.AutoDisableOnDeparture;
_participantSort = uiPrefs.ParticipantSort;
_minimizeToTray = uiPrefs.MinimizeToTray;
_controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable;
_launchTeamsOnStartup = uiPrefs.LaunchTeamsOnStartup;
_autoHideTeamsWindows = uiPrefs.AutoHideTeamsWindows;
// AutoRecordOnCall removed — recording surface axed.
_embedTeamsWindow = uiPrefs.EmbedTeamsWindow;
_controlSurfaceEnabled = uiPrefs.ControlSurfaceEnabled;
// Bring the auto-apply flag in from the presets store so the checkbox
// reflects the user's prior choice when the settings panel opens.
try { _autoApplyLastPreset = OperatorPresetStore.GetStartupPreference().AutoApplyOnStartup; }
catch { /* best-effort — disk read failures shouldn't block UI startup */ }
// Recording-directory init removed alongside the rest of the recording surface.
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
ResetOutputDefaultsCommand = new RelayCommand(ResetOutputDefaults);
CopyControlSurfaceUrlCommand = new RelayCommand(() =>
{
try
{
System.Windows.Clipboard.SetText(ControlSurfaceUrl);
_toast?.Show($"Copied: {ControlSurfaceUrl}");
}
catch
{
// Clipboard occasionally errors when something else has it locked.
}
});
OpenControlSurfaceCommand = new RelayCommand(() =>
{
// Hands the URL off to the OS shell so the user's default browser
// opens it. Operators previewing how the control panel looks on
// their phone / tablet / second monitor would otherwise have to
// copy-paste the URL — this is a one-click preview.
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = ControlSurfaceUrl,
UseShellExecute = true,
});
}
catch (Exception ex)
{
_toast?.Warn($"Couldn't open: {ex.Message}");
}
});
}
/// <summary>
/// Open the embedded HTML control panel (the <c>/ui</c> endpoint) in the
/// default browser. Enabled regardless of whether the control surface is
/// running — if it isn't, the browser will show a connection error, which
/// is informative; operators learn the surface needs to be enabled first.
/// </summary>
public RelayCommand OpenControlSurfaceCommand { get; }
private void ResetOutputDefaults()
{
var confirm = MessageBox.Show(
"Reset framerate, resolution, aspect and audio to Dragon-ISO defaults?\n\n" +
"This won't touch your NDI group configuration or display toggles.",
"Dragon-ISO — Reset output defaults",
MessageBoxButton.YesNo,
MessageBoxImage.Question,
MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
var defaults = FrameProcessingSettings.Default;
Framerate = defaults.Framerate;
Resolution = defaults.Resolution;
Aspect = defaults.Aspect;
Audio = defaults.Audio;
_toast?.Show("Output settings reset to defaults — click Apply Changes to commit");
}
public IEnumerable<TargetFramerate> AvailableFramerates => Enum.GetValues<TargetFramerate>();
public IEnumerable<TargetResolution> AvailableResolutions => Enum.GetValues<TargetResolution>();
public IEnumerable<AspectMode> AvailableAspectModes => Enum.GetValues<AspectMode>();
public IEnumerable<AudioMode> AvailableAudioModes => Enum.GetValues<AudioMode>();
public TargetFramerate Framerate { get => _framerate; set => SetField(ref _framerate, value); }
public TargetResolution Resolution { get => _resolution; set => SetField(ref _resolution, value); }
public AspectMode Aspect { get => _aspect; set => SetField(ref _aspect, value); }
public AudioMode Audio { get => _audio; set => SetField(ref _audio, value); }
/// <summary>NDI discovery group(s) — comma-separated. Empty = default (Public).</summary>
public string DiscoveryGroups { get => _discoveryGroups; set => SetField(ref _discoveryGroups, value); }
/// <summary>NDI output group(s) — comma-separated. Empty = default (Public).</summary>
public string OutputGroups { get => _outputGroups; set => SetField(ref _outputGroups, value); }
/// <summary>
/// Hide the user's own self-preview ("(Local)") from the participants list.
/// On by default — operators rarely want to ISO-route their own preview.
/// Read by <see cref="MainViewModel"/> when filtering the list it presents.
/// Persisted to <c>ui-prefs.json</c> via <see cref="UIPreferences"/>.
/// </summary>
public bool HideLocalSelf
{
get => _hideLocalSelf;
set
{
if (SetField(ref _hideLocalSelf, value)) PersistUiPrefs();
}
}
/// <summary>
/// When a participant leaves the meeting (their NDI source disappears),
/// automatically tear down their ISO pipeline. Off by default so transient
/// drops don't lose the operator's routing — but useful for clean
/// show-end behavior. Read by MainViewModel when reconciling departures.
/// Persisted to <c>ui-prefs.json</c>.
/// </summary>
public bool AutoDisableOnDeparture
{
get => _autoDisableOnDeparture;
set
{
if (SetField(ref _autoDisableOnDeparture, value)) PersistUiPrefs();
}
}
/// <summary>
/// Available sort modes for the dropdown in DISPLAY settings.
/// </summary>
public IEnumerable<UIPreferences.SortMode> AvailableSortModes => Enum.GetValues<UIPreferences.SortMode>();
/// <summary>
/// How the participants DataGrid is sorted. Persisted across launches via
/// <see cref="UIPreferences"/>. Reaches into <see cref="MainViewModel.SetSortMode"/>
/// on Application.Current to actually apply the sort to the live view —
/// the settings VM doesn't directly know about the main VM but App holds
/// both and exposes the main window via its DataContext.
/// </summary>
public UIPreferences.SortMode ParticipantSort
{
get => _participantSort;
set
{
if (!SetField(ref _participantSort, value)) return;
PersistUiPrefs();
// Apply to the live view immediately. App.MainWindow.DataContext
// is the MainViewModel; cast and call.
var main = (Application.Current?.MainWindow?.DataContext) as MainViewModel;
main?.SetSortMode(value);
}
}
/// <summary>
/// Minimize-to-tray behavior. When on, minimizing the main window hides
/// it from the taskbar and shows a tray icon (double-click to restore).
/// Right-click menu on the tray icon offers "Show", "Stop all ISOs", "Exit".
/// Useful for long unattended shows where the operator wants Dragon-ISO
/// running but invisible.
/// </summary>
public bool MinimizeToTray
{
get => _minimizeToTray;
set
{
if (!SetField(ref _minimizeToTray, value)) return;
PersistUiPrefs();
// Reach into the App-owned tray host. App constructs it after the
// main window exists, so the cast is safe at any time the settings
// panel is interactable.
var tray = (Application.Current as App)?.TrayIcon;
if (tray is not null) tray.Enabled = value;
_toast?.Show(value
? "Minimize-to-tray enabled — minimizing now hides the window"
: "Minimize-to-tray disabled");
}
}
/// <summary>
/// Snapshot the current persistable UI state to disk. Called from any
/// <see cref="UIPreferences.Prefs"/>-backed setter. Best-effort — disk
/// failures don't surface to the operator (the in-memory state still
/// reflects their click for this session).
/// </summary>
private void PersistUiPrefs()
{
// Theme isn't owned by this VM — read whatever ThemeManager has
// persisted (or default) so we don't clobber it on save.
var existing = UIPreferences.Load();
UIPreferences.Save(new UIPreferences.Prefs(
HideLocalSelf: _hideLocalSelf,
AutoDisableOnDeparture: _autoDisableOnDeparture,
ParticipantSort: _participantSort,
MinimizeToTray: _minimizeToTray,
ControlSurfaceLanReachable: _controlSurfaceLanReachable,
LaunchTeamsOnStartup: _launchTeamsOnStartup,
AutoHideTeamsWindows: _autoHideTeamsWindows,
EmbedTeamsWindow: _embedTeamsWindow,
Theme: existing.Theme,
ControlSurfaceEnabled: _controlSurfaceEnabled));
}
/// <summary>
/// Auto-launch the Microsoft Teams desktop client when Dragon-ISO starts.
/// Paired with <see cref="AutoHideTeamsWindows"/> gives the operator a
/// "Dragon-ISO is the only window I see" experience — Teams runs in the
/// background, all interaction happens through the participants DataGrid
/// + IN-CALL bar.
/// </summary>
public bool LaunchTeamsOnStartup
{
get => _launchTeamsOnStartup;
set
{
if (SetField(ref _launchTeamsOnStartup, value)) PersistUiPrefs();
}
}
/// <summary>
/// Auto-hide Teams' top-level windows as soon as they materialize after
/// a launch (whether triggered via <see cref="LaunchTeamsOnStartup"/>,
/// the rail button, or the eye-toggle). Runs a brief background poll
/// that calls <c>TeamsLauncher.HideWindows</c> every ~250ms for up to
/// 15 seconds, catching splash + main + follow-up panels.
/// </summary>
public bool AutoHideTeamsWindows
{
get => _autoHideTeamsWindows;
set
{
if (SetField(ref _autoHideTeamsWindows, value)) PersistUiPrefs();
}
}
// AutoRecordOnCall / RecordIsosToDisk / RecordingDirectory properties
// removed alongside the rest of the recording surface.
/// <summary>
/// EXPERIMENTAL: SetParent-reparents Teams' main window into a Dragon-ISO-
/// owned host so Teams visually appears inside our window. WebView2 in
/// modern Teams may render weirdly after reparent — if so, untick and
/// fall back to the auto-hide flow. Polling logic in MainWindow.xaml.cs
/// applies / restores the embed; this property is just the persisted
/// toggle.
/// </summary>
public bool EmbedTeamsWindow
{
get => _embedTeamsWindow;
set
{
if (SetField(ref _embedTeamsWindow, value)) PersistUiPrefs();
}
}
/// <summary>
/// REST control surface — Stream Deck / Companion / thin-client controllers.
/// Off by default. Bind address depends on <see cref="ControlSurfaceLanReachable"/>:
/// loopback (127.0.0.1) by default, or all-interfaces when LAN-reachable.
/// </summary>
public bool ControlSurfaceEnabled
{
get => _controlSurfaceEnabled;
set
{
if (!SetField(ref _controlSurfaceEnabled, value)) return;
PersistUiPrefs();
var srv = (Application.Current as App)?.ControlSurface;
if (srv is null) return;
if (value) srv.Start(_controlSurfacePort, _controlSurfaceLanReachable);
else srv.Stop();
_toast?.Show(value
? $"Control surface listening on {ControlSurfaceUrl}"
: "Control surface stopped");
}
}
/// <summary>
/// Port the control surface binds to. Editable while the surface is off; while on,
/// changing the port stops + restarts the listener on the new port.
/// </summary>
public int ControlSurfacePort
{
get => _controlSurfacePort;
set
{
if (!SetField(ref _controlSurfacePort, value)) return;
OnPropertyChanged(nameof(ControlSurfaceUrl));
if (!_controlSurfaceEnabled) return;
var srv = (Application.Current as App)?.ControlSurface;
srv?.Start(value, _controlSurfaceLanReachable);
_toast?.Show($"Control surface restarted on {ControlSurfaceUrl}");
}
}
/// <summary>
/// LAN-reachable mode. When false (default), control surface binds to
/// 127.0.0.1 — only this machine. When true, binds to all interfaces so
/// a thin-client controller on a phone or another laptop can drive
/// DragonISO. The OSC bridge follows suit if it's running.
///
/// Important: HttpListener requires either Administrator privilege OR a
/// one-time URL ACL reservation for non-loopback prefixes:
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
/// (run from an elevated PowerShell). Without that the listener throws
/// AccessDeniedException on Start; the failure surfaces as a logger
/// warning with the exact netsh command.
/// </summary>
public bool ControlSurfaceLanReachable
{
get => _controlSurfaceLanReachable;
set
{
if (!SetField(ref _controlSurfaceLanReachable, value)) return;
PersistUiPrefs();
OnPropertyChanged(nameof(ControlSurfaceUrl));
if (!_controlSurfaceEnabled) return;
var srv = (Application.Current as App)?.ControlSurface;
srv?.Start(_controlSurfacePort, value);
var osc = (Application.Current as App)?.OscBridge;
if (osc?.IsRunning == true) osc.Start(_oscBridgePort, value);
_toast?.Show(value
? $"Control surface now LAN-reachable: {ControlSurfaceUrl}"
: "Control surface now loopback-only");
}
}
/// <summary>
/// Friendly URL of the running surface, for the settings panel + status
/// bar tooltip. Resolves to the first non-loopback IPv4 address when
/// LAN-reachable; loopback otherwise. Computed on demand because the
/// LAN IP may change between settings opens (Wi-Fi swap, VPN connect).
/// </summary>
public string ControlSurfaceUrl
{
get
{
var host = _controlSurfaceLanReachable
? GetLanIPv4() ?? "127.0.0.1"
: "127.0.0.1";
return $"http://{host}:{_controlSurfacePort}/ui";
}
}
/// <summary>
/// Best-effort routable IPv4 address suitable for showing the operator a
/// "paste me into the thin client" URL. Skips:
/// • loopback interfaces (127.x)
/// • tunnel/virtual interfaces (NetworkInterfaceType.Tunnel — e.g. WSL,
/// Hyper-V, Tailscale, OpenVPN-style virtuals)
/// • APIPA/link-local addresses (169.254.x — assigned when DHCP fails;
/// a host with one of these AND a real DHCP lease should pick the lease)
/// Prefers Ethernet/Wi-Fi over everything else, then falls back to the
/// first non-link-local non-loopback IPv4. Returns null only if no
/// usable address exists at all.
/// </summary>
private static string? GetLanIPv4()
{
try
{
string? linkLocalFallback = null;
string? otherFallback = null;
foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
if (ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Loopback) continue;
if (ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Tunnel) continue;
var isPhysical =
ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Ethernet ||
ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.GigabitEthernet ||
ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Wireless80211;
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
{
if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
if (System.Net.IPAddress.IsLoopback(ua.Address)) continue;
var addr = ua.Address.ToString();
var isLinkLocal = addr.StartsWith("169.254.", StringComparison.Ordinal);
if (isPhysical && !isLinkLocal) return addr; // best
if (!isLinkLocal) otherFallback ??= addr; // routable but virtual NIC
if (isLinkLocal) linkLocalFallback ??= addr; // worst
}
}
return otherFallback ?? linkLocalFallback;
}
catch { /* best-effort */ }
return null;
}
/// <summary>
/// OSC bridge over UDP — same command surface as the REST endpoints,
/// reachable from Companion / TouchOSC / lighting consoles. Off by default;
/// bound to 127.0.0.1 only.
/// </summary>
public bool OscBridgeEnabled
{
get => _oscBridgeEnabled;
set
{
if (!SetField(ref _oscBridgeEnabled, value)) return;
var bridge = (Application.Current as App)?.OscBridge;
if (bridge is null) return;
if (value) bridge.Start(_oscBridgePort, _controlSurfaceLanReachable);
else bridge.Stop();
var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
_toast?.Show(value
? $"OSC bridge listening on udp://{host}:{_oscBridgePort}/"
: "OSC bridge stopped");
}
}
/// <summary>OSC bridge UDP port. Default 9000 (TouchOSC's default).</summary>
public int OscBridgePort
{
get => _oscBridgePort;
set
{
if (!SetField(ref _oscBridgePort, value)) return;
if (!_oscBridgeEnabled) return;
var bridge = (Application.Current as App)?.OscBridge;
bridge?.Start(value, _controlSurfaceLanReachable);
var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
_toast?.Show($"OSC bridge restarted on udp://{host}:{value}/");
}
}
/// <summary>
/// Output-name template applied when the operator enables an ISO without
/// a per-participant CustomName. Default <c>"{name}"</c> renders the
/// speaker's display name directly (changed from the legacy
/// <c>"Dragon-ISO_{guid}"</c> in 0.9.0-rc19, since downstream switchers
/// almost always want human-readable identifiers). Switch back to a
/// guid-based template if you need stable IDs that survive participant
/// name changes. See <see cref="OutputNameTemplate"/> for the supported
/// tokens.
/// </summary>
public string OutputNameTemplate
{
get => _outputNameTemplate;
set
{
if (SetField(ref _outputNameTemplate, value))
{
DragonISO.App.Services.OutputNameTemplate.Set(value ?? string.Empty);
}
}
}
/// <summary>
/// Background update check on launch. Throttled to once per 24h via a
/// timestamp file. When a newer release is found, surfaces a non-modal
/// banner with a "Get update" button. Off-by-default would be friendlier
/// for paranoid setups; on-by-default is friendlier for adoption.
/// </summary>
public bool UpdateCheckOnLaunch
{
get => _updateCheckOnLaunch;
set
{
if (SetField(ref _updateCheckOnLaunch, value))
{
UpdateChecker.LaunchCheckEnabled = value;
_toast?.Show(value
? "Update checks enabled — runs once per 24h on launch"
: "Update checks disabled");
}
}
}
/// <summary>
/// On launch, automatically re-apply the most recently applied operator preset.
/// Closes the loop on the recurring-show workflow: the operator clicks Apply
/// once, and from that point on Dragon-ISO restores the same routing on every
/// subsequent launch as soon as the matching participants come online.
/// Persisted to <c>presets.json</c>'s <c>AutoApplyOnStartup</c> field via
/// <see cref="OperatorPresetStore"/>.
/// </summary>
public bool AutoApplyLastPreset
{
get => _autoApplyLastPreset;
set
{
if (SetField(ref _autoApplyLastPreset, value))
{
try { OperatorPresetStore.SetAutoApplyOnStartup(value); }
catch { /* best-effort */ }
}
}
}
public AsyncRelayCommand ApplyCommand { get; }
/// <summary>
/// Copy the current control-surface URL to the clipboard. Operators on a
/// thin-client setup tap this, then paste into a phone browser. Bound to
/// a small button next to the LAN-reachable toggle.
/// </summary>
public RelayCommand CopyControlSurfaceUrlCommand { get; }
/// <summary>
/// Restore Output settings (framerate, resolution, aspect, audio) to the engine's
/// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky —
/// the operator's transcoder topology is a per-machine setting that survives
/// preferences resets) and doesn't touch Display toggles. Confirms first.
/// </summary>
public RelayCommand ResetOutputDefaultsCommand { get; }
/// <summary>
/// One-click "set up the transcoder topology" — writes ndi-config.v1.json so all
/// local senders broadcast on a private group ("Dragon-ISO-input") while local
/// receivers can see both that and "public", then sets the engine's discovery and
/// output groups to align (engine receives from the private group, emits on Public).
/// User has to restart Teams for the new ndi-config.v1.json to take effect there.
/// </summary>
public AsyncRelayCommand ApplyTranscoderTopologyCommand { get; }
private async Task ApplyAsync()
{
var settings = new FrameProcessingSettings(_framerate, _resolution, _aspect, _audio);
await _controller.SetGlobalSettingsAsync(settings, CancellationToken.None);
var groups = new NdiGroupSettings(
DiscoveryGroups: string.IsNullOrWhiteSpace(_discoveryGroups) ? null : _discoveryGroups.Trim(),
OutputGroups: string.IsNullOrWhiteSpace(_outputGroups) ? null : _outputGroups.Trim());
await _controller.SetGroupSettingsAsync(groups, CancellationToken.None);
_toast?.Show("Settings saved");
}
private async Task ApplyTranscoderTopologyAsync()
{
// 1. Update the machine-wide NDI config so Teams' raw broadcasts go to the
// private group instead of polluting Public.
var result = NdiAccessManagerConfig.ApplyTranscoderTopology();
if (!result.Success)
{
MessageBox.Show(
$"Could not write NDI Access Manager config.\n\n{result.ErrorMessage}\n\nPath: {result.ConfigPath}",
"Dragon-ISO — Apply transcoder topology",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
// 2. Update the engine: receive only from the private group, emit on Public.
var ourGroups = new NdiGroupSettings(
DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup,
OutputGroups: "public");
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
// 3. Reflect the new values in the bound text boxes.
DiscoveryGroups = NdiAccessManagerConfig.TranscoderInputGroup;
OutputGroups = "public";
var backupNote = result.BackupPath is null
? "No prior NDI config existed; a fresh one was created."
: $"A backup of your prior NDI config was saved to:\n{result.BackupPath}";
MessageBox.Show(
"Transcoder topology applied. ✓\n\n" +
"• Local senders (Teams, etc.) will broadcast on group 'Dragon-ISO-input'.\n" +
"• Local receivers will see both 'public' and 'Dragon-ISO-input'.\n" +
"• Dragon-ISO will discover from 'Dragon-ISO-input' and re-emit on 'public'.\n\n" +
"RESTART Microsoft Teams for the new NDI config to take effect there.\n\n" +
backupNote,
"Dragon-ISO — Apply transcoder topology",
MessageBoxButton.OK,
MessageBoxImage.Information);
_toast?.Show("Transcoder topology applied — restart Teams to take effect");
}
}

View file

@ -1,135 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Domain;
namespace DragonISO.App.ViewModels;
/// <summary>
/// View-model for the per-participant <see cref="FrameProcessingSettings"/> override
/// editor. Holds the operator's in-progress choice of framerate / resolution /
/// aspect / audio for a single participant, and exposes Apply / Clear / Cancel
/// commands that drive the engine through <see cref="IIsoController"/>.
///
/// "Following global settings" state is reflected via <see cref="HasOverride"/>,
/// which is true exactly when the participant currently has a non-null override
/// on the engine side (i.e. their pipeline beats the global defaults).
/// </summary>
public sealed class IsoOverrideDialogViewModel : ObservableObject
{
private readonly IIsoController _controller;
private readonly ToastViewModel? _toast;
private TargetFramerate _framerate;
private TargetResolution _resolution;
private AspectMode _aspect;
private AudioMode _audio;
private bool _hasOverride;
/// <summary>
/// Participant identity. The engine API is Guid-keyed; we cache the display
/// name once at construction so the dialog title doesn't need to re-resolve
/// the participant from the live list (and so it survives the participant
/// going offline mid-dialog).
/// </summary>
public Guid ParticipantId { get; }
public string DisplayName { get; }
/// <summary>
/// Reference to the existing <see cref="GlobalSettingsViewModel"/> so the
/// dialog's ComboBoxes can bind directly to its Available* lists — there's
/// no point duplicating the Enum.GetValues calls here.
/// </summary>
public GlobalSettingsViewModel Settings { get; }
/// <summary>
/// Action the dialog code-behind wires up so the VM can close the window
/// after Apply / Clear / Cancel without taking a Window dependency.
/// </summary>
public Action? RequestClose { get; set; }
public IsoOverrideDialogViewModel(
IIsoController controller,
GlobalSettingsViewModel settings,
Guid participantId,
string displayName,
FrameProcessingSettings? currentOverride,
ToastViewModel? toast = null)
{
_controller = controller;
Settings = settings;
_toast = toast;
ParticipantId = participantId;
DisplayName = displayName;
// Initialize the four enum values from the existing override (if any)
// or fall back to the global settings — the dialog should always open
// with values that already reflect what this pipeline is using.
var source = currentOverride ?? new FrameProcessingSettings(
settings.Framerate,
settings.Resolution,
settings.Aspect,
settings.Audio);
_framerate = source.Framerate;
_resolution = source.Resolution;
_aspect = source.Aspect;
_audio = source.Audio;
_hasOverride = currentOverride is not null;
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
ClearCommand = new AsyncRelayCommand(ClearAsync, () => _hasOverride);
CancelCommand = new RelayCommand(() => RequestClose?.Invoke());
}
public TargetFramerate Framerate { get => _framerate; set => SetField(ref _framerate, value); }
public TargetResolution Resolution { get => _resolution; set => SetField(ref _resolution, value); }
public AspectMode Aspect { get => _aspect; set => SetField(ref _aspect, value); }
public AudioMode Audio { get => _audio; set => SetField(ref _audio, value); }
/// <summary>
/// True when the participant currently has a non-null override (i.e. this
/// dialog opened on an already-overridden pipeline). Toggles the visibility
/// of the "Following global settings" indicator and gates the
/// <see cref="ClearCommand"/>.
/// </summary>
public bool HasOverride
{
get => _hasOverride;
private set
{
if (SetField(ref _hasOverride, value))
{
OnPropertyChanged(nameof(FollowingGlobalsVisible));
ClearCommand.RaiseCanExecuteChanged();
}
}
}
/// <summary>
/// Convenience for XAML visibility binding — true when we should show the
/// "Following global settings · Reset to global" affordance.
/// </summary>
public bool FollowingGlobalsVisible => _hasOverride;
public AsyncRelayCommand ApplyCommand { get; }
public AsyncRelayCommand ClearCommand { get; }
public RelayCommand CancelCommand { get; }
private async Task ApplyAsync()
{
var settings = new FrameProcessingSettings(_framerate, _resolution, _aspect, _audio);
await _controller.SetIsoOverrideAsync(ParticipantId, settings, CancellationToken.None);
HasOverride = true;
_toast?.Show($"Override saved for {DisplayName}");
RequestClose?.Invoke();
}
private async Task ClearAsync()
{
await _controller.SetIsoOverrideAsync(ParticipantId, null, CancellationToken.None);
HasOverride = false;
_toast?.Show($"{DisplayName} now follows global settings");
RequestClose?.Invoke();
}
}

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,742 +0,0 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Windows.Data;
using System.Windows.Threading;
using DragonISO.App.Services;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Domain;
namespace DragonISO.App.ViewModels;
/// <summary>
/// Top-level view model for the main window. Owns the live collection of <see cref="ParticipantViewModel"/>,
/// the global settings panel, and the alert banner. Subscribes to <see cref="IIsoController"/>'s observables
/// and marshals updates onto the UI dispatcher.
///
/// Split across partial files by responsibility:
/// • <c>MainViewModel.cs</c> — fields, properties, constructor (wires commands), OnStatsTick, Dispose
/// • <c>MainViewModel.TeamsCommands.cs</c> — Mute / Cam / Leave / Share + Join URL + Teams meeting-state poll
/// • <c>MainViewModel.PresetCommands.cs</c> — auto-apply-last-preset path
/// • <c>MainViewModel.BulkCommands.cs</c> — Stop all / Enable all / Snapshot all
/// </summary>
public sealed partial class MainViewModel : ObservableObject, IDisposable
{
private readonly IIsoController _controller;
private readonly Dispatcher _dispatcher;
private readonly IDisposable _participantsSub;
private readonly IDisposable _alertsSub;
private readonly DispatcherTimer _statsTimer;
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
private string _statusText = "Starting…";
/// <summary>
/// Wall-clock at which <see cref="InitializeAsync"/> kicked the engine. Used to
/// gate the "Scanning for NDI sources…" placeholder so it shows for a few
/// seconds after launch even when ParticipantCount == 0 (the bleak
/// "no ndi sources yet" empty state was being shown immediately and
/// operators assumed the app was broken before discovery had a chance to fire).
/// Null until InitializeAsync runs.
/// </summary>
private DateTimeOffset? _engineStartedAt;
/// <summary>How long after engine start to keep showing "Scanning…" instead of the empty-state copy.</summary>
private static readonly TimeSpan DiscoveryGracePeriod = TimeSpan.FromSeconds(8);
// _pendingPresetName / Deadline / Applied + the auto-apply path
// moved to MainViewModel.PresetCommands.cs.
public ObservableCollection<ParticipantViewModel> Participants { get; } = new();
/// <summary>
/// Filter-backed view over <see cref="Participants"/>. The DataGrid binds
/// to this rather than the raw collection so the operator's filter text
/// hides non-matching rows without mutating the underlying observable
/// (which would break IsoController's identity tracking).
/// </summary>
public ICollectionView ParticipantsView { get; }
private string _participantFilter = string.Empty;
/// <summary>
/// Apply the operator's saved sort preference to <see cref="ParticipantsView"/>.
/// JoinOrder = no SortDescriptions (whatever order participants are added in);
/// Alphabetical = ascending by DisplayName; OnlineFirst = IsOnline desc then
/// DisplayName asc. Called on construction and from <see cref="SetSortMode"/>.
/// </summary>
private void ApplySortFromPrefs()
{
var prefs = Services.UIPreferences.Load();
SetSortMode(prefs.ParticipantSort);
}
/// <summary>
/// Re-applies the sort descriptions on the ParticipantsView. Called from the
/// settings panel when the operator picks a different sort mode.
/// </summary>
public void SetSortMode(Services.UIPreferences.SortMode mode)
{
_currentSortMode = mode;
ParticipantsView.SortDescriptions.Clear();
switch (mode)
{
case Services.UIPreferences.SortMode.Alphabetical:
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
break;
case Services.UIPreferences.SortMode.OnlineFirst:
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.IsOnline), ListSortDirection.Descending));
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
break;
case Services.UIPreferences.SortMode.LoudestFirst:
// Sort by the displayed audio level (which already includes the
// decay envelope) so participants don't snap-reorder on every
// tiny audio frame. ParticipantsView.Refresh() at the stats
// tick re-evaluates the sort with the latest values.
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayedAudioLevel), ListSortDirection.Descending));
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
break;
// JoinOrder: leave SortDescriptions empty.
}
}
private Services.UIPreferences.SortMode _currentSortMode = Services.UIPreferences.SortMode.JoinOrder;
/// <summary>
/// Live filter substring. Empty = show everyone. Matched case-insensitively
/// against display name. Setter refreshes the view immediately so the
/// DataGrid reflows as the operator types.
/// </summary>
public string ParticipantFilter
{
get => _participantFilter;
set
{
if (SetField(ref _participantFilter, value))
ParticipantsView.Refresh();
}
}
public GlobalSettingsViewModel Settings { get; }
public AlertBannerViewModel AlertBanner { get; } = new();
public ToastViewModel Toast { get; }
public UpdateBannerViewModel UpdateBanner { get; } = new();
/// <summary>
/// Engine-side controller. Exposed so the PresetsDialog (a Window, not a VM)
/// can re-issue EnableIsoAsync / DisableIsoAsync when applying a preset
/// without us having to plumb a per-action command through the participant
/// view-models from the dialog's XAML.
/// </summary>
internal IIsoController Controller => _controller;
/// <summary>
/// Emergency-stop: disable every running ISO. Bound to a small "Stop all" affordance
/// near the participants header so an operator can kill all outputs in a single click
/// when something goes sideways during a live show.
/// </summary>
public AsyncRelayCommand StopAllIsosCommand { get; }
/// <summary>
/// Bulk-enable: turn on ISOs for every online participant whose pipeline isn't
/// already running. Useful for "everyone joined, hit one button, every route goes
/// live." Skips offline rows (no source) and rows already enabled.
/// </summary>
public AsyncRelayCommand EnableAllOnlineCommand { get; }
/// <summary>
/// Force NDI discovery to rebuild its finder. Surfaced as a small "Refresh" pill
/// next to the participants header — useful right after Apply Transcoder Topology
/// or when Teams restarts mid-session and stale TTLs are masking new sources.
/// </summary>
public RelayCommand RefreshDiscoveryCommand { get; }
// ════════════════════════════════════════════════════════════════════════
// Phase E.3 — In-call controls. Each command drives a UIAutomation lookup
// against Teams' window tree and reports a toast on outcome. Best-effort:
// a control-not-found result toasts a hint rather than throwing, since
// Teams isn't always in a call (the buttons only appear in-call).
// ════════════════════════════════════════════════════════════════════════
public RelayCommand ToggleMuteCommand { get; }
public RelayCommand ToggleCameraCommand { get; }
public RelayCommand LeaveCallCommand { get; }
public RelayCommand OpenShareTrayCommand { get; }
// Recording-marker and roll-recording commands removed — recording feature axed.
/// <summary>F1 binding — opens the help / cheat-sheet dialog.</summary>
public RelayCommand ShowHelpCommand { get; }
/// <summary>
/// Ctrl+T binding — cycles dark ↔ light theme via ThemeManager.
/// Persists the operator's choice through UIPreferences.Theme.
/// The v2 header surfaces this as a click affordance too; the
/// command exists once so both bindings reach the same path.
/// </summary>
public RelayCommand ToggleThemeCommand { get; }
/// <summary>
/// Ctrl+K binding — opens the v2 command palette. The actual window
/// open call lives in <see cref="MainWindow"/> (view-side concern);
/// this command delegates through an Action callback the view sets
/// after construction so the VM stays unaware of WPF Window types.
/// </summary>
public RelayCommand OpenCommandPaletteCommand { get; }
private Action? _openCommandPalette;
/// <summary>
/// Wire the view's palette-opening callback. Called by MainWindow's
/// constructor right after DataContext is set. Idempotent — second
/// call replaces the first.
/// </summary>
public void RegisterCommandPaletteOpener(Action openPalette) =>
_openCommandPalette = openPalette;
/// <summary>Opens the inline notes viewer for today's show-notes file.</summary>
public RelayCommand ShowNotesCommand { get; }
/// <summary>Join a Teams meeting from a pasted URL — see <see cref="JoinMeetingUrl"/>.</summary>
public RelayCommand JoinMeetingCommand { get; }
/// <summary>Save a PNG snapshot of every enabled participant's current frame.</summary>
public RelayCommand SnapshotAllCommand { get; }
/// <summary>
/// Toggle the ISO for the Nth visible participant (1-based, matches the
/// numpad layout). Used by the NumPad1..NumPad9 hotkeys; resolves
/// against ParticipantsView so the index matches what the operator
/// sees in the current sort + filter.
/// </summary>
public RelayCommand<string> ToggleByIndexCommand { get; }
/// <summary>
/// Two-way bound to the quick-join input. Whatever the operator pastes
/// gets handed to <see cref="TeamsLauncher.TryJoinMeeting"/> when the
/// Join button fires. Cleared on success so the field is ready for the
/// next paste.
/// </summary>
public string JoinMeetingUrl
{
get => _joinMeetingUrl;
set => SetField(ref _joinMeetingUrl, value);
}
private string _joinMeetingUrl = string.Empty;
public string StatusText
{
get => _statusText;
set => SetField(ref _statusText, value);
}
// Recording-status properties (IsRecording, ActiveRecordingCount,
// RecordingElapsed, RecordingFreeSpace, IsLowDiskSpace) removed when the
// recording feature was axed.
/// <summary>
/// Total visible participants — feeds the v2 transport strip's "PART N"
/// readout. Updated on every 1Hz stats tick alongside <see cref="LiveCount"/>.
/// </summary>
public int ParticipantCount
{
get => _participantCount;
private set => SetField(ref _participantCount, value);
}
private int _participantCount;
/// <summary>
/// True for the first <see cref="DiscoveryGracePeriod"/> after engine start.
/// The XAML uses this to swap the empty-state placeholder from the bleak
/// "no ndi sources yet — open teams and start a meeting" copy (which reads
/// as broken to operators who just launched into an active meeting) to a
/// neutral "Scanning for NDI sources…" status while NDI Find resolves
/// mDNS responses. Always false once participants populate.
/// </summary>
public bool IsDiscovering
{
get => _isDiscovering;
private set => SetField(ref _isDiscovering, value);
}
private bool _isDiscovering;
/// <summary>
/// Currently-enabled (live) ISO count — feeds the v2 transport strip's
/// "LIVE N" readout. The number is cyan-tinted when non-zero to draw
/// the operator's eye to active state.
/// </summary>
public int LiveCount
{
get => _liveCount;
private set => SetField(ref _liveCount, value);
}
private int _liveCount;
/// <summary>True when the REST control surface (or OSC bridge, or both) is listening.</summary>
public bool IsControlSurfaceRunning
{
get => _isControlSurfaceRunning;
private set => SetField(ref _isControlSurfaceRunning, value);
}
private bool _isControlSurfaceRunning;
/// <summary>Human-readable string for the control-surface tooltip ("REST :9755 + OSC :9000").</summary>
public string ControlSurfaceText
{
get => _controlSurfaceText;
private set => SetField(ref _controlSurfaceText, value);
}
private string _controlSurfaceText = string.Empty;
/// <summary>
/// "IN CALL" when Teams is in an active meeting; "READY" when Teams is
/// running but not in a call; empty when Teams isn't running. Surfaced
/// as a status pill in the IN-CALL bar so operators with auto-hide on
/// can see Teams' state without restoring its window.
/// </summary>
public string TeamsMeetingState
{
get => _teamsMeetingState;
private set
{
if (SetField(ref _teamsMeetingState, value))
OnPropertyChanged(nameof(HasTeamsState));
}
}
private string _teamsMeetingState = string.Empty;
/// <summary>True when Teams is currently in a call (Leave button present in UIA tree).</summary>
public bool IsTeamsInCall
{
get => _isTeamsInCall;
private set => SetField(ref _isTeamsInCall, value);
}
private bool _isTeamsInCall;
/// <summary>True when <see cref="TeamsMeetingState"/> is non-empty. Used to gate visibility of the IN-CALL bar status pill via the existing BoolToVis converter.</summary>
public bool HasTeamsState => !string.IsNullOrEmpty(_teamsMeetingState);
/// <summary>True when the local user's mic is muted in the active Teams call.</summary>
public bool IsLocalMuted
{
get => _isLocalMuted;
private set => SetField(ref _isLocalMuted, value);
}
private bool _isLocalMuted;
/// <summary>True when the local user's camera is off in the active Teams call.</summary>
public bool IsLocalCameraOff
{
get => _isLocalCameraOff;
private set => SetField(ref _isLocalCameraOff, value);
}
private bool _isLocalCameraOff;
/// <summary>
/// Elapsed time since the first ISO went live, formatted "HH:mm:ss". Empty
/// when nothing's running. Useful for operators tracking show length.
/// Resets when all ISOs go offline (next time one comes back, the timer
/// starts from 00:00:00 again).
/// </summary>
public string SessionElapsed
{
get => _sessionElapsed;
private set => SetField(ref _sessionElapsed, value);
}
private string _sessionElapsed = string.Empty;
public bool IsSessionActive
{
get => _isSessionActive;
private set => SetField(ref _isSessionActive, value);
}
private bool _isSessionActive;
private DateTimeOffset? _sessionStartedAt;
public MainViewModel(IIsoController controller, Dispatcher dispatcher)
{
_controller = controller;
_dispatcher = dispatcher;
Toast = new ToastViewModel(dispatcher);
Settings = new GlobalSettingsViewModel(controller, Toast);
// Set up the filter-aware view AFTER Participants is non-null. The
// CollectionView binds to the live collection; Filter callback runs
// each time Refresh() is called or the collection mutates.
ParticipantsView = CollectionViewSource.GetDefaultView(Participants);
ParticipantsView.Filter = obj =>
{
if (string.IsNullOrEmpty(_participantFilter)) return true;
return obj is ParticipantViewModel p &&
p.DisplayName.Contains(_participantFilter, StringComparison.OrdinalIgnoreCase);
};
// Apply the operator's saved sort preference, if any.
ApplySortFromPrefs();
// Subscribe directly (no ObserveOn) and marshal to the UI thread inside
// the callback via Dispatcher.InvokeAsync. The previous ObserveOn(
// SynchronizationContextScheduler) path captured SynchronizationContext
// .Current at subscribe time — fragile in WPF startup ordering, where
// the UI thread's SyncContext can be in a transitional state during
// App.OnStartup and the captured context never pumps subsequent
// OnNext calls. Direct subscribe + explicit dispatcher marshal is the
// pattern proven by Console.Program.cs (engine emits, consumer marshals).
_participantsSub = controller.Participants
.Subscribe(snapshot => _dispatcher.InvokeAsync(
() => OnParticipantsChanged(snapshot),
DispatcherPriority.Background));
_alertsSub = controller.Alerts
.ObserveOn(new SynchronizationContextScheduler(
System.Threading.SynchronizationContext.Current ?? new DispatcherSynchronizationContext(_dispatcher)))
.Subscribe(alert =>
{
AlertBanner.Current = alert;
});
// 1 Hz stats poll — pull live frame counters from each running pipeline and
// push them onto the per-participant view models. Cheap (just reads volatile
// fields on the engine side) and runs on the UI dispatcher so SetField is safe.
_statsTimer = new DispatcherTimer(DispatcherPriority.Background, _dispatcher)
{
Interval = TimeSpan.FromSeconds(1),
};
_statsTimer.Tick += OnStatsTick;
_statsTimer.Start();
StopAllIsosCommand = new AsyncRelayCommand(StopAllIsosAsync, () => Participants.Any(p => p.IsEnabled));
EnableAllOnlineCommand = new AsyncRelayCommand(EnableAllOnlineAsync,
() => Participants.Any(p => p.IsOnline && !p.IsEnabled));
RefreshDiscoveryCommand = new RelayCommand(() =>
{
_controller.RefreshDiscovery();
Toast.Show("Refreshing NDI discovery…");
});
ToggleThemeCommand = new RelayCommand(() =>
{
// ThemeManager.Toggle persists the new preference to UIPreferences
// and fires the resource-dictionary swap on the dispatcher thread.
Services.ThemeManager.Current.Toggle();
});
OpenCommandPaletteCommand = new RelayCommand(() => _openCommandPalette?.Invoke());
ShowHelpCommand = new RelayCommand(() =>
{
// Showing a Window from a VM violates strict MVVM, but Dragon-ISO doesn't
// ship a navigation service and a HelpWindow is purely a UI concern.
// Owner is set so the dialog centers and inherits z-order.
var help = new HelpWindow { Owner = System.Windows.Application.Current?.MainWindow };
help.ShowDialog();
});
ShowNotesCommand = new RelayCommand(() =>
{
var notes = new NotesWindow { Owner = System.Windows.Application.Current?.MainWindow };
notes.Show(); // non-modal so operators can stamp + read alongside the show
});
SnapshotAllCommand = new RelayCommand(SnapshotAll, () => Participants.Any(p => p.IsEnabled));
ToggleByIndexCommand = new RelayCommand<string>(s =>
{
// Numpad / digit hotkeys pass "1".."9" as a string. Resolve
// against the filtered/sorted view so the index matches what
// the operator sees on screen, not the underlying storage order.
if (!int.TryParse(s, out var idx) || idx < 1 || idx > 9) return;
var i = 0;
foreach (var item in ParticipantsView)
{
if (item is not ParticipantViewModel p) continue;
if (++i == idx)
{
if (p.ToggleIsoCommand.CanExecute(null))
p.ToggleIsoCommand.Execute(null);
break;
}
}
});
JoinMeetingCommand = new RelayCommand(JoinPastedMeeting);
ToggleMuteCommand = MakeTeamsCommand(
label: "Mute",
invoke: TeamsControlBridge.ToggleMute,
successMessage: "Toggled mute");
ToggleCameraCommand = MakeTeamsCommand(
label: "Camera",
invoke: TeamsControlBridge.ToggleCamera,
successMessage: "Toggled camera");
LeaveCallCommand = MakeTeamsCommand(
label: "Leave",
invoke: TeamsControlBridge.LeaveCall,
successMessage: "Left the call");
OpenShareTrayCommand = MakeTeamsCommand(
label: "Share",
invoke: TeamsControlBridge.OpenShareTray,
successMessage: "Opened share tray");
}
// Body methods extracted to themed partial files:
// MainViewModel.BulkCommands.cs — EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll
// MainViewModel.TeamsCommands.cs — MakeTeamsCommand, JoinPastedMeeting,
// ExtractMeetingTitle, PollTeamsMeetingState
// MainViewModel.PresetCommands.cs — RequestApplyPresetOnStartup,
// LoadPendingPresetFromPreferences,
// TryAutoApplyPendingPreset
private void OnStatsTick(object? sender, EventArgs e)
{
foreach (var vm in Participants)
{
try
{
var stats = _controller.GetStats(vm.Id);
vm.UpdateStats(stats);
// Refresh preview thumbnail from the engine's most recent
// processed frame. Returns null if no pipeline is running for
// this participant; UpdateThumbnail short-circuits in that
// case, leaving the previous frame in place rather than
// visibly blanking when the pipeline restarts.
vm.UpdateThumbnail(_controller.GetLatestProcessedFrame(vm.Id));
}
catch
{
// Stats are advisory; never let a transient read failure
// tear down the timer or surface an error to the user.
}
}
// Active-speaker highlight: find the loudest enabled participant
// and mark their IsActiveSpeaker flag. Only one row at a time;
// ties broken by enumeration order (first one wins). Threshold of
// 0.05 prevents constant flicker between near-silent participants
// when nobody's really speaking.
ParticipantViewModel? loudest = null;
double loudestLevel = 0.05;
foreach (var p in Participants)
{
if (!p.IsEnabled) continue;
if (p.DisplayedAudioLevel > loudestLevel)
{
loudest = p;
loudestLevel = p.DisplayedAudioLevel;
}
}
foreach (var p in Participants)
{
var shouldHighlight = ReferenceEquals(p, loudest);
if (p.IsActiveSpeaker != shouldHighlight)
p.IsActiveSpeaker = shouldHighlight;
}
// If sort mode is LoudestFirst, refresh the view so the new audio
// peaks re-evaluate the sort. Skipped for the other sort modes
// since their keys (name, online state) don't change every tick —
// no need to pay the Refresh cost.
if (_currentSortMode == Services.UIPreferences.SortMode.LoudestFirst)
{
try { ParticipantsView.Refresh(); }
catch { /* defensive — Refresh occasionally throws on collection mutations */ }
}
// Update footer badges. Recording count is "ISOs that have a recorder
// attached" — _controller.RecordingEnabled tells us the global toggle,
// but the actual recorder count = number of running pipelines while
// that toggle was on (transient enables can mean fewer recorders than
// running pipelines). Approximate by ANDing global toggle + running
// ISO count; close enough for an at-a-glance footer.
var totalParticipants = Participants.Count;
var enabledCount = Participants.Count(p => p.IsEnabled);
// Recording-elapsed timer + disk-free polling removed alongside the rest
// of the recording surface.
// Expose counts as VM properties for the v2 transport-strip binding.
// The strip's "PART 4 · LIVE 2" reads these — pushing them on the
// 1Hz tick keeps the cost off the per-frame UI path.
ParticipantCount = totalParticipants;
LiveCount = enabledCount;
// IsDiscovering gates the "Scanning for NDI sources…" placeholder.
// True for DiscoveryGracePeriod after engine start AS LONG AS we
// haven't seen any participants yet; once anything arrives we drop
// out of the discovering state immediately (back to the OK path).
if (totalParticipants == 0 && _engineStartedAt is { } startedAt)
{
IsDiscovering = DateTimeOffset.UtcNow - startedAt < DiscoveryGracePeriod;
}
else if (IsDiscovering)
{
IsDiscovering = false;
}
// Session timer — start on first ISO going live, reset when none are
// live anymore. Subsequent enables after a full-zero gap restart the
// timer rather than resuming, which is the operator's mental model:
// "the show started when the first feed went live."
if (enabledCount > 0)
{
_sessionStartedAt ??= DateTimeOffset.UtcNow;
var elapsed = DateTimeOffset.UtcNow - _sessionStartedAt.Value;
SessionElapsed = elapsed.TotalHours >= 1
? $"{(int)elapsed.TotalHours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"
: $"{elapsed.Minutes:D2}:{elapsed.Seconds:D2}";
IsSessionActive = true;
}
else if (_sessionStartedAt is not null)
{
_sessionStartedAt = null;
SessionElapsed = string.Empty;
IsSessionActive = false;
}
// Dynamic status text — replaces the static "Engine running at X fps"
// once ISOs are live. The framerate target is still implicit (the user
// set it in OUTPUT settings; surfacing it constantly steals footer
// real estate from more-actionable info).
if (totalParticipants == 0)
{
StatusText = "Discovering NDI sources…";
}
else if (enabledCount == 0)
{
StatusText = totalParticipants == 1
? "1 participant visible"
: $"{totalParticipants} participants visible";
}
else
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
}
// Teams meeting state — UIA traversal at 1Hz; off-thread so a slow
// UIA call doesn't stall the UI tick. Implementation in
// MainViewModel.TeamsCommands.cs.
PollTeamsMeetingState();
// Control-surface state — peek at App's owned services.
var app = System.Windows.Application.Current as App;
var rest = app?.ControlSurface?.IsRunning ?? false;
var osc = app?.OscBridge?.IsRunning ?? false;
IsControlSurfaceRunning = rest || osc;
// When LAN-reachable mode is on, the footer text shows the routable
// URL instead of just the port — operators setting up a thin client
// shouldn't have to open Settings to find what to type into a
// browser. We trust the Settings VM's ControlSurfaceLanReachable
// boolean since that's where the toggle is persisted.
var lanMode = rest && (app?.ControlSurface?.BoundToLan ?? false);
var lanHost = lanMode ? Settings.ControlSurfaceUrl.Replace("/ui", "") : null;
ControlSurfaceText = (rest, osc) switch
{
(true, true) when lanMode => $"{lanHost} + OSC :{app!.OscBridge!.Port}",
(true, false) when lanMode => lanHost!,
(true, true) => $"REST :{app!.ControlSurface!.Port} + OSC :{app.OscBridge!.Port}",
(true, false) => $"REST :{app!.ControlSurface!.Port}",
(false, true) => $"OSC :{app!.OscBridge!.Port}",
_ => string.Empty,
};
}
public async Task InitializeAsync(CancellationToken cancellationToken)
{
StatusText = "Discovering NDI sources…";
_engineStartedAt = DateTimeOffset.UtcNow;
IsDiscovering = true;
await _controller.StartAsync(cancellationToken);
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
// Auto-apply last preset bookkeeping. We don't apply here —
// participants haven't been discovered yet — instead we record
// the intent and let OnParticipantsChanged trigger the apply
// once the meeting has populated. Implementation in
// MainViewModel.PresetCommands.cs.
LoadPendingPresetFromPreferences();
}
private void OnParticipantsChanged(IReadOnlyList<Participant> incoming)
{
var seenIds = new HashSet<Guid>();
var hideLocal = Settings.HideLocalSelf;
var autoDisable = Settings.AutoDisableOnDeparture;
foreach (var p in incoming)
{
// The new Teams client emits a "(Local)" pseudo-participant for the user's
// own preview — operators rarely want it as a routable ISO. Suppress when
// HideLocalSelf is on (default).
if (hideLocal && IsLocalSelf(p)) continue;
seenIds.Add(p.Id);
if (_byId.TryGetValue(p.Id, out var vm))
{
var wasOnline = vm.IsOnline;
vm.Update(p);
// Departure: source went from non-null to null. Always toast so the
// operator notices, even when AutoDisableOnDeparture is off — the
// ISO might still be "running" but emitting a slate frame, which
// looks fine in Dragon-ISO's UI but is broken downstream.
if (wasOnline && !vm.IsOnline && vm.IsEnabled)
{
if (autoDisable)
{
var captured = vm;
_ = Task.Run(async () =>
{
try { await _controller.DisableIsoAsync(captured.Id, CancellationToken.None); }
catch { /* defensive */ }
await _dispatcher.InvokeAsync(() =>
{
captured.IsEnabled = false;
Toast.Show($"Auto-disabled ISO: {captured.DisplayName} left the meeting");
});
});
}
else
{
// ISO stays running on a slate frame; warn the operator so
// they can decide whether to disable manually.
Toast.Warn($"{vm.DisplayName} disconnected — ISO still running on slate");
}
}
}
else
{
vm = new ParticipantViewModel(_controller, p, Toast);
_byId[p.Id] = vm;
Participants.Add(vm);
}
}
// Remove participants no longer present (or now hidden by the filter).
for (var i = Participants.Count - 1; i >= 0; i--)
{
var vm = Participants[i];
if (!seenIds.Contains(vm.Id))
{
_byId.Remove(vm.Id);
Participants.RemoveAt(i);
}
}
// Auto-apply-last-preset, second half: once participants populate, kick the
// apply. We fire it under either of two conditions: (a) every display name
// referenced by the preset is present (best case — the meeting is fully
// populated, no skipped assignments), or (b) the grace deadline has passed
// (give up waiting and apply with whoever's online).
if (_pendingPresetName is not null && !_pendingPresetApplied)
{
TryAutoApplyPendingPreset();
}
}
private static bool IsLocalSelf(Participant p) =>
string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);
public void Dispose()
{
_statsTimer.Stop();
_statsTimer.Tick -= OnStatsTick;
_participantsSub.Dispose();
_alertsSub.Dispose();
}
}

View file

@ -1,23 +0,0 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace DragonISO.App.ViewModels;
/// <summary>
/// Minimal MVVM base class implementing <see cref="INotifyPropertyChanged"/>.
/// </summary>
public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
}

View file

@ -1,575 +0,0 @@
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Domain;
using DragonISO.Engine.Pipeline;
using IsoHealthStats = DragonISO.Engine.Domain.IsoHealthStats;
namespace DragonISO.App.ViewModels;
/// <summary>
/// Per-row view model for a participant in the participant list.
/// Wraps a domain <see cref="Participant"/> and exposes ISO toggle and naming commands.
/// </summary>
public sealed class ParticipantViewModel : ObservableObject
{
private readonly IIsoController _controller;
private readonly ToastViewModel? _toast;
private Participant _participant;
private bool _isEnabled;
private bool _isProcessing;
private string _customName;
/// <summary>Thumbnail dimensions. 16:9 ratio matches the typical Teams output aspect.</summary>
private const int ThumbnailWidth = 160;
private const int ThumbnailHeight = 90;
/// <summary>
/// Live preview of the most recent processed frame, scaled to <see cref="ThumbnailWidth"/>×
/// <see cref="ThumbnailHeight"/>. Updated by <see cref="UpdateThumbnail"/> on the UI
/// thread, called from MainViewModel's stats tick. Null until the first frame arrives.
/// </summary>
public WriteableBitmap? Thumbnail
{
get => _thumbnail;
private set
{
if (SetField(ref _thumbnail, value))
OnPropertyChanged(nameof(HasThumbnail));
}
}
private WriteableBitmap? _thumbnail;
/// <summary>True when <see cref="Thumbnail"/> is non-null. Bound to Visibility in XAML.</summary>
public bool HasThumbnail => _thumbnail is not null;
/// <summary>
/// True when this participant is currently the loudest among the live
/// set — set by MainViewModel at the 1Hz stats tick. Bound to a cyan
/// border accent on the DataGrid row so operators can spot who's
/// speaking without watching every VU bar individually.
/// </summary>
public bool IsActiveSpeaker
{
get => _isActiveSpeaker;
internal set => SetField(ref _isActiveSpeaker, value);
}
private bool _isActiveSpeaker;
public ParticipantViewModel(IIsoController controller, Participant participant, ToastViewModel? toast = null)
{
_controller = controller;
_toast = toast;
_participant = participant;
_customName = string.Empty;
ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing);
CopySourceNameCommand = new RelayCommand(() =>
{
try
{
var src = _participant.CurrentSource?.FullName;
if (!string.IsNullOrEmpty(src))
System.Windows.Clipboard.SetText(src);
}
catch
{
// Clipboard occasionally errors when something else has it locked;
// best-effort, no user-visible failure.
}
});
OpenPreviewCommand = new RelayCommand(() =>
{
// Non-modal — operator can open multiple previews at once.
// Owner is the main window so the preview centers nicely and
// closes cleanly when the host exits.
var preview = new PreviewWindow(_controller, Id, DisplayName);
preview.Show();
});
RestartIsoCommand = new AsyncRelayCommand(RestartIsoAsync,
() => _isEnabled && !_isProcessing);
SaveSnapshotCommand = new RelayCommand(SaveSnapshot, () => _isEnabled);
}
/// <summary>
/// Encode the most recent <see cref="IsoPipeline.LatestProcessedFrame"/>
/// as a PNG and write it to <c>%USERPROFILE%\Pictures\Dragon-ISO\</c>. Used
/// by the participants' context menu for grabbing a stillframe — useful
/// for highlight reels, social posts, bug reports. Best-effort: a no-op
/// + warn-toast if no frame is currently available (pipeline just spun
/// up, or recording isn't enabled). Filename includes participant name
/// + timestamp so back-to-back snapshots don't collide.
/// </summary>
private void SaveSnapshot()
{
try
{
var frame = _controller.GetLatestProcessedFrame(Id);
if (frame is null || frame.Pixels.IsEmpty)
{
_toast?.Warn("No frame available yet — try again in a few seconds");
return;
}
var dir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
"Dragon-ISO");
System.IO.Directory.CreateDirectory(dir);
var safeName = string.Join("_", DisplayName.Split(System.IO.Path.GetInvalidFileNameChars()));
if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant";
var path = System.IO.Path.Combine(dir,
$"{safeName}_{DateTimeOffset.Now:yyyyMMdd_HHmmss}.png");
// ProcessedFrame is BGRA32, top-down. WriteableBitmap with
// Bgra32 pixel format takes the bytes verbatim.
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);
_toast?.Show($"Saved snapshot: {System.IO.Path.GetFileName(path)}");
}
catch (Exception ex)
{
_toast?.Warn($"Snapshot failed: {ex.Message}");
}
}
/// <summary>
/// Disable + re-enable this pipeline. Brief delay between for the engine
/// to fully tear down before we ask for a fresh sender. The processing
/// flag suppresses the toggle button + restart action while in flight.
/// </summary>
private async Task RestartIsoAsync()
{
if (!IsEnabled) return;
IsProcessing = true;
try
{
await _controller.DisableIsoAsync(Id, CancellationToken.None);
// Short delay so any in-flight NDI sender disposal completes before
// we ask CreateSender for the same name. Empirically 250ms is plenty.
await Task.Delay(250);
var resolvedName = string.IsNullOrWhiteSpace(_customName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
Id,
DisplayName)
: _customName;
bool? recordOverride = _recordToDisk ? null : false;
await _controller.EnableIsoAsync(Id, resolvedName, recordOverride, CancellationToken.None);
// IsEnabled is already true (we never set it false); re-fire the
// change notification so any UI bindings sensitive to a transition
// observe the restart.
OnPropertyChanged(nameof(IsEnabled));
}
finally
{
IsProcessing = false;
}
}
/// <summary>
/// Refresh the preview thumbnail from the engine's most recent processed frame.
/// Must be called on the UI thread (MainViewModel's stats DispatcherTimer is fine).
/// Allocates the WriteableBitmap lazily on the first call so we don't pay for it
/// on participants that never have an ISO enabled. Skips work if the engine has
/// no frame yet (no pipeline, or pipeline still warming up).
/// </summary>
public void UpdateThumbnail(ProcessedFrame? frame)
{
if (frame is null || frame.Pixels.IsEmpty)
{
// Don't clear a previously-rendered thumbnail on transient null reads —
// a brief gap between frames shouldn't visibly blank the preview. The
// Thumbnail is only set to null when the pipeline genuinely stops, which
// we observe by IsEnabled flipping false elsewhere.
return;
}
// Defense in depth: if the engine ever hands us a frame whose pixel buffer
// doesn't match the declared dimensions (would imply an engine bug), don't
// crash the UI on IndexOutOfRangeException — silently skip this update and
// wait for a sane frame.
var expectedBytes = frame.Width * frame.Height * 4;
if (frame.Width <= 0 || frame.Height <= 0 || frame.Pixels.Length < expectedBytes)
return;
if (_thumbnail is null)
{
// 96 DPI matches the WPF default; PixelFormats.Bgra32 matches the
// engine's BGRA pixel layout so the WritePixels call is a memcpy.
// The setter fires PropertyChanged for both Thumbnail and HasThumbnail
// so the DataGrid's Visibility bindings flip in the same change cycle.
Thumbnail = new WriteableBitmap(
ThumbnailWidth, ThumbnailHeight, 96, 96, PixelFormats.Bgra32, null);
}
var thumb = _thumbnail!;
thumb.Lock();
try
{
ScaleNearestNeighborBgra(
src: frame.Pixels.Span,
srcW: frame.Width,
srcH: frame.Height,
dst: thumb.BackBuffer,
dstStride: thumb.BackBufferStride,
dstW: ThumbnailWidth,
dstH: ThumbnailHeight);
thumb.AddDirtyRect(new Int32Rect(0, 0, ThumbnailWidth, ThumbnailHeight));
}
finally
{
thumb.Unlock();
}
}
/// <summary>
/// Inline nearest-neighbor scaler — copies one downsampled BGRA frame into the
/// WriteableBitmap's back buffer. We don't reuse <see cref="ManagedNearestNeighborFrameScaler"/>
/// because it allocates a managed buffer per scale; here we want to write
/// directly into the WriteableBitmap's pinned native memory to avoid a copy.
///
/// The arithmetic is the same: for each destination pixel, compute the source
/// pixel via integer ratio and copy 4 bytes. At 1Hz × 10 thumbnails that's
/// ~144,000 pixel reads per second — negligible CPU.
/// </summary>
private static void ScaleNearestNeighborBgra(
ReadOnlySpan<byte> src, int srcW, int srcH,
IntPtr dst, int dstStride, int dstW, int dstH)
{
// Pre-compute the x-ratio table once per call so the inner loop is just two
// multiplies and a memcpy. Java-style fixed-point would be faster but for
// 160×90 the overhead is irrelevant.
Span<int> srcXFor = stackalloc int[dstW];
for (var x = 0; x < dstW; x++) srcXFor[x] = x * srcW / dstW;
unsafe
{
var dstPtr = (byte*)dst;
var srcStride = srcW * 4;
for (var y = 0; y < dstH; y++)
{
var srcY = y * srcH / dstH;
var srcRow = srcY * srcStride;
var dstRow = y * dstStride;
for (var x = 0; x < dstW; x++)
{
var srcOff = srcRow + srcXFor[x] * 4;
var dstOff = dstRow + x * 4;
dstPtr[dstOff + 0] = src[srcOff + 0];
dstPtr[dstOff + 1] = src[srcOff + 1];
dstPtr[dstOff + 2] = src[srcOff + 2];
dstPtr[dstOff + 3] = src[srcOff + 3];
}
}
}
}
public Guid Id => _participant.Id;
public string DisplayName => _participant.DisplayName;
public string SourceMachine => _participant.CurrentSource?.MachineName ?? "(disconnected)";
public string SourceFullName => _participant.CurrentSource?.FullName ?? "(disconnected)";
public bool IsOnline => _participant.CurrentSource is not null;
public bool IsEnabled
{
get => _isEnabled;
set => SetField(ref _isEnabled, value);
}
/// <summary>
/// When true (default), the operator wants this participant's ISO recorded
/// when the global recording toggle is on. When false, this participant is
/// opted out of recording even with global on. The flag is read at the
/// EnableIsoAsync call so changing it after enabling has no effect on a
/// running pipeline; operator must disable + re-enable to apply.
/// </summary>
public bool RecordToDisk
{
get => _recordToDisk;
set => SetField(ref _recordToDisk, value);
}
private bool _recordToDisk = true;
private long _framesIn;
private long _framesOut;
private long _framesDropped;
private string _incomingResolution = "—";
private string _incomingFps = "—";
/// <summary>Number of frames the receiver has captured so far.</summary>
public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }
/// <summary>Number of frames the sender has emitted so far.</summary>
public long FramesOut { get => _framesOut; set => SetField(ref _framesOut, value); }
/// <summary>Frames dropped by the closest-frame strategy when the receiver outpaces the processor.</summary>
public long FramesDropped { get => _framesDropped; set => SetField(ref _framesDropped, value); }
private string _stateLabel = "—";
private string _stateColor = "Wd.Text.Tertiary";
private double _peakAudioLevel;
private double _displayedAudioLevel;
private DateTimeOffset _lastPeakAt;
/// <summary>
/// Smoothed audio level for display (0.0 to 1.0). Decays toward 0 between
/// peak updates so the VU bar feels alive even when audio is sparse. The
/// raw peak from the engine arrives at the 1Hz stats poll; we interpolate
/// down between polls in the property getter (technically a slight
/// abstraction leak but simpler than wiring another timer).
/// </summary>
public double DisplayedAudioLevel
{
get => _displayedAudioLevel;
private set => SetField(ref _displayedAudioLevel, value);
}
/// <summary>
/// VU-bar fill width as a 0-100 number, suitable for a Width binding on
/// a fixed-size 100-px-wide indicator. Returns the displayed (decayed)
/// audio level scaled to [0, 100]; 0 when no recent audio has been seen.
/// </summary>
public double AudioLevelWidthPercent => Math.Clamp(_displayedAudioLevel * 100, 0, 100);
/// <summary>Human-readable pipeline state ("Receiving", "Error", "—").</summary>
public string StateLabel { get => _stateLabel; set => SetField(ref _stateLabel, value); }
/// <summary>Resource key of the brush to color the state pill with.</summary>
public string StateColor { get => _stateColor; set => SetField(ref _stateColor, value); }
/// <summary>Source resolution as "WxH", or em-dash when no frames have been seen yet.</summary>
public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); }
/// <summary>
/// Live incoming framerate as "59.94 fps", or em-dash when fewer than 2 frames
/// have been observed since the pipeline started. Computed in the engine via a
/// 30-frame moving window.
/// </summary>
public string IncomingFps { get => _incomingFps; set => SetField(ref _incomingFps, value); }
/// <summary>Updates the live stats display from a controller-side snapshot.</summary>
public void UpdateStats(IsoHealthStats stats)
{
// Audio level: take the new peak when it's higher than what we're
// currently displaying (instant attack), otherwise decay toward zero
// (slow release). 0.7 multiplier per 1Hz tick = ~half-life of ~1.6s,
// which feels like a real VU meter. When the engine starts feeding
// real PeakAudioLevel values, this code starts working without
// further changes.
if (stats.PeakAudioLevel > _displayedAudioLevel)
{
_displayedAudioLevel = stats.PeakAudioLevel;
_lastPeakAt = DateTimeOffset.UtcNow;
}
else
{
_displayedAudioLevel *= 0.7;
if (_displayedAudioLevel < 0.01) _displayedAudioLevel = 0;
}
_peakAudioLevel = stats.PeakAudioLevel;
OnPropertyChanged(nameof(DisplayedAudioLevel));
OnPropertyChanged(nameof(AudioLevelWidthPercent));
FramesIn = stats.FramesIn;
FramesOut = stats.FramesOut;
FramesDropped = stats.FramesDropped;
IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0
? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
: "—";
IncomingFps = stats.IncomingFps > 0
? $"{stats.IncomingFps:0.0} fps"
: "—";
(StateLabel, StateColor) = stats.State switch
{
IsoState.Receiving => ("LIVE", "Wd.Status.Live"),
IsoState.Sending => ("LIVE", "Wd.Status.Live"),
IsoState.NoSignal => ("NO SIGNAL", "Wd.Status.Warn"),
IsoState.Error => ("ERROR", "Wd.Status.Error"),
IsoState.Idle => (IsEnabled ? "STARTING" : "—", "Wd.Text.Tertiary"),
_ => ("—", "Wd.Text.Tertiary"),
};
}
public bool IsProcessing
{
get => _isProcessing;
private set
{
if (SetField(ref _isProcessing, value))
ToggleIsoCommand.RaiseCanExecuteChanged();
}
}
public string CustomName
{
get => _customName;
set
{
if (SetField(ref _customName, value))
{
OnPropertyChanged(nameof(OutputName));
OnPropertyChanged(nameof(EditableOutputName));
}
}
}
/// <summary>
/// The NDI source name Dragon-ISO will broadcast this participant as. Prefers
/// the operator's <see cref="CustomName"/> when set; otherwise renders the
/// active template (default <c>"{name}"</c>, falling back to
/// <c>Dragon-ISO_{guid}</c> when the participant has no display name yet).
/// Bound by the v2 participants table's mono "output name" column for
/// read-only display contexts.
/// </summary>
public string OutputName =>
string.IsNullOrWhiteSpace(_customName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
_participant.Id,
_participant.DisplayName)
: _customName;
/// <summary>
/// Two-way binding endpoint for the inline-editable Output column. Reads
/// resolve to whatever name will actually be broadcast (<see cref="OutputName"/>);
/// writes set <see cref="CustomName"/> with a couple of UX niceties:
///
/// • Clearing the field (empty / whitespace) reverts to the template
/// default — the user doesn't have to remember the template syntax to
/// "undo" a customization.
///
/// • Typing a value that exactly matches the resolved default is treated
/// as a no-op (CustomName stays empty), so the participant continues
/// to follow the template when their display name changes upstream.
/// Without this, typing the auto-suggested value would silently
/// "pin" the participant to a stale name forever.
/// </summary>
public string EditableOutputName
{
get => OutputName;
set
{
var trimmed = (value ?? string.Empty).Trim();
var defaultRendered = Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
_participant.Id,
_participant.DisplayName);
CustomName = string.IsNullOrWhiteSpace(trimmed) ||
string.Equals(trimmed, defaultRendered, StringComparison.Ordinal)
? string.Empty
: trimmed;
}
}
public AsyncRelayCommand ToggleIsoCommand { get; }
/// <summary>Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor.</summary>
public RelayCommand CopySourceNameCommand { get; }
/// <summary>Open a non-modal floating preview window for this participant. Multi-monitor friendly.</summary>
public RelayCommand OpenPreviewCommand { get; }
public RelayCommand SaveSnapshotCommand { get; }
/// <summary>
/// Restart the pipeline for this participant: disable, brief pause, re-enable.
/// Useful when a single feed flakes (drops climb, framerate jitters) without
/// affecting other ISOs. No-op when the pipeline isn't currently enabled.
/// </summary>
public AsyncRelayCommand RestartIsoCommand { get; }
/// <summary>Refreshes the underlying participant data (called when the controller emits an updated list).</summary>
public void Update(Participant updated)
{
_participant = updated;
OnPropertyChanged(nameof(DisplayName));
OnPropertyChanged(nameof(SourceMachine));
OnPropertyChanged(nameof(SourceFullName));
OnPropertyChanged(nameof(IsOnline));
// OutputName/EditableOutputName both derive from _participant.DisplayName
// when no per-participant CustomName is set — re-notify so the Output
// column tracks upstream Teams name changes for participants who
// haven't been manually renamed.
OnPropertyChanged(nameof(OutputName));
OnPropertyChanged(nameof(EditableOutputName));
}
private async Task ToggleIsoAsync()
{
IsProcessing = true;
try
{
if (IsEnabled)
{
await _controller.DisableIsoAsync(Id, CancellationToken.None);
IsEnabled = false;
}
else
{
// Resolve the output name: explicit per-participant CustomName
// wins; otherwise expand the operator's template (default is
// "{name}" since 0.9.0-rc19, with an empty-name fallback to
// Dragon-ISO_{guid} inside Render). Passing the rendered name
// to EnableIsoAsync as customName overrides the engine's
// DefaultOutputName path.
var resolvedName = string.IsNullOrWhiteSpace(_customName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
Id,
DisplayName)
: _customName;
// Per-participant recording opt-out: when RecordToDisk is false,
// pass a false override so the engine doesn't attach a recorder
// even if global recording is on.
bool? recordOverride = _recordToDisk ? null : false;
await _controller.EnableIsoAsync(
Id,
resolvedName,
recordOverride,
CancellationToken.None);
IsEnabled = true;
}
}
catch (InvalidOperationException)
{
// Race window: participant left the meeting between when the operator
// clicked Enable/Disable and when the engine resolved the ID. The
// controller throws InvalidOperationException with a "not currently
// visible on the network" message in this case. Surface it as a soft
// warning toast rather than letting it escape into the dispatcher's
// unhandled-exception channel (which fires a fatal crash dialog).
//
// Leave IsEnabled at its current value — the engine refused the state
// change, so the VM should reflect the actual engine state.
_toast?.Warn($"{DisplayName} just left the meeting");
}
catch (Exception ex)
{
// Defensive catch-all for any other engine-side failure (port bind
// race, pipeline factory throw, etc.). Same reasoning as above —
// an exception from an operator click should never tear down the
// dispatcher.
_toast?.Warn($"Couldn't toggle ISO for {DisplayName}: {ex.Message}");
}
finally
{
IsProcessing = false;
}
}
}

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