Compare commits
No commits in common. "main" and "phase-a-complete" have entirely different histories.
main
...
phase-a-co
221 changed files with 3908 additions and 24392 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|
@ -21,15 +21,15 @@ jobs:
|
||||||
echo "$HOME/.dotnet" >> $GITHUB_PATH
|
echo "$HOME/.dotnet" >> $GITHUB_PATH
|
||||||
echo "DOTNET_ROOT=$HOME/.dotnet" >> $GITHUB_ENV
|
echo "DOTNET_ROOT=$HOME/.dotnet" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Restore (Linux solution filter — excludes Windows-only WPF app)
|
- name: Restore (Linux solution filter — excludes Windows-only WPF app)
|
||||||
run: dotnet restore Dragon-ISO.Linux.slnf
|
run: dotnet restore TeamsISO.Linux.slnf
|
||||||
|
|
||||||
- name: Build (Release, treat warnings as errors)
|
- 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)
|
- name: Test (excluding requires=ndi)
|
||||||
run: >
|
run: >
|
||||||
dotnet test Dragon-ISO.Linux.slnf
|
dotnet test TeamsISO.Linux.slnf
|
||||||
--configuration Release
|
--configuration Release
|
||||||
--no-build
|
--no-build
|
||||||
--logger "trx;LogFileName=test-results.trx"
|
--logger "trx;LogFileName=test-results.trx"
|
||||||
|
|
@ -47,7 +47,7 @@ jobs:
|
||||||
-reports:"**/coverage.cobertura.xml" \
|
-reports:"**/coverage.cobertura.xml" \
|
||||||
-targetdir:coverage-report \
|
-targetdir:coverage-report \
|
||||||
-reporttypes:"Cobertura;TextSummary" \
|
-reporttypes:"Cobertura;TextSummary" \
|
||||||
-assemblyfilters:"+Dragon-ISO.Engine;-Dragon-ISO.Engine.NdiInterop"
|
-assemblyfilters:"+TeamsISO.Engine;-TeamsISO.Engine.NdiInterop"
|
||||||
|
|
||||||
- name: Enforce coverage threshold (80%)
|
- name: Enforce coverage threshold (80%)
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -59,14 +59,14 @@ jobs:
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
path: '**/test-results.trx'
|
path: '**/test-results.trx'
|
||||||
|
|
||||||
- name: Upload coverage report
|
- name: Upload coverage report
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: coverage-report
|
name: coverage-report
|
||||||
path: coverage-report/
|
path: coverage-report/
|
||||||
|
|
|
||||||
|
|
@ -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
9
.gitignore
vendored
|
|
@ -28,12 +28,3 @@ publish/
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Local Claude session metadata
|
|
||||||
.claude/
|
|
||||||
|
|
||||||
# Build / test output logs
|
|
||||||
*.log
|
|
||||||
full-output.txt
|
|
||||||
test-output.txt
|
|
||||||
test-run.txt
|
|
||||||
|
|
|
||||||
86
CHANGELOG.md
86
CHANGELOG.md
|
|
@ -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
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||||
<AnalysisLevel>latest</AnalysisLevel>
|
<AnalysisLevel>latest</AnalysisLevel>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.0-alpha.0</Version>
|
||||||
<Authors>Wild Dragon LLC</Authors>
|
<Authors>Wild Dragon LLC</Authors>
|
||||||
<Company>Wild Dragon LLC</Company>
|
<Company>Wild Dragon LLC</Company>
|
||||||
<Product>TeamsISO</Product>
|
<Product>TeamsISO</Product>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
126
README.md
|
|
@ -1,128 +1,20 @@
|
||||||
# TeamsISO
|
# TeamsISO
|
||||||
|
|
||||||
**Per-participant NDI ISO controller for Microsoft Teams.**
|
Per-Participant NDI ISO Controller for Microsoft Teams.
|
||||||
|
|
||||||
TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a
|
TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a live-production environment. It receives each participant's NDI stream, 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).
|
||||||
live-production environment. It receives each participant's NDI stream,
|
|
||||||
normalizes framerate / resolution / aspect / audio per a configured target,
|
|
||||||
and re-emits clean, individually-addressable NDI sources for ingestion by a
|
|
||||||
switcher — vMix, OBS, Ross, hardware capture.
|
|
||||||
|
|
||||||
> **Status:** **v1.0.0** — first general release. Windows only. Requires
|
## Status
|
||||||
> Microsoft Teams (with NDI broadcast enabled) and the NDI 6 runtime.
|
|
||||||
|
|
||||||
---
|
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
|
Requires .NET 8 SDK.
|
||||||
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.
|
|
||||||
|
|
||||||
## Install
|
dotnet build
|
||||||
|
dotnet test
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Proprietary, © Wild Dragon LLC 2026. All rights reserved.
|
Proprietary, © Wild Dragon LLC 2026.
|
||||||
|
|
|
||||||
11
TeamsISO.Linux.slnf
Normal file
11
TeamsISO.Linux.slnf
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,72 +1,58 @@
|
||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.0.31903.59
|
VisualStudioVersion = 17.0.31903.59
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{46E05E34-8A87-4986-87D3-FE0DE4E05F44}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{46E05E34-8A87-4986-87D3-FE0DE4E05F44}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine", "src\Dragon-ISO.Engine\Dragon-ISO.Engine.csproj", "{F0D24EAE-9225-4DC4-B3D2-6966077287A0}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine", "src\TeamsISO.Engine\TeamsISO.Engine.csproj", "{F0D24EAE-9225-4DC4-B3D2-6966077287A0}"
|
||||||
EndProject
|
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}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.NdiInterop", "src\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj", "{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DBDF4A1D-4215-42D5-B456-2CE7159DF848}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DBDF4A1D-4215-42D5-B456-2CE7159DF848}"
|
||||||
EndProject
|
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}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.Tests", "src\tests\TeamsISO.Engine.Tests\TeamsISO.Engine.Tests.csproj", "{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.App", "src\Dragon-ISO.App\Dragon-ISO.App.csproj", "{80DCE039-3BBC-4D3F-B44B-51F324591C29}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App", "src\TeamsISO.App\TeamsISO.App.csproj", "{80DCE039-3BBC-4D3F-B44B-51F324591C29}"
|
||||||
EndProject
|
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}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.IntegrationTests", "src\tests\TeamsISO.Engine.IntegrationTests\TeamsISO.Engine.IntegrationTests.csproj", "{A85E331D-026E-4BDE-B89C-0CC4C95001CE}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Console", "src\Dragon-ISO.Console\Dragon-ISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}"
|
Global
|
||||||
EndProject
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
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}"
|
Debug|Any CPU = Debug|Any CPU
|
||||||
EndProject
|
Release|Any CPU = Release|Any CPU
|
||||||
Global
|
EndGlobalSection
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
HideSolutionNode = FALSE
|
||||||
Release|Any CPU = Release|Any CPU
|
EndGlobalSection
|
||||||
EndGlobalSection
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
HideSolutionNode = FALSE
|
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
EndGlobalSection
|
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.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}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.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}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.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}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.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}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
EndGlobalSection
|
||||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.Build.0 = Release|Any CPU
|
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{DBDF4A1D-4215-42D5-B456-2CE7159DF848} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU
|
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
EndGlobalSection
|
||||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
EndGlobal
|
||||||
{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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
||||||
8
docs/superpowers/plans/_NEXT.md
Normal file
8
docs/superpowers/plans/_NEXT.md
Normal 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.
|
||||||
213
docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md
Normal file
213
docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md
Normal 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.
|
||||||
|
|
@ -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
15
docs/test-playbook.md
Normal 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.)
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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 |
Binary file not shown.
Binary file not shown.
|
|
@ -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 |
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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\<date>\"/>
|
|
||||||
<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>
|
|
||||||
|
|
@ -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
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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://<your-lan-ip>: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>
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
|
||||||
/// "<original> (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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
src/Dragon-ISO.App/Properties/Strings.Designer.cs
generated
36
src/Dragon-ISO.App/Properties/Strings.Designer.cs
generated
|
|
@ -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));
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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&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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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\<YYYY-MM-DD>.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue