Compare commits

...

4 commits
v1.0.2 ... main

Author SHA1 Message Date
edb7975039 rebrand: rename all TeamsISO source paths to Dragon-ISO
Some checks failed
CI / build-and-test (push) Failing after 29s
Release / build-msi (push) Failing after 21s
- Rename solution files: TeamsISO.sln/slnf -> Dragon-ISO.sln/slnf
- Rename all src/TeamsISO.* directories and project files
  to src/Dragon-ISO.* equivalents
- Update .gitignore to exclude build/test output logs
- Update ci.yml, CHANGELOG.md, build-and-test.ps1, docs references

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:18:27 -04:00
3cd2fc1dba @
rebrand installer from TeamsISO to Dragon-ISO

- Rename TeamsISO.Installer.wixproj to 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 (registry band key + Version)
- Fix release.yml: signing step referenced Dragon-ISO.exe but
  AssemblyName=DragonISO so exe is DragonISO.exe (no hyphen)
- Fix release.yml: upload-artifact@v3 to @v4, add signtool null-guard
  to MSI signing step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@
2026-05-31 11:16:40 -04:00
fc76b0dfb3 Add Dragon-ISO installer implementation plan 2026-05-31 11:02:37 -04:00
c5314ebae3 Add Dragon-ISO installer design spec 2026-05-31 10:56:30 -04:00
199 changed files with 2521 additions and 1772 deletions

View file

@ -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 TeamsISO.Linux.slnf run: dotnet restore Dragon-ISO.Linux.slnf
- name: Build (Release, treat warnings as errors) - name: Build (Release, treat warnings as errors)
run: dotnet build TeamsISO.Linux.slnf --configuration Release --no-restore run: dotnet build Dragon-ISO.Linux.slnf --configuration Release --no-restore
- name: Test (excluding requires=ndi) - name: Test (excluding requires=ndi)
run: > run: >
dotnet test TeamsISO.Linux.slnf dotnet test Dragon-ISO.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:"+TeamsISO.Engine;-TeamsISO.Engine.NdiInterop" -assemblyfilters:"+Dragon-ISO.Engine;-Dragon-ISO.Engine.NdiInterop"
- name: Enforce coverage threshold (80%) - name: Enforce coverage threshold (80%)
run: | run: |

View file

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

6
.gitignore vendored
View file

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

View file

@ -1,62 +1,62 @@
# Changelog # Changelog
All notable changes to TeamsISO are documented here. The format follows 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 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] 2026-05-17 ## [1.0.0] — 2026-05-17
First general release. Windows-only, .NET 8 WPF, NDI 6. First general release. Windows-only, .NET 8 WPF, NDI 6.
### Engine ### Engine
- **Participant discovery** over NDI with name cleanup strips the - **Participant discovery** over NDI with name cleanup — strips the
"MS Teams - " / "(Teams) " prefixes and surfaces the operator-friendly "MS Teams - " / "(Teams) " prefixes and surfaces the operator-friendly
display name. display name.
- **Per-participant ISO outputs** with normalized framerate, resolution, - **Per-participant ISO outputs** with normalized framerate, resolution,
aspect mode, and audio routing. Each ISO is an individually-addressable aspect mode, and audio routing. Each ISO is an individually-addressable
NDI source. NDI source.
- **NDI Groups** support discovery and sender. One-click "Apply - **NDI Groups** support — discovery and sender. One-click "Apply
transcoder topology" pins Teams' raw broadcasts to a private transcoder topology" pins Teams' raw broadcasts to a private
`teamsiso-input` group while TeamsISO re-emits on `Public`. `Dragon-ISO-input` group while Dragon-ISO re-emits on `Public`.
- **Self-healing finder** if the NDI runtime stalls (zero discovered - **Self-healing finder** — if the NDI runtime stalls (zero discovered
sources past a startup grace period, or sources go from present to sources past a startup grace period, or sources go from present to
empty and stay that way), the engine rebuilds the finder automatically. empty and stay that way), the engine rebuilds the finder automatically.
- **Real-time recording** per-output raw BGRA stream + `manifest.json` - **Real-time recording** — per-output raw BGRA stream + `manifest.json`
+ an FFmpeg `convert.cmd` script for post-production conversion to + an FFmpeg `convert.cmd` script for post-production conversion to
H.264 MKV. Recording is opt-in globally and per-participant. H.264 MKV. Recording is opt-in globally and per-participant.
### UI "Studio Terminal" ### UI — "Studio Terminal"
- **Dark and light themes** with a runtime swap and a system-follow mode. - **Dark and light themes** with a runtime swap and a system-follow mode.
The Wild Dragon mark, the participants-grid watermark, and every accent The Wild Dragon mark, the participants-grid watermark, and every accent
brush respond to the active theme. brush respond to the active theme.
- **Header**: brand mark, theme toggle, settings gear. - **Header**: brand mark, theme toggle, settings gear.
- **Transport strip**: session timer, participant count, live ISO count, - **Transport strip**: session timer, participant count, live ISO count,
control-surface URL at-a-glance status. control-surface URL — at-a-glance status.
- **Participants table**: 24px state LED, 106px live thumbnail preview, - **Participants table**: 24px state LED, 106px live thumbnail preview,
name + caption, 5-bar audio meter, **inline-editable output name**, name + caption, 5-bar audio meter, **inline-editable output name**,
CFG button (per-row override editor), ISO enable pill. CFG button (per-row override editor), ISO enable pill.
- **Settings drawer** slide-over from the right with OUTPUT / NETWORK / - **Settings drawer** — slide-over from the right with OUTPUT / NETWORK /
APP tabs. APP tabs.
- **Ctrl+K command palette** fuzzy search across Quick / Teams / - **Ctrl+K command palette** — fuzzy search across Quick / Teams /
Presets / Output / Network / App categories. Presets / Output / Network / App categories.
- **Live preview thumbnails** in the participants table; right-click - **Live preview thumbnails** in the participants table; right-click →
Open preview spawns a non-modal floating window suitable for a Open preview… spawns a non-modal floating window suitable for a
secondary monitor. secondary monitor.
### Output name template ### Output name template
- New default: **the speaker's display name** (`{name}`). Per-participant - New default: **the speaker's display name** (`{name}`). Per-participant
overrides are inline-editable in the table. Empty-name fallback to overrides are inline-editable in the table. Empty-name fallback to
`TEAMSISO_{guid}` keeps the NDI sender uniquely identifiable while a `Dragon-ISO_{guid}` keeps the NDI sender uniquely identifiable while a
participant's display name resolves upstream. participant's display name resolves upstream.
- Available tokens: `{name}`, `{guid}`, `{machine}`, `{timestamp}`. - Available tokens: `{name}`, `{guid}`, `{machine}`, `{timestamp}`.
### Operator presets ### Operator presets
- Save current per-participant ISO assignments + custom output names to - Save current per-participant ISO assignments + custom output names to
`%LOCALAPPDATA%\TeamsISO\presets.json`. Optional auto-apply on next `%LOCALAPPDATA%\Dragon-ISO\presets.json`. Optional auto-apply on next
launch. launch.
### Teams orchestration ### Teams orchestration
@ -71,16 +71,16 @@ First general release. Windows-only, .NET 8 WPF, NDI 6.
- REST + WebSocket on `127.0.0.1:9755` for Bitfocus Companion / Stream - REST + WebSocket on `127.0.0.1:9755` for Bitfocus Companion / Stream
Deck / custom controllers. Deck / custom controllers.
- OSC on UDP `127.0.0.1:9000` for TouchOSC. - OSC on UDP `127.0.0.1:9000` for TouchOSC.
- Self-contained HTML control panel at `/ui` open from any phone on - Self-contained HTML control panel at `/ui` — open from any phone on
the LAN. the LAN.
### Diagnostics & installer ### Diagnostics & installer
- Rolling daily Serilog logs under `%LOCALAPPDATA%\TeamsISO\logs\`. - Rolling daily Serilog logs under `%LOCALAPPDATA%\Dragon-ISO\logs\`.
- Diagnostic bundle export zips logs + config + presets for bug reports. - Diagnostic bundle export — zips logs + config + presets for bug reports.
- Forgejo-backed update check (manual or silent-on-launch, throttled to - Forgejo-backed update check (manual or silent-on-launch, throttled to
24h). 24h).
- WiX MSI installer with proper Add/Remove Programs metadata, Start Menu - WiX MSI installer with proper Add/Remove Programs metadata, Start Menu
+ Desktop shortcuts, and in-place upgrade. + Desktop shortcuts, and in-place upgrade.
[1.0.0]: https://forge.wilddragon.net/zgaetano/teamsiso/releases/tag/v1.0.0 [1.0.0]: https://forge.wilddragon.net/zgaetano/Dragon-ISO/releases/tag/v1.0.0

12
Dragon-ISO.Linux.slnf Normal file
View file

@ -0,0 +1,12 @@
{
"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"
]
}
}

14
Dragon-ISO.Windows.slnf Normal file
View file

@ -0,0 +1,14 @@
{
"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"
]
}
}

View file

@ -1,72 +1,72 @@
 
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}") = "TeamsISO.Engine", "src\TeamsISO.Engine\TeamsISO.Engine.csproj", "{F0D24EAE-9225-4DC4-B3D2-6966077287A0}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine", "src\Dragon-ISO.Engine\Dragon-ISO.Engine.csproj", "{F0D24EAE-9225-4DC4-B3D2-6966077287A0}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.NdiInterop", "src\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj", "{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine.NdiInterop", "src\Dragon-ISO.Engine.NdiInterop\Dragon-ISO.Engine.NdiInterop.csproj", "{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}"
EndProject 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}") = "TeamsISO.Engine.Tests", "src\tests\TeamsISO.Engine.Tests\TeamsISO.Engine.Tests.csproj", "{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine.Tests", "src\tests\Dragon-ISO.Engine.Tests\Dragon-ISO.Engine.Tests.csproj", "{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App", "src\TeamsISO.App\TeamsISO.App.csproj", "{80DCE039-3BBC-4D3F-B44B-51F324591C29}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.App", "src\Dragon-ISO.App\Dragon-ISO.App.csproj", "{80DCE039-3BBC-4D3F-B44B-51F324591C29}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.IntegrationTests", "src\tests\TeamsISO.Engine.IntegrationTests\TeamsISO.Engine.IntegrationTests.csproj", "{A85E331D-026E-4BDE-B89C-0CC4C95001CE}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine.IntegrationTests", "src\tests\Dragon-ISO.Engine.IntegrationTests\Dragon-ISO.Engine.IntegrationTests.csproj", "{A85E331D-026E-4BDE-B89C-0CC4C95001CE}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src\TeamsISO.Console\TeamsISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Console", "src\Dragon-ISO.Console\Dragon-ISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App.Tests", "src\tests\TeamsISO.App.Tests\TeamsISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.App.Tests", "src\tests\Dragon-ISO.App.Tests\Dragon-ISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.Build.0 = Debug|Any CPU {F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.ActiveCfg = Release|Any CPU {F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.Build.0 = Release|Any CPU {F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.Build.0 = Release|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU {E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.ActiveCfg = Release|Any CPU {E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.Build.0 = Release|Any CPU {E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.Build.0 = Release|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.Build.0 = Debug|Any CPU {F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.ActiveCfg = Release|Any CPU {F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.Build.0 = Release|Any CPU {F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.Build.0 = Release|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.Build.0 = Debug|Any CPU {80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.ActiveCfg = Release|Any CPU {80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.Build.0 = Release|Any CPU {80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.Build.0 = Release|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.Build.0 = Debug|Any CPU {A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.ActiveCfg = Release|Any CPU {A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.Build.0 = Release|Any CPU {A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.Build.0 = Release|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.Build.0 = Debug|Any CPU {B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.ActiveCfg = Release|Any CPU {B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.Build.0 = Release|Any CPU {B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} {F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6} = {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} {DBDF4A1D-4215-42D5-B456-2CE7159DF848} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848} {F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
{80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} {80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848} {A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} {C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848} {B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,22 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- <!--
TeamsISO — MSI installer (WiX v5) Dragon-ISO — MSI installer (WiX v5)
Produces: TeamsISO-Setup-<Version>.msi (per-machine install). Produces: Dragon-ISO-Setup-<Version>.msi (per-machine install).
Build: Build:
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj -c Release -r win-x64 (no self contained) -o publish/TeamsISO 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/TeamsISO.Installer.wixproj -c Release dotnet build installer/Dragon-ISO.Installer.wixproj -c Release
Runtime expectations: Runtime expectations:
- .NET 8 Desktop runtime present on target (framework-dependent build) - .NET 8 Desktop runtime present on target (framework-dependent build)
- NDI 6 Runtime present — checked in CheckNdiRuntime; absence WARNS - NDI 6 Runtime present — checked in NdiRuntimeDirV6Search; absence WARNS
but does not block install (operators can install NDI after the app) 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" <Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui"> xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package Name="TeamsISO" <Package Name="Dragon-ISO"
Manufacturer="Wild Dragon LLC" Manufacturer="Wild Dragon LLC"
Version="1.0.0.0" Version="1.0.0.0"
UpgradeCode="9F4A8B2C-1D3E-4A5B-9C6D-8E7F0A1B2C3D" UpgradeCode="9F4A8B2C-1D3E-4A5B-9C6D-8E7F0A1B2C3D"
@ -32,9 +37,9 @@
installer dialogs. installer dialogs.
--> -->
<SummaryInformation <SummaryInformation
Description="TeamsISO — per-participant NDI ISO controller for Microsoft Teams. Splits each Teams participant into a normalized NDI source for vMix / OBS / Ross / hardware switchers." 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" Manufacturer="Wild Dragon LLC"
Keywords="NDI, Microsoft Teams, ISO recording, broadcast, live production, vMix, OBS, switcher, Wild Dragon" /> 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 MajorUpgrade: a newer install replaces an older one in-place. We
@ -42,13 +47,13 @@
forward-migration path; downgrading would leave operators with a forward-migration path; downgrading would leave operators with a
config the older binary doesn't understand. config the older binary doesn't understand.
--> -->
<MajorUpgrade DowngradeErrorMessage="A newer version of TeamsISO is already installed. Uninstall it before installing this older version." <MajorUpgrade DowngradeErrorMessage="A newer version of Dragon-ISO is already installed. Uninstall it before installing this older version."
Schedule="afterInstallInitialize" /> Schedule="afterInstallInitialize" />
<!-- <!--
Single MSI feature; users see only the install/uninstall screens. Single MSI feature; users see only the install/uninstall screens.
--> -->
<Feature Id="Main" Title="TeamsISO" Level="1"> <Feature Id="Main" Title="Dragon-ISO" Level="1">
<ComponentGroupRef Id="ApplicationFiles" /> <ComponentGroupRef Id="ApplicationFiles" />
<ComponentGroupRef Id="Shortcuts" /> <ComponentGroupRef Id="Shortcuts" />
<ComponentGroupRef Id="DesktopShortcut" /> <ComponentGroupRef Id="DesktopShortcut" />
@ -56,37 +61,51 @@
</Feature> </Feature>
<!-- <!--
Friendly install UI. WixToolset.UI.wixext provides several flavors; Minimal install UI: Welcome/License -> Progress -> Finish.
WixUI_InstallDir lets the user pick the directory. No directory picker; installs to Program Files\Wild Dragon\Dragon-ISO.
--> -->
<ui:WixUI Id="WixUI_InstallDir" /> <ui:WixUI Id="WixUI_Minimal" />
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
<!-- <!--
Add/Remove Programs metadata. ARPHELPLINK is the "Help" link; ARPURLINFOABOUT Add/Remove Programs metadata. ARPHELPLINK is the "Help" link; ARPURLINFOABOUT
is the manufacturer/about link; ARPCONTACT is the support contact shown is the manufacturer/about link; ARPCONTACT is the support contact shown
when the user clicks "Support information" from the ARP entry. ARPCOMMENTS when the user clicks "Support information" from the ARP entry. ARPCOMMENTS
is the long description displayed in some Settings Apps surfaces. is the long description displayed in some Settings -> Apps surfaces.
--> -->
<Property Id="ARPHELPLINK" Value="https://forge.wilddragon.net/zgaetano/teamsiso" /> <Property Id="ARPHELPLINK" Value="https://forge.wilddragon.net/zgaetano/dragon-iso" />
<Property Id="ARPURLINFOABOUT" Value="https://wilddragon.net" /> <Property Id="ARPURLINFOABOUT" Value="https://wilddragon.net" />
<Property Id="ARPCONTACT" Value="Wild Dragon LLC — support@wilddragon.net" /> <Property Id="ARPCONTACT" Value="Wild Dragon LLC — support@wilddragon.net" />
<Property Id="ARPCOMMENTS" Value="TeamsISO turns Microsoft Teams' raw NDI broadcast into clean, normalized, per-participant NDI sources for ingestion by a live-production switcher (vMix, OBS, Ross, hardware capture). Each participant gets an individually-addressable source with configurable framerate, resolution, aspect mode, and audio routing." /> <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_InstallDir; don't redeclare. --> <!-- ARPNOMODIFY is set by WixUI_Minimal. Do not redeclare. -->
<Property Id="ARPNOREPAIR" Value="1" /> <Property Id="ARPNOREPAIR" Value="1" />
<!-- <!--
ARP icon references the same .ico the WPF host uses. WiX requires the ARP icon: references the same .ico the WPF host uses. WiX requires the
icon resource to live next to the wxs OR be reachable at build time; icon resource to live next to the wxs OR be reachable at build time;
we point at the published copy under src/TeamsISO.App/Assets so the icon 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. embedded in the MSI matches the icon in the running exe.
--> -->
<Icon Id="TeamsISOIcon" SourceFile="$(var.AssetsDir)teamsiso.ico" /> <Icon Id="DragonISOIcon" SourceFile="$(var.AssetsDir)Dragon-ISO.ico" />
<Property Id="ARPPRODUCTICON" Value="TeamsISOIcon" /> <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 NDI Runtime detection. We check for NDI_RUNTIME_DIR_V6 in the system
environment block. Missing → warn during install, don't block. The environment block. Missing -> warn during install, don't block. The
engine surfaces a clear MessageBox with an install-NDI link at first engine surfaces a clear MessageBox with an install-NDI link at first
launch if the runtime really isn't there. launch if the runtime really isn't there.
--> -->
@ -107,11 +126,11 @@
--> -->
<!-- <!--
Install layout under Program Files\Wild Dragon\TeamsISO. Install layout under Program Files\Wild Dragon\Dragon-ISO.
--> -->
<StandardDirectory Id="ProgramFiles64Folder"> <StandardDirectory Id="ProgramFiles64Folder">
<Directory Id="ManufacturerFolder" Name="Wild Dragon"> <Directory Id="ManufacturerFolder" Name="Wild Dragon">
<Directory Id="INSTALLFOLDER" Name="TeamsISO" /> <Directory Id="INSTALLFOLDER" Name="Dragon-ISO" />
</Directory> </Directory>
</StandardDirectory> </StandardDirectory>
@ -129,30 +148,33 @@
</ComponentGroup> </ComponentGroup>
<!-- <!--
Start Menu and Desktop shortcuts direct .exe targets. Start Menu and Desktop shortcuts: direct .exe targets.
Don't wrap the Target in runas.exe /trustlevel:0x20000 (or anything Don't wrap the Target in runas.exe /trustlevel:0x20000 (or anything
else that demotes the spawned process). The SAFER-restricted token else that demotes the spawned process). The SAFER-restricted token
breaks .NET 8 WPF apphost startup: the process appears alive with breaks .NET 8 WPF apphost startup: the process appears alive with
a window, but no managed code past BAML parse executes. Verified a window, but no managed code past BAML parse executes. Verified
empirically 2026-05-16 — letting TeamsISO inherit the launching empirically 2026-05-16; letting Dragon-ISO inherit the launching
token (medium or high integrity, doesn't matter) is the correct token (medium or high integrity, doesn't matter) is the correct
behavior. NDI discovery works fine at either integrity level. 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"> <ComponentGroup Id="Shortcuts" Directory="WildDragonStartMenuFolder">
<Component Id="StartMenuShortcut" Guid="*"> <Component Id="StartMenuShortcut" Guid="*">
<Shortcut Id="StartMenuTeamsISO" <Shortcut Id="StartMenuDragonISO"
Name="TeamsISO" Name="Dragon-ISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams" Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]TeamsISO.exe" Target="[INSTALLFOLDER]DragonISO.exe"
WorkingDirectory="INSTALLFOLDER" WorkingDirectory="INSTALLFOLDER"
Icon="TeamsISOIcon" /> Icon="DragonISOIcon" />
<!-- Required by ICE64: Start Menu folder must be cleaned on uninstall. --> <!-- Required by ICE64: Start Menu folder must be cleaned on uninstall. -->
<RemoveFolder Id="RemoveWildDragonStartMenuFolder" <RemoveFolder Id="RemoveWildDragonStartMenuFolder"
Directory="WildDragonStartMenuFolder" Directory="WildDragonStartMenuFolder"
On="uninstall" /> On="uninstall" />
<RegistryValue Root="HKCU" <RegistryValue Root="HKCU"
Key="Software\Wild Dragon\TeamsISO" Key="Software\Wild Dragon\Dragon-ISO"
Name="StartMenuShortcut" Name="StartMenuShortcut"
Type="integer" Type="integer"
Value="1" Value="1"
@ -163,14 +185,14 @@
<StandardDirectory Id="DesktopFolder" /> <StandardDirectory Id="DesktopFolder" />
<ComponentGroup Id="DesktopShortcut" Directory="DesktopFolder"> <ComponentGroup Id="DesktopShortcut" Directory="DesktopFolder">
<Component Id="DesktopShortcutComponent" Guid="*"> <Component Id="DesktopShortcutComponent" Guid="*">
<Shortcut Id="DesktopTeamsISO" <Shortcut Id="DesktopDragonISO"
Name="TeamsISO" Name="Dragon-ISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams" Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]TeamsISO.exe" Target="[INSTALLFOLDER]DragonISO.exe"
WorkingDirectory="INSTALLFOLDER" WorkingDirectory="INSTALLFOLDER"
Icon="TeamsISOIcon" /> Icon="DragonISOIcon" />
<RegistryValue Root="HKCU" <RegistryValue Root="HKCU"
Key="Software\Wild Dragon\TeamsISO" Key="Software\Wild Dragon\Dragon-ISO"
Name="DesktopShortcut" Name="DesktopShortcut"
Type="integer" Type="integer"
Value="1" Value="1"
@ -179,14 +201,14 @@
</ComponentGroup> </ComponentGroup>
<!-- <!--
ARP icon registry entry. Optional the MSI auto-fills most ARP ARP icon registry entry. Optional: the MSI auto-fills most ARP
fields from the Package element. We only need to point at the fields from the Package element. We only need to store the install
executable for the ARP icon. path for diagnostic / uninstall tooling.
--> -->
<ComponentGroup Id="ArpEntry" Directory="INSTALLFOLDER"> <ComponentGroup Id="ArpEntry" Directory="INSTALLFOLDER">
<Component Id="ArpIconRegistry" Guid="*"> <Component Id="ArpIconRegistry" Guid="*">
<RegistryValue Root="HKLM" <RegistryValue Root="HKLM"
Key="Software\Wild Dragon\TeamsISO" Key="Software\Wild Dragon\Dragon-ISO"
Name="InstallPath" Name="InstallPath"
Type="string" Type="string"
Value="[INSTALLFOLDER]" Value="[INSTALLFOLDER]"
@ -195,4 +217,4 @@
</ComponentGroup> </ComponentGroup>
</Package> </Package>
</Wix> </Wix>

View file

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

View file

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

View file

@ -1,16 +1,16 @@
using System.IO; using System.IO;
using System.Windows; using System.Windows;
using System.Windows.Interop; using System.Windows.Interop;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TeamsISO.App.Services; using DragonISO.App.Services;
using TeamsISO.App.ViewModels; using DragonISO.App.ViewModels;
using TeamsISO.Engine.Controller; using DragonISO.Engine.Controller;
using TeamsISO.Engine.Interop; using DragonISO.Engine.Interop;
using TeamsISO.Engine.NdiInterop; using DragonISO.Engine.NdiInterop;
using TeamsISO.Engine.Persistence; using DragonISO.Engine.Persistence;
using TeamsISO.Engine.Pipeline; using DragonISO.Engine.Pipeline;
namespace TeamsISO.App; namespace DragonISO.App;
// Linear bootstrap steps that OnStartup walks through, extracted so the // Linear bootstrap steps that OnStartup walks through, extracted so the
// main file reads as a wiring pipeline rather than a single 200-line // main file reads as a wiring pipeline rather than a single 200-line
@ -19,10 +19,10 @@ namespace TeamsISO.App;
public partial class App public partial class App
{ {
/// <summary> /// <summary>
/// Acquire the per-user named mutex that gates a single TeamsISO /// Acquire the per-user named mutex that gates a single Dragon-ISO
/// instance per Windows user. Two TeamsISOs on the same machine for /// instance per Windows user. Two Dragon-ISOs on the same machine for
/// the same user race over the NDI finder, the NDI senders, and /// the same user race over the NDI finder, the NDI senders, and
/// %APPDATA%\TeamsISO\config.json — none of those are safe to share. /// %APPDATA%\Dragon-ISO\config.json — none of those are safe to share.
/// ///
/// On loss: broadcast the bring-to-front message to wake the existing /// On loss: broadcast the bring-to-front message to wake the existing
/// instance and signal the caller to <see cref="Application.Shutdown(int)"/> /// instance and signal the caller to <see cref="Application.Shutdown(int)"/>
@ -36,7 +36,7 @@ public partial class App
_ownsSingleInstanceMutex = createdNew; _ownsSingleInstanceMutex = createdNew;
if (!createdNew) if (!createdNew)
{ {
var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront"); var bringToFront = RegisterWindowMessageW("WildDragon.DragonISO.BringToFront");
if (bringToFront != 0) if (bringToFront != 0)
SendNotifyMessageW(HWND_BROADCAST, bringToFront, IntPtr.Zero, IntPtr.Zero); SendNotifyMessageW(HWND_BROADCAST, bringToFront, IntPtr.Zero, IntPtr.Zero);
return false; return false;
@ -46,7 +46,7 @@ public partial class App
// *subsequent* launch that broadcasts our bring-to-front message // *subsequent* launch that broadcasts our bring-to-front message
// surfaces our window. Hold the delegate in a field so OnExit can // surfaces our window. Hold the delegate in a field so OnExit can
// unsubscribe cleanly (ComponentDispatcher is process-static). // unsubscribe cleanly (ComponentDispatcher is process-static).
var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront"); var bringToFrontMsg = RegisterWindowMessageW("WildDragon.DragonISO.BringToFront");
_bringToFrontHandler = (ref MSG msg, ref bool handled) => _bringToFrontHandler = (ref MSG msg, ref bool handled) =>
{ {
if (msg.message == (int)bringToFrontMsg && MainWindow is not null) if (msg.message == (int)bringToFrontMsg && MainWindow is not null)
@ -80,10 +80,10 @@ public partial class App
catch (Exception ex) catch (Exception ex)
{ {
MessageBox.Show( MessageBox.Show(
"TeamsISO could not initialize the NDI runtime.\n\n" + "Dragon-ISO could not initialize the NDI runtime.\n\n" +
"Install the NDI Runtime from https://ndi.video/tools/ and try again.\n\n" + "Install the NDI Runtime from https://ndi.video/tools/ and try again.\n\n" +
"Details: " + ex.Message, "Details: " + ex.Message,
"TeamsISO — NDI runtime missing", "Dragon-ISO — NDI runtime missing",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Error); MessageBoxImage.Error);
return false; return false;
@ -92,7 +92,7 @@ public partial class App
/// <summary> /// <summary>
/// Wire the engine: configstore, NDI runtime probe, frame scaler, /// Wire the engine: configstore, NDI runtime probe, frame scaler,
/// pipeline factory, IsoController. Doesn't start the engine that's /// pipeline factory, IsoController. Doesn't start the engine — that's
/// MainViewModel.InitializeAsync's job. /// MainViewModel.InitializeAsync's job.
/// </summary> /// </summary>
private void BootstrapEngine() private void BootstrapEngine()
@ -101,7 +101,7 @@ public partial class App
var configPath = Path.Combine( var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"TeamsISO", "config.json"); "Dragon-ISO", "config.json");
var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger<ConfigStore>()); var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger<ConfigStore>());
var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix); var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix);
@ -141,7 +141,7 @@ public partial class App
/// REST + WebSocket control surface for Stream Deck / Companion and /// REST + WebSocket control surface for Stream Deck / Companion and
/// the OSC bridge. Created always; only Started if the operator had /// the OSC bridge. Created always; only Started if the operator had
/// the toggle on in the previous session (the settings VM's setter /// the toggle on in the previous session (the settings VM's setter
/// handles the in-session flip path). Failures log + toast we don't /// handles the in-session flip path). Failures log + toast — we don't
/// want a port-bind error to block app start. /// want a port-bind error to block app start.
/// </summary> /// </summary>
private void BootstrapControlSurfaceServices() private void BootstrapControlSurfaceServices()
@ -209,8 +209,8 @@ public partial class App
/// <summary> /// <summary>
/// Auto-launch Teams in the background if the operator opted in. /// Auto-launch Teams in the background if the operator opted in.
/// Combined with AutoHideTeamsWindows this gives the "I only see /// Combined with AutoHideTeamsWindows this gives the "I only see
/// TeamsISO" experience. Fire-and-forget — a slow Teams launch must /// Dragon-ISO" experience. Fire-and-forget — a slow Teams launch must
/// not delay TeamsISO's own window from appearing. /// not delay Dragon-ISO's own window from appearing.
/// </summary> /// </summary>
private void TryAutoLaunchTeams(ILogger logger) private void TryAutoLaunchTeams(ILogger logger)
{ {
@ -242,7 +242,7 @@ public partial class App
else if (settings.AutoHideTeamsWindows && TeamsLauncher.IsRunning()) else if (settings.AutoHideTeamsWindows && TeamsLauncher.IsRunning())
{ {
// Teams is already up from a previous session. If auto-hide is // Teams is already up from a previous session. If auto-hide is
// on, hide it now so the operator's "I only see TeamsISO" rule // on, hide it now so the operator's "I only see Dragon-ISO" rule
// applies even when Teams was launched externally. // applies even when Teams was launched externally.
_ = TeamsLauncher.AutoHideAfterLaunchAsync(); _ = TeamsLauncher.AutoHideAfterLaunchAsync();
} }

View file

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

View file

@ -1,7 +1,7 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TeamsISO.App.Services; using DragonISO.App.Services;
namespace TeamsISO.App; namespace DragonISO.App;
// Background update check, throttled to once per 24h. Fire-and-forget // Background update check, throttled to once per 24h. Fire-and-forget
// so a slow / offline update server never delays startup. Surfaces a // so a slow / offline update server never delays startup. Surfaces a

View file

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

View file

@ -1,51 +1,51 @@
using System.IO; using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Windows; using System.Windows;
using System.Windows.Interop; using System.Windows.Interop;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TeamsISO.App.ViewModels; using DragonISO.App.ViewModels;
using TeamsISO.Engine.Controller; using DragonISO.Engine.Controller;
using TeamsISO.Engine.Logging; using DragonISO.Engine.Logging;
using TeamsISO.Engine.NdiInterop; using DragonISO.Engine.NdiInterop;
// Application + MessageBox aliases live in GlobalUsings.cs (project-wide). // Application + MessageBox aliases live in GlobalUsings.cs (project-wide).
// Don't redeclare here Roslyn errors with CS1537 on duplicate alias. // Don't redeclare here — Roslyn errors with CS1537 on duplicate alias.
namespace TeamsISO.App; namespace DragonISO.App;
// Split across partial files by responsibility: // Split across partial files by responsibility:
// • App.xaml.cs — class skeleton, OnStartup (the wiring // • App.xaml.cs — class skeleton, OnStartup (the wiring
// pipeline that calls into the partials), // pipeline that calls into the partials),
// OnExit, CLI arg parser. // OnExit, CLI arg parser.
// • App.Bootstrap.cs — the linear setup steps OnStartup walks // • App.Bootstrap.cs — the linear setup steps OnStartup walks
// (single-instance gate, NDI interop, engine, // (single-instance gate, NDI interop, engine,
// main window, control surface, tray icon, // main window, control surface, tray icon,
// onboarding, Teams auto-launch). // onboarding, Teams auto-launch).
// • App.CrashHandlers.cs — AppDomain / Dispatcher / Task exception // • App.CrashHandlers.cs — AppDomain / Dispatcher / Task exception
// handlers + crash dialog + LogDirectory. // handlers + crash dialog + LogDirectory.
// • App.UpdateCheckBootstrap.cs — the background update-checker // • App.UpdateCheckBootstrap.cs — the background update-checker
// kickoff (24h-throttled). // kickoff (24h-throttled).
public partial class App : Application public partial class App : Application
{ {
/// <summary> /// <summary>
/// Per-user mutex name. Including the username (acting as a SID proxy) ensures two /// Per-user mutex name. Including the username (acting as a SID proxy) ensures two
/// different Windows users can each run TeamsISO on the same machine, while one /// different Windows users can each run Dragon-ISO on the same machine, while one
/// user can't spawn duplicate instances that would contend over the NDI runtime /// user can't spawn duplicate instances that would contend over the NDI runtime
/// and the shared %APPDATA%\TeamsISO\config.json. /// and the shared %APPDATA%\Dragon-ISO\config.json.
/// ///
/// The "Global\" prefix puts the named object in the system-wide namespace /// The "Global\" prefix puts the named object in the system-wide namespace
/// (not session-local or integrity-isolated). This matters because when an /// (not session-local or integrity-isolated). This matters because when an
/// admin user has UAC effectively disabled, launches from different parents /// admin user has UAC effectively disabled, launches from different parents
/// (elevated File Explorer, non-elevated shell, etc.) can land in slightly /// (elevated File Explorer, non-elevated shell, etc.) can land in slightly
/// different security contexts. A "Local\" mutex was being created in /// different security contexts. A "Local\" mutex was being created in
/// different views per integrity level on some boxes, letting two TeamsISO /// different views per integrity level on some boxes, letting two Dragon-ISO
/// instances run concurrently the second's REST surface couldn't bind port /// 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 /// 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 /// (already held with shared=false), producing a window that looked like
/// the app but had no engine attached. Global\ closes that gap. /// the app but had no engine attached. Global\ closes that gap.
/// </summary> /// </summary>
private static readonly string SingleInstanceMutexName = private static readonly string SingleInstanceMutexName =
$"Global\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}"; $"Global\\WildDragon.DragonISO.SingleInstance.{Environment.UserName}";
private System.Threading.Mutex? _singleInstanceMutex; private System.Threading.Mutex? _singleInstanceMutex;
private bool _ownsSingleInstanceMutex; private bool _ownsSingleInstanceMutex;
@ -54,23 +54,23 @@ public partial class App : Application
private NdiInteropPInvoke? _interop; private NdiInteropPInvoke? _interop;
private IsoController? _controller; private IsoController? _controller;
private MainViewModel? _viewModel; private MainViewModel? _viewModel;
private TeamsISO.App.Services.ControlSurfaceServer? _controlSurface; private DragonISO.App.Services.ControlSurfaceServer? _controlSurface;
private TeamsISO.App.Services.OscBridge? _oscBridge; private DragonISO.App.Services.OscBridge? _oscBridge;
// _diskSpaceWatcher removed only existed to auto-disable recording at low free space. // _diskSpaceWatcher removed — only existed to auto-disable recording at low free space.
private TeamsISO.App.Services.TrayIconHost? _trayIcon; private DragonISO.App.Services.TrayIconHost? _trayIcon;
/// <summary> /// <summary>
/// REST control surface lifetime. Lives on App so the settings VM can flip /// 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. /// it on/off without us plumbing yet another DI dependency through MainViewModel.
/// Null between process startup and the OnStartup wire-up, and after OnExit. /// Null between process startup and the OnStartup wire-up, and after OnExit.
/// </summary> /// </summary>
internal TeamsISO.App.Services.ControlSurfaceServer? ControlSurface => _controlSurface; internal DragonISO.App.Services.ControlSurfaceServer? ControlSurface => _controlSurface;
/// <summary>OSC bridge (UDP) lifetime same lifecycle pattern as the REST surface.</summary> /// <summary>OSC bridge (UDP) lifetime — same lifecycle pattern as the REST surface.</summary>
internal TeamsISO.App.Services.OscBridge? OscBridge => _oscBridge; internal DragonISO.App.Services.OscBridge? OscBridge => _oscBridge;
/// <summary>Tray-icon host. Exposed so the settings VM can flip the minimize-to-tray toggle.</summary> /// <summary>Tray-icon host. Exposed so the settings VM can flip the minimize-to-tray toggle.</summary>
internal TeamsISO.App.Services.TrayIconHost? TrayIcon => _trayIcon; internal DragonISO.App.Services.TrayIconHost? TrayIcon => _trayIcon;
[DllImport("user32.dll")] [DllImport("user32.dll")]
private static extern uint RegisterWindowMessageW(string lpString); private static extern uint RegisterWindowMessageW(string lpString);
@ -82,10 +82,10 @@ public partial class App : Application
protected override async void OnStartup(StartupEventArgs e) protected override async void OnStartup(StartupEventArgs e)
{ {
// RAW TRACE captures startup BEFORE Serilog comes up. Helps diagnose // RAW TRACE — captures startup BEFORE Serilog comes up. Helps diagnose
// launches where the Serilog log stays empty (silent file-sink failure, // launches where the Serilog log stays empty (silent file-sink failure,
// pre-logger crash, weird parent-spawn environment, etc.). Writes to // pre-logger crash, weird parent-spawn environment, etc.). Writes to
// %LOCALAPPDATA%\TeamsISO\startup-trace.log. // %LOCALAPPDATA%\Dragon-ISO\startup-trace.log.
var parentName = "(unknown)"; var parentName = "(unknown)";
try { parentName = TryGetParentProcessName() ?? "(null)"; } catch { } try { parentName = TryGetParentProcessName() ?? "(null)"; } catch { }
StartupTrace.Write($"OnStartup ENTER. exe={Environment.ProcessPath} parent={parentName} args=[{string.Join(' ', e.Args)}]"); StartupTrace.Write($"OnStartup ENTER. exe={Environment.ProcessPath} parent={parentName} args=[{string.Join(' ', e.Args)}]");
@ -101,9 +101,9 @@ public partial class App : Application
StartupTrace.Write("base.OnStartup returned"); StartupTrace.Write("base.OnStartup returned");
// De-elevation via runas /trustlevel:0x20000 was tried (commits 191b2c5, // De-elevation via runas /trustlevel:0x20000 was tried (commits 191b2c5,
// 54ee578) on the theory that elevated TeamsISO can't discover NDI // 54ee578) on the theory that elevated Dragon-ISO can't discover NDI
// sources. THAT THEORY WAS WRONG verified 2026-05-16 that elevated // sources. THAT THEORY WAS WRONG — verified 2026-05-16 that elevated
// TeamsISO discovers NDI sources fine. The SAFER-restricted token // Dragon-ISO discovers NDI sources fine. The SAFER-restricted token
// produced by runas /trustlevel was the ACTUAL cause of every "no // produced by runas /trustlevel was the ACTUAL cause of every "no
// participants" report: it breaks .NET 8 WPF startup such that the // participants" report: it breaks .NET 8 WPF startup such that the
// process appears alive with a window but the managed code never gets // process appears alive with a window but the managed code never gets
@ -113,14 +113,14 @@ public partial class App : Application
if (Array.IndexOf(e.Args, "--keep-elevation") >= 0) if (Array.IndexOf(e.Args, "--keep-elevation") >= 0)
StartupTrace.Write("--keep-elevation flag present (no-op now; de-elevation removed)"); StartupTrace.Write("--keep-elevation flag present (no-op now; de-elevation removed)");
// Crash diagnostics wire the three exception channels WPF leaves open by // Crash diagnostics — wire the three exception channels WPF leaves open by
// default to a single handler that logs Fatal to Serilog. // default to a single handler that logs Fatal to Serilog.
AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandled; AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandled;
DispatcherUnhandledException += OnDispatcherUnhandled; DispatcherUnhandledException += OnDispatcherUnhandled;
System.Threading.Tasks.TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; System.Threading.Tasks.TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
StartupTrace.Write("crash handlers registered"); StartupTrace.Write("crash handlers registered");
try { TeamsISO.App.Services.ThemeManager.Current.Apply(); StartupTrace.Write("ThemeManager.Apply OK"); } try { DragonISO.App.Services.ThemeManager.Current.Apply(); StartupTrace.Write("ThemeManager.Apply OK"); }
catch (Exception ex) { StartupTrace.Write($"ThemeManager.Apply THREW: {ex}"); } catch (Exception ex) { StartupTrace.Write($"ThemeManager.Apply THREW: {ex}"); }
// Single-instance gate. Trace the mutex acquisition. // Single-instance gate. Trace the mutex acquisition.
@ -129,7 +129,7 @@ public partial class App : Application
StartupTrace.Write($"TryAcquireSingleInstance returned: {acquired}"); StartupTrace.Write($"TryAcquireSingleInstance returned: {acquired}");
if (!acquired) if (!acquired)
{ {
StartupTrace.Write("not first instance Shutdown(0)"); StartupTrace.Write("not first instance — Shutdown(0)");
Shutdown(0); Shutdown(0);
return; return;
} }
@ -141,14 +141,14 @@ public partial class App : Application
StartupTrace.Write("EngineLogging.CreateDefault OK"); StartupTrace.Write("EngineLogging.CreateDefault OK");
var logger = _loggerFactory.CreateLogger<App>(); var logger = _loggerFactory.CreateLogger<App>();
logger.LogInformation( logger.LogInformation(
"TeamsISO.App starting up. Build: {Version}. Process: {Pid}.", "DragonISO.App starting up. Build: {Version}. Process: {Pid}.",
typeof(App).Assembly.GetName().Version, typeof(App).Assembly.GetName().Version,
Environment.ProcessId); Environment.ProcessId);
StartupTrace.Write("Serilog first write attempted"); StartupTrace.Write("Serilog first write attempted");
if (!TryBootstrapNdiInterop()) if (!TryBootstrapNdiInterop())
{ {
StartupTrace.Write("TryBootstrapNdiInterop returned false Shutdown(2)"); StartupTrace.Write("TryBootstrapNdiInterop returned false — Shutdown(2)");
Shutdown(2); Shutdown(2);
return; return;
} }
@ -176,7 +176,7 @@ public partial class App : Application
StartBackgroundUpdateCheck(logger); StartBackgroundUpdateCheck(logger);
StartupTrace.Write("OnStartup COMPLETE"); StartupTrace.Write("OnStartup COMPLETE");
// 5-second post-init participant probe tells us whether discovery // 5-second post-init participant probe — tells us whether discovery
// is actually producing rows once the engine is up. // is actually producing rows once the engine is up.
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
@ -195,8 +195,8 @@ public partial class App : Application
try { _loggerFactory?.CreateLogger<App>().LogCritical(ex, "OnStartup failed before main loop"); } try { _loggerFactory?.CreateLogger<App>().LogCritical(ex, "OnStartup failed before main loop"); }
catch { /* defensive */ } catch { /* defensive */ }
MessageBox.Show( MessageBox.Show(
"TeamsISO failed to start.\n\nDetails: " + ex, "Dragon-ISO failed to start.\n\nDetails: " + ex,
"TeamsISO — startup error", "Dragon-ISO — startup error",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Error); MessageBoxImage.Error);
Shutdown(1); Shutdown(1);
@ -204,7 +204,7 @@ public partial class App : Application
} }
// De-elevation helpers (ShouldDeElevate, TryDeElevateAndExit, the // De-elevation helpers (ShouldDeElevate, TryDeElevateAndExit, the
// TEAMSISO_RELAUNCHED env var) were removed 2026-05-16. The whole // Dragon-ISO_RELAUNCHED env var) were removed 2026-05-16. The whole
// pattern was treating a symptom that wasn't actually the problem // pattern was treating a symptom that wasn't actually the problem
// (elevation does NOT break NDI Find); the SAFER token produced by // (elevation does NOT break NDI Find); the SAFER token produced by
// runas /trustlevel:0x20000 broke .NET 8 WPF startup itself, so the // runas /trustlevel:0x20000 broke .NET 8 WPF startup itself, so the
@ -237,10 +237,10 @@ public partial class App : Application
/// <summary> /// <summary>
/// Parse the supported CLI flags. Currently: /// Parse the supported CLI flags. Currently:
/// <c>--apply-preset NAME</c> apply the named preset once participants /// <c>--apply-preset NAME</c> — apply the named preset once participants
/// populate. Equivalent to running TeamsISO and clicking Presets → select → /// populate. Equivalent to running Dragon-ISO and clicking Presets → select →
/// Apply, but driven from a desktop shortcut. /// Apply, but driven from a desktop shortcut.
/// Unrecognized flags are silently ignored operators using shortcut.lnk /// Unrecognized flags are silently ignored — operators using shortcut.lnk
/// files don't need to fight argument parsers. /// files don't need to fight argument parsers.
/// </summary> /// </summary>
private void ApplyCommandLineArgs(string[] args) private void ApplyCommandLineArgs(string[] args)

View file

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View file

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,64 +1,64 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<!-- <!--
WinForms in addition to WPF for the system-tray NotifyIcon there's no WinForms in addition to WPF for the system-tray NotifyIcon — there's no
WPF equivalent. The two frameworks coexist cleanly in .NET 8; UseWindowsForms WPF equivalent. The two frameworks coexist cleanly in .NET 8; UseWindowsForms
adds System.Windows.Forms.dll without changing the application model. adds System.Windows.Forms.dll without changing the application model.
--> -->
<UseWindowsForms>true</UseWindowsForms> <UseWindowsForms>true</UseWindowsForms>
<RootNamespace>TeamsISO.App</RootNamespace> <RootNamespace>DragonISO.App</RootNamespace>
<AssemblyName>TeamsISO</AssemblyName> <AssemblyName>DragonISO</AssemblyName>
<EnableWindowsTargeting>true</EnableWindowsTargeting> <EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationIcon>Assets\teamsiso.ico</ApplicationIcon> <ApplicationIcon>Assets\Dragon-ISO.ico</ApplicationIcon>
<!-- <!--
Required by ParticipantViewModel.ScaleNearestNeighborBgra, which writes Required by ParticipantViewModel.ScaleNearestNeighborBgra, which writes
directly into a WriteableBitmap's pinned BackBuffer (IntPtr) for 10× directly into a WriteableBitmap's pinned BackBuffer (IntPtr) for 10×
better thumbnail update perf than going through Span<byte>. better thumbnail update perf than going through Span<byte>.
--> -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\TeamsISO.Engine\TeamsISO.Engine.csproj" /> <ProjectReference Include="..\Dragon-ISO.Engine\Dragon-ISO.Engine.csproj" />
<ProjectReference Include="..\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj" /> <ProjectReference Include="..\Dragon-ISO.Engine.NdiInterop\Dragon-ISO.Engine.NdiInterop.csproj" />
<!-- <!--
System.Management gives us Win32_Process via ManagementObjectSearcher, System.Management gives us Win32_Process via ManagementObjectSearcher,
used in App.xaml.cs's ShouldDeElevate() to look up the parent process used in App.xaml.cs's ShouldDeElevate() to look up the parent process
name (we re-spawn ourselves via runas /trustlevel:0x20000 when the name (we re-spawn ourselves via runas /trustlevel:0x20000 when the
parent is explorer.exe AND we're elevated that combo triggers an parent is explorer.exe AND we're elevated — that combo triggers an
NDI mDNS-isolation bug that returns zero discovered sources). NDI mDNS-isolation bug that returns zero discovered sources).
--> -->
<PackageReference Include="System.Management" Version="8.0.0" /> <PackageReference Include="System.Management" Version="8.0.0" />
</ItemGroup> </ItemGroup>
<!-- <!--
Grant the test assembly access to internal types specifically the Grant the test assembly access to internal types — specifically the
OperatorPresetStore.PathOverride hook used to redirect file IO away from OperatorPresetStore.PathOverride hook used to redirect file IO away from
%LOCALAPPDATA% during tests. We use AssemblyAttribute rather than %LOCALAPPDATA% during tests. We use AssemblyAttribute rather than
AssemblyInfo.cs so it co-locates with the project's other config. AssemblyInfo.cs so it co-locates with the project's other config.
--> -->
<ItemGroup> <ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute"> <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>TeamsISO.App.Tests</_Parameter1> <_Parameter1>Dragon-ISO.App.Tests</_Parameter1>
</AssemblyAttribute> </AssemblyAttribute>
</ItemGroup> </ItemGroup>
<!-- <!--
Strings.resx user-facing English MessageBox copy. Embedded as a Strings.resx — user-facing English MessageBox copy. Embedded as a
.NET resource so ResourceManager can resolve TeamsISO.App.Properties.Strings .NET resource so ResourceManager can resolve Dragon-ISO.App.Properties.Strings
by basename. Strings.Designer.cs is hand-written (see file comment). by basename. Strings.Designer.cs is hand-written (see file comment).
--> -->
<ItemGroup> <ItemGroup>
<EmbeddedResource Update="Properties\Strings.resx"> <EmbeddedResource Update="Properties\Strings.resx">
<LogicalName>TeamsISO.App.Properties.Strings.resources</LogicalName> <LogicalName>Dragon-ISO.App.Properties.Strings.resources</LogicalName>
</EmbeddedResource> </EmbeddedResource>
</ItemGroup> </ItemGroup>
<!-- Wild Dragon brand assets embedded as resources so the published binary is self-contained. --> <!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. -->
<ItemGroup> <ItemGroup>
<!-- Source navy-blue dragon-mark, kept for AboutWindow / installer iconography. --> <!-- Source navy-blue dragon-mark, kept for AboutWindow / installer iconography. -->
<Resource Include="Assets\dragon-mark.png" /> <Resource Include="Assets\dragon-mark.png" />
@ -67,12 +67,12 @@
a single Wd.BrandMark.Image resource key. The dark theme picks the white a single Wd.BrandMark.Image resource key. The dark theme picks the white
dragon (visible on #0A0A0A), the light theme picks the black dragon dragon (visible on #0A0A0A), the light theme picks the black dragon
(visible on #FAFAFB). Generated from dragon-mark.png via (visible on #FAFAFB). Generated from dragon-mark.png via
Assets/_recolor_dragon.py re-run if the source mark ever changes. Assets/_recolor_dragon.py — re-run if the source mark ever changes.
--> -->
<Resource Include="Assets\dragon-mark-white.png" /> <Resource Include="Assets\dragon-mark-white.png" />
<Resource Include="Assets\dragon-mark-black.png" /> <Resource Include="Assets\dragon-mark-black.png" />
<Resource Include="Assets\wild-dragon-wordmark.png" /> <Resource Include="Assets\wild-dragon-wordmark.png" />
<Resource Include="Assets\teamsiso.ico" /> <Resource Include="Assets\Dragon-ISO.ico" />
<!-- <!--
Inter Variable from rsms/inter v3.19 (OFL). Single .ttf covers every weight 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. from 100 (Thin) to 900 (Black) so we don't ship a directory of duplicates.

View file

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

View file

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

View file

@ -1,12 +1,12 @@
<Window x:Class="TeamsISO.App.MainWindow" <Window x:Class="DragonISO.App.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels" xmlns:vm="clr-namespace:DragonISO.App.ViewModels"
xmlns:conv="clr-namespace:TeamsISO.App.Converters" xmlns:conv="clr-namespace:DragonISO.App.Converters"
mc:Ignorable="d" mc:Ignorable="d"
Title="TeamsISO" Title="DragonISO"
Width="1280" Height="780" Width="1280" Height="780"
MinWidth="980" MinHeight="640" MinWidth="980" MinHeight="640"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
@ -19,7 +19,7 @@
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}"> d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
<!-- <!--
v2 SHELL — "Studio Terminal" v2 SHELL — "Studio Terminal"
(Approved 2026-05-13. Shape brief at docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md) (Approved 2026-05-13. Shape brief at docs/shapes/2026-05-13-DragonISO-v2-studio-terminal.md)
Default Windows title bar (no chromeless WindowChrome). The 32px header Default Windows title bar (no chromeless WindowChrome). The 32px header
below it carries the brand mark, wordmark, and two icon buttons: below it carries the brand mark, wordmark, and two icon buttons:
@ -105,7 +105,7 @@
Background="Transparent" Background="Transparent"
BorderThickness="0" BorderThickness="0"
Cursor="Hand" Cursor="Hand"
ToolTip="About TeamsISO"> ToolTip="About DragonISO">
<!-- Source bound to Wd.BrandMark.Image so the mark flips <!-- Source bound to Wd.BrandMark.Image so the mark flips
white↔black with the active theme (see Theme.Dark / white↔black with the active theme (see Theme.Dark /
Theme.Light). The PNG carries its own AA so HighQuality Theme.Light). The PNG carries its own AA so HighQuality
@ -114,7 +114,7 @@
Width="20" Height="20" Width="20" Height="20"
RenderOptions.BitmapScalingMode="HighQuality"/> RenderOptions.BitmapScalingMode="HighQuality"/>
</Button> </Button>
<TextBlock Text="TeamsISO" <TextBlock Text="DragonISO"
FontFamily="{StaticResource Wd.Font.Sans}" FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="13" FontSize="13"
FontWeight="Medium" FontWeight="Medium"
@ -427,7 +427,7 @@
crosses its threshold (0.2, 0.4, crosses its threshold (0.2, 0.4,
0.6, 0.8, 1.0). No averaging. 0.6, 0.8, 1.0). No averaging.
4. Output name 130px — JetBrains Mono 12 — the NDI source 4. Output name 130px — JetBrains Mono 12 — the NDI source
name TeamsISO broadcasts as. name DragonISO broadcasts as.
5. ISO toggle pill 100px — LIVE = cyan-muted fill with cyan 5. ISO toggle pill 100px — LIVE = cyan-muted fill with cyan
text; OFF = hollow neutral; ERROR text; OFF = hollow neutral; ERROR
gets the existing trigger swap. gets the existing trigger swap.
@ -638,7 +638,7 @@
</DataGridTemplateColumn> </DataGridTemplateColumn>
<!-- Col 4 — Output name (mono, INLINE EDITABLE). The NDI source <!-- Col 4 — Output name (mono, INLINE EDITABLE). The NDI source
name TeamsISO will broadcast this participant as. Defaults name DragonISO will broadcast this participant as. Defaults
to the speaker's display name; type to override per-row, to the speaker's display name; type to override per-row,
clear the field to revert to the default. EditableOutputName clear the field to revert to the default. EditableOutputName
handles both directions (see ParticipantViewModel comment). handles both directions (see ParticipantViewModel comment).
@ -1007,7 +1007,7 @@
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Margin="0,16,0,0" Margin="0,16,0,0"
Padding="0,9"/> Padding="0,9"/>
<TextBlock Text="Pins Teams' raw NDI broadcasts to a private 'teamsiso-input' group so they don't pollute the Public network. Restart Teams after applying." <TextBlock Text="Pins Teams' raw NDI broadcasts to a private 'DragonISO-input' group so they don't pollute the Public network. Restart Teams after applying."
Style="{StaticResource Wd.Text.Body}" Style="{StaticResource Wd.Text.Body}"
FontSize="11" FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}" Foreground="{DynamicResource Wd.Text.Tertiary}"
@ -1055,7 +1055,7 @@
Height="1" Height="1"
Background="{DynamicResource Wd.Border}"/> Background="{DynamicResource Wd.Border}"/>
<CheckBox Content="Launch Microsoft Teams on TeamsISO startup" <CheckBox Content="Launch Microsoft Teams on DragonISO startup"
IsChecked="{Binding Settings.LaunchTeamsOnStartup}"/> IsChecked="{Binding Settings.LaunchTeamsOnStartup}"/>
<CheckBox Content="Auto-hide Teams windows when launched" <CheckBox Content="Auto-hide Teams windows when launched"
IsChecked="{Binding Settings.AutoHideTeamsWindows}" IsChecked="{Binding Settings.AutoHideTeamsWindows}"

View file

@ -1,11 +1,11 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Windows; using System.Windows;
using System.Windows.Input; using System.Windows.Input;
using TeamsISO.App.Services; using DragonISO.App.Services;
using TeamsISO.App.ViewModels; using DragonISO.App.ViewModels;
namespace TeamsISO.App; namespace DragonISO.App;
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
@ -43,14 +43,14 @@ public partial class MainWindow : Window
private void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e) private void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
{ {
// A failure persisting window state must NEVER block the window from // A failure persisting window state must NEVER block the window from
// closing operator's shutdown comes first. WindowStateStore.Save // closing — operator's shutdown comes first. WindowStateStore.Save
// already swallows its own IO errors; this is defense-in-depth for // already swallows its own IO errors; this is defense-in-depth for
// anything that escapes (NRE, future regression, etc.). // anything that escapes (NRE, future regression, etc.).
try { WindowStateStore.Save(this); } try { WindowStateStore.Save(this); }
catch { /* best-effort: forgo placement memory for one launch */ } catch { /* best-effort: forgo placement memory for one launch */ }
} }
/// <summary>Opens the About dialog version, NDI runtime, build SHA.</summary> /// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary>
private void OnAboutClick(object sender, RoutedEventArgs e) private void OnAboutClick(object sender, RoutedEventArgs e)
{ {
var about = new AboutWindow { Owner = this }; var about = new AboutWindow { Owner = this };
@ -77,7 +77,7 @@ public partial class MainWindow : Window
/// Tracks whether we have hidden Teams' windows so the next click reverses /// 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 /// 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 /// because hidden windows still report as hidden if the operator manually
/// re-opens them and we only care about TeamsISO's own toggle history. /// re-opens them and we only care about Dragon-ISO's own toggle history.
/// </summary> /// </summary>
private bool _teamsWindowsHidden; private bool _teamsWindowsHidden;
@ -116,9 +116,9 @@ public partial class MainWindow : Window
/// <summary> /// <summary>
/// Three-state click behavior matching operator intuition: /// Three-state click behavior matching operator intuition:
/// 1. Teams not running launch it via TeamsLauncher's fallback chain. /// 1. Teams not running → launch it via TeamsLauncher's fallback chain.
/// 2. Teams running but its windows are hidden restore + foreground them. /// 2. Teams running but its windows are hidden → restore + foreground them.
/// 3. Teams running with visible windows bring the most recent to front. /// 3. Teams running with visible windows → bring the most recent to front.
/// (Stopping Teams is a right-click action; see OnLaunchTeamsRightClick.) /// (Stopping Teams is a right-click action; see OnLaunchTeamsRightClick.)
/// </summary> /// </summary>
private void OnLaunchTeamsClick(object sender, RoutedEventArgs e) private void OnLaunchTeamsClick(object sender, RoutedEventArgs e)
@ -139,8 +139,8 @@ public partial class MainWindow : Window
{ {
var autoHide = (DataContext as MainViewModel)?.Settings.AutoHideTeamsWindows ?? false; var autoHide = (DataContext as MainViewModel)?.Settings.AutoHideTeamsWindows ?? false;
toast?.Show(autoHide toast?.Show(autoHide
? "Launching Microsoft Teams (will hide windows automatically)" ? "Launching Microsoft Teams (will hide windows automatically)…"
: "Launching Microsoft Teams"); : "Launching Microsoft Teams…");
if (autoHide) if (autoHide)
{ {
_ = TeamsLauncher.AutoHideAfterLaunchAsync(); _ = TeamsLauncher.AutoHideAfterLaunchAsync();
@ -157,7 +157,7 @@ public partial class MainWindow : Window
var shown = TeamsLauncher.ShowWindows(); var shown = TeamsLauncher.ShowWindows();
_teamsWindowsHidden = false; _teamsWindowsHidden = false;
toast?.Show(shown > 0 toast?.Show(shown > 0
? $"Teams is already running surfaced {shown} window(s)" ? $"Teams is already running — surfaced {shown} window(s)"
: "Teams is running but has no visible windows yet"); : "Teams is running but has no visible windows yet");
} }
@ -165,7 +165,7 @@ public partial class MainWindow : Window
/// Right-click on the Launch button asks to stop Teams. Split out from the /// 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 /// 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 /// "open OR ambush you with a stop dialog". The confirmation dialog here is
/// intentional Stop Teams is a destructive mid-show action; explicit /// intentional — Stop Teams is a destructive mid-show action; explicit
/// confirmation is the right pattern, not the "ambush" anti-pattern that /// confirmation is the right pattern, not the "ambush" anti-pattern that
/// was fixed for left-click. The palette also offers Stop Teams for /// was fixed for left-click. The palette also offers Stop Teams for
/// keyboard-first operators. /// keyboard-first operators.
@ -208,8 +208,8 @@ public partial class MainWindow : Window
/// <summary> /// <summary>
/// Toggle the v2 settings drawer overlay. The header gear button and the /// 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 /// drawer's own Close button both call this. State is held by the
/// overlay's <see cref="UIElement.Visibility"/> directly no separate /// overlay's <see cref="UIElement.Visibility"/> directly — no separate
/// flag so the toggle is idempotent regardless of how many entry /// flag — so the toggle is idempotent regardless of how many entry
/// points open / close it. /// points open / close it.
/// </summary> /// </summary>
private void OnSettingsToggleClick(object sender, RoutedEventArgs e) private void OnSettingsToggleClick(object sender, RoutedEventArgs e)
@ -221,7 +221,7 @@ public partial class MainWindow : Window
} }
/// <summary> /// <summary>
/// Clicking the scrim behind the drawer dismisses it same affordance as /// Clicking the scrim behind the drawer dismisses it — same affordance as
/// every well-behaved slide-over on every platform. /// every well-behaved slide-over on every platform.
/// </summary> /// </summary>
private void OnSettingsScrimClick(object sender, MouseButtonEventArgs e) private void OnSettingsScrimClick(object sender, MouseButtonEventArgs e)
@ -231,7 +231,7 @@ public partial class MainWindow : Window
} }
/// <summary> /// <summary>
/// Open the v2 Ctrl+K command palette. Bound to the header K button and /// Open the v2 Ctrl+K command palette. Bound to the header ⌘K button and
/// to the Ctrl+K keyboard binding. The palette is a chromeless floating /// to the Ctrl+K keyboard binding. The palette is a chromeless floating
/// window owned by this MainWindow so it centers correctly, closes on /// window owned by this MainWindow so it centers correctly, closes on
/// Deactivated (click outside), and inherits z-order. We construct a /// Deactivated (click outside), and inherits z-order. We construct a

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,19 +1,19 @@
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
// GET / server info + endpoint catalogue. Returned as the JSON // GET / — server info + endpoint catalogue. Returned as the JSON
// homepage when a Companion / Stream Deck plugin first probes the // homepage when a Companion / Stream Deck plugin first probes the
// surface; humans see it via curl http://127.0.0.1:9755/. // surface; humans see it via curl http://127.0.0.1:9755/.
public sealed partial class ControlSurfaceServer public sealed partial class ControlSurfaceServer
{ {
private object GetServerInfo() private object GetServerInfo()
{ {
// Best-effort engine snapshot wrapped in TryRead so a transient // Best-effort engine snapshot — wrapped in TryRead so a transient
// controller error doesn't 500 the homepage poll. // controller error doesn't 500 the homepage poll.
var settings = TryRead(() => _controller.GlobalSettings); var settings = TryRead(() => _controller.GlobalSettings);
var groups = TryRead(() => _controller.GroupSettings); var groups = TryRead(() => _controller.GroupSettings);
return new return new
{ {
product = "TeamsISO", product = "Dragon-ISO",
version = typeof(ControlSurfaceServer).Assembly.GetName().Version?.ToString() ?? "unknown", version = typeof(ControlSurfaceServer).Assembly.GetName().Version?.ToString() ?? "unknown",
engine = new engine = new
{ {

View file

@ -1,11 +1,11 @@
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Text.Json; using System.Text.Json;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
// /notes/* route handlers append-only operator show-notes file. // /notes/* route handlers — append-only operator show-notes file.
// //
// POST /notes (body: { "text": "..." }) AppendNote // POST /notes (body: { "text": "..." }) → AppendNote
public sealed partial class ControlSurfaceServer public sealed partial class ControlSurfaceServer
{ {
private object AppendNote(JsonElement body, NameValueCollection query) private object AppendNote(JsonElement body, NameValueCollection query)

View file

@ -1,24 +1,24 @@
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Text.Json; using System.Text.Json;
using TeamsISO.Engine.Domain; using DragonISO.Engine.Domain;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
// /participants/* route handlers. Anything that reads or writes // /participants/* route handlers. Anything that reads or writes
// participant + per-pipeline state lives here. // participant + per-pipeline state lives here.
// //
// GET /participants GetParticipants // GET /participants → GetParticipants
// POST /participants/{id}/iso ToggleIsoByIdAsync // POST /participants/{id}/iso → ToggleIsoByIdAsync
// POST /participants/iso ToggleIsoByNameAsync // POST /participants/iso → ToggleIsoByNameAsync
// POST /participants/{id}/override SetIsoOverrideByIdAsync // POST /participants/{id}/override → SetIsoOverrideByIdAsync
// DELETE /participants/{id}/override ClearIsoOverrideByIdAsync // DELETE /participants/{id}/override → ClearIsoOverrideByIdAsync
public sealed partial class ControlSurfaceServer public sealed partial class ControlSurfaceServer
{ {
private object GetParticipants() private object GetParticipants()
{ {
var vm = _viewModel(); var vm = _viewModel();
if (vm is null) return new { participants = Array.Empty<object>() }; if (vm is null) return new { participants = Array.Empty<object>() };
// Synchronously snapshot on the UI thread ObservableCollection // Synchronously snapshot on the UI thread — ObservableCollection
// isn't safe to enumerate from this request handler's thread-pool // isn't safe to enumerate from this request handler's thread-pool
// task, and the ParticipantViewModel property reads chase // task, and the ParticipantViewModel property reads chase
// data-binding state. // data-binding state.
@ -57,7 +57,7 @@ public sealed partial class ControlSurfaceServer
} }
/// <summary> /// <summary>
/// POST /participants/{id}/override set or replace the per-pipeline /// POST /participants/{id}/override — set or replace the per-pipeline
/// override. Body fields: framerate (enum string), resolution (enum /// override. Body fields: framerate (enum string), resolution (enum
/// string), aspect (enum string), audio (enum string). All fields are /// string), aspect (enum string), audio (enum string). All fields are
/// optional; missing fields fall back to the current global value. /// optional; missing fields fall back to the current global value.
@ -87,7 +87,7 @@ public sealed partial class ControlSurfaceServer
} }; } };
} }
/// <summary>DELETE /participants/{id}/override pipeline reverts to global settings.</summary> /// <summary>DELETE /participants/{id}/override — pipeline reverts to global settings.</summary>
private async Task<object> ClearIsoOverrideByIdAsync(string path) private async Task<object> ClearIsoOverrideByIdAsync(string path)
{ {
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
@ -151,7 +151,7 @@ public sealed partial class ControlSurfaceServer
if (vm is null || dispatcher is null) if (vm is null || dispatcher is null)
return new { ok = false, error = "view-model not ready" }; return new { ok = false, error = "view-model not ready" };
// Look up the VM and snapshot its current state on the UI thread // Look up the VM and snapshot its current state on the UI thread —
// ObservableCollection enumeration and view-model property reads // ObservableCollection enumeration and view-model property reads
// both need to happen there. // both need to happen there.
var lookup = await dispatcher.InvokeAsync(() => var lookup = await dispatcher.InvokeAsync(() =>

View file

@ -1,10 +1,10 @@
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
// /presets/* route handlers. // /presets/* route handlers.
// //
// POST /presets/refresh-discovery RefreshDiscovery // POST /presets/refresh-discovery → RefreshDiscovery
// POST /presets/stop-all StopAllAsync // POST /presets/stop-all → StopAllAsync
// POST /presets/{name}/apply ApplyPresetAsync // POST /presets/{name}/apply → ApplyPresetAsync
public sealed partial class ControlSurfaceServer public sealed partial class ControlSurfaceServer
{ {
private object RefreshDiscovery() private object RefreshDiscovery()
@ -20,7 +20,7 @@ public sealed partial class ControlSurfaceServer
var dispatcher = System.Windows.Application.Current?.Dispatcher; var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null) return new { ok = false, error = "dispatcher not ready" }; if (dispatcher is null) return new { ok = false, error = "dispatcher not ready" };
// Snapshot the enabled set on the UI thread ObservableCollection // Snapshot the enabled set on the UI thread — ObservableCollection
// isn't safe to enumerate from a thread-pool task, and reading the // isn't safe to enumerate from a thread-pool task, and reading the
// IsEnabled property indirectly walks the data-binding system. // IsEnabled property indirectly walks the data-binding system.
var enabled = await dispatcher.InvokeAsync(() => var enabled = await dispatcher.InvokeAsync(() =>
@ -50,7 +50,7 @@ public sealed partial class ControlSurfaceServer
if (vm is null || dispatcher is null) if (vm is null || dispatcher is null)
return new { ok = false, error = "view-model not ready" }; return new { ok = false, error = "view-model not ready" };
// Snapshot participants on the UI thread ObservableCollection // Snapshot participants on the UI thread — ObservableCollection
// enumeration and ParticipantViewModel state reads both need to // enumeration and ParticipantViewModel state reads both need to
// happen there. PresetApplier marshals subsequent property writes // happen there. PresetApplier marshals subsequent property writes
// via the dispatcher. // via the dispatcher.

View file

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

View file

@ -1,8 +1,8 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
// GET /participants/{id}/thumbnail.bmp small BMP of the latest // GET /participants/{id}/thumbnail.bmp — small BMP of the latest
// processed frame. Used by the embedded HTML control panel for live // processed frame. Used by the embedded HTML control panel for live
// preview tiles with a cache-busting query param at ~1Hz. // preview tiles with a cache-busting query param at ~1Hz.
// //

View file

@ -1,19 +1,19 @@
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
// /topology/* route handlers read + apply / restore the machine NDI // /topology/* route handlers — read + apply / restore the machine NDI
// access-manager config so the operator can flip transcoder topology // access-manager config so the operator can flip transcoder topology
// without leaving the web UI. // without leaving the web UI.
// //
// GET /topology GetTopology // GET /topology → GetTopology
// POST /topology/apply ApplyTopologyAsync // POST /topology/apply → ApplyTopologyAsync
// POST /topology/restore RestoreTopologyAsync // POST /topology/restore → RestoreTopologyAsync
public sealed partial class ControlSurfaceServer public sealed partial class ControlSurfaceServer
{ {
/// <summary> /// <summary>
/// Report the current NDI machine topology. "mode" is "hidden" when /// Report the current NDI machine topology. "mode" is "hidden" when
/// local senders are confined to the private group (raw Teams sources /// local senders are confined to the private group (raw Teams sources
/// invisible to the rest of the LAN), "public" otherwise. Reads the /// invisible to the rest of the LAN), "public" otherwise. Reads the
/// machine NDI config file directly no caching, so the result /// machine NDI config file directly — no caching, so the result
/// reflects whatever state the file is in right now (including /// reflects whatever state the file is in right now (including
/// manual edits). /// manual edits).
/// </summary> /// </summary>
@ -37,9 +37,9 @@ public sealed partial class ControlSurfaceServer
} }
/// <summary> /// <summary>
/// Apply the transcoder topology: machine senders → <c>teamsiso-input</c>, /// Apply the transcoder topology: machine senders → <c>Dragon-ISO-input</c>,
/// receivers → <c>public + teamsiso-input</c>; engine groups updated to /// receivers → <c>public + Dragon-ISO-input</c>; engine groups updated to
/// match (discover from teamsiso-input, broadcast on public). Operator /// match (discover from Dragon-ISO-input, broadcast on public). Operator
/// MUST restart Teams afterward for it to read the new NDI config. /// MUST restart Teams afterward for it to read the new NDI config.
/// </summary> /// </summary>
private async Task<object> ApplyTopologyAsync() private async Task<object> ApplyTopologyAsync()
@ -51,7 +51,7 @@ public sealed partial class ControlSurfaceServer
} }
// Mirror what the WPF settings VM does so the engine groups + // Mirror what the WPF settings VM does so the engine groups +
// machine config stay in lockstep. // machine config stay in lockstep.
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings( var ourGroups = new DragonISO.Engine.Domain.NdiGroupSettings(
DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup, DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup,
OutputGroups: "public"); OutputGroups: "public");
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None); await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
@ -76,7 +76,7 @@ public sealed partial class ControlSurfaceServer
{ {
return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath }; return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath };
} }
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings( var ourGroups = new DragonISO.Engine.Domain.NdiGroupSettings(
DiscoveryGroups: null, DiscoveryGroups: null,
OutputGroups: null); OutputGroups: null);
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None); await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);

View file

@ -1,18 +1,18 @@
using System.Net.WebSockets; using System.Net.WebSockets;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
// GET /ws upgrades to WebSocket and pushes participant-list snapshots // GET /ws — upgrades to WebSocket and pushes participant-list snapshots
// at 4Hz with diffing (no push when nothing changed). Lets controllers // at 4Hz with diffing (no push when nothing changed). Lets controllers
// stay live-synced without polling /participants. // stay live-synced without polling /participants.
// //
// Lifecycle: // Lifecycle:
// Server's accept loop upgrades the request and hands the socket here. // • Server's accept loop upgrades the request and hands the socket here.
// HandleWebSocketAsync owns the connection until the client closes. // • HandleWebSocketAsync owns the connection until the client closes.
// The Start() method wires a 4Hz DispatcherTimer that calls // • The Start() method wires a 4Hz DispatcherTimer that calls
// PushSnapshotIfChangedAsync to fan out to every connected client. // PushSnapshotIfChangedAsync to fan out to every connected client.
public sealed partial class ControlSurfaceServer public sealed partial class ControlSurfaceServer
{ {
@ -20,7 +20,7 @@ public sealed partial class ControlSurfaceServer
/// Owns a single client connection until it closes. Sends an immediate /// 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 /// 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 /// for the next push tick), then sits in a receive loop draining any
/// incoming text — we ignore client→server messages for v1 since all /// incoming text — we ignore clientâ†server messages for v1 since all
/// commands are REST. The receive loop is the canonical way to detect /// commands are REST. The receive loop is the canonical way to detect
/// graceful close: when WebSocket.ReceiveAsync returns CloseReceived, /// graceful close: when WebSocket.ReceiveAsync returns CloseReceived,
/// we close back and remove the client. /// we close back and remove the client.
@ -33,7 +33,7 @@ public sealed partial class ControlSurfaceServer
try try
{ {
// Initial snapshot fetch synchronously on the UI thread so the // Initial snapshot — fetch synchronously on the UI thread so the
// ObservableCollection isn't enumerated cross-thread. // ObservableCollection isn't enumerated cross-thread.
await SendAsync(ws, await GetSnapshotJsonAsync()); await SendAsync(ws, await GetSnapshotJsonAsync());
@ -68,7 +68,7 @@ public sealed partial class ControlSurfaceServer
/// Dispatcher-tick handler. Reads the current participants snapshot, /// Dispatcher-tick handler. Reads the current participants snapshot,
/// and if it differs from what we last pushed, broadcasts the new /// and if it differs from what we last pushed, broadcasts the new
/// JSON to every connected client. Diffing on the JSON string is /// JSON to every connected client. Diffing on the JSON string is
/// cheap and saves wire bytes when nothing's actually changing /// cheap and saves wire bytes when nothing's actually changing —
/// typical operator workflow has long periods of no state churn /// typical operator workflow has long periods of no state churn
/// between meetings. /// between meetings.
/// </summary> /// </summary>

View file

@ -1,4 +1,4 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Net; using System.Net;
@ -8,39 +8,39 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Windows.Threading; using System.Windows.Threading;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TeamsISO.App.ViewModels; using DragonISO.App.ViewModels;
using TeamsISO.Engine.Controller; using DragonISO.Engine.Controller;
using TeamsISO.Engine.Domain; using DragonISO.Engine.Domain;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Localhost-only HTTP control surface. Lets external controllers (Bitfocus /// Localhost-only HTTP control surface. Lets external controllers (Bitfocus
/// Companion, Stream Deck plugins, Bome MIDI Translator, custom node-RED flows, /// Companion, Stream Deck plugins, Bome MIDI Translator, custom node-RED flows,
/// etc.) drive TeamsISO without needing to embed a UI binding. /// 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 /// 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 TeamsISO". /// 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 /// If a future user needs LAN access, add a token check + bind to a configurable
/// address; both are deliberately punted for v1. /// address; both are deliberately punted for v1.
/// ///
/// Endpoints (all return application/json): /// Endpoints (all return application/json):
/// ///
/// GET / server info + endpoint list /// GET / — server info + endpoint list
/// GET /participants list of {id, displayName, isOnline, isEnabled} /// GET /participants — list of {id, displayName, isOnline, isEnabled}
/// POST /participants/{id}/iso body {"enabled":bool,"customName":string?} /// POST /participants/{id}/iso — body {"enabled":bool,"customName":string?}
/// POST /participants/iso body {"displayName":string,"enabled":bool} (look up by name) /// POST /participants/iso — body {"displayName":string,"enabled":bool} (look up by name)
/// POST /presets/{name}/apply apply a saved preset /// POST /presets/{name}/apply — apply a saved preset
/// POST /presets/refresh-discovery rebuild NDI finder /// POST /presets/refresh-discovery — rebuild NDI finder
/// POST /presets/stop-all disable every running ISO /// POST /presets/stop-all — disable every running ISO
/// POST /teams/mute toggle mute via UIA /// POST /teams/mute — toggle mute via UIA
/// POST /teams/camera toggle camera via UIA /// POST /teams/camera — toggle camera via UIA
/// POST /teams/leave leave the call via UIA /// POST /teams/leave — leave the call via UIA
/// POST /teams/share open share tray via UIA /// POST /teams/share — open share tray via UIA
/// POST /teams/raise-hand toggle raise hand via UIA /// POST /teams/raise-hand — toggle raise hand via UIA
/// POST /recording body {"enabled":bool,"directory":string?} /// POST /recording — body {"enabled":bool,"directory":string?}
/// ///
/// All POST bodies are optional endpoints that take parameters accept them /// All POST bodies are optional — endpoints that take parameters accept them
/// either via JSON body or via query string (?enabled=true&amp;customName=Host). /// either via JSON body or via query string (?enabled=true&amp;customName=Host).
/// This is friendly to Companion's "URL with query string" mode. /// This is friendly to Companion's "URL with query string" mode.
/// </summary> /// </summary>
@ -93,11 +93,11 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
/// <param name="port">TCP port to listen on.</param> /// <param name="port">TCP port to listen on.</param>
/// <param name="bindToLan"> /// <param name="bindToLan">
/// When true, binds to all interfaces (<c>http://+:port/</c>) so other /// When true, binds to all interfaces (<c>http://+:port/</c>) so other
/// machines on the LAN can reach the control surface typical for /// machines on the LAN can reach the control surface — typical for
/// "headless show machine + thin client controller" setups. When false /// "headless show machine + thin client controller" setups. When false
/// (default), binds to <c>127.0.0.1</c> only. /// (default), binds to <c>127.0.0.1</c> only.
/// ///
/// LAN binding requires either running TeamsISO as Administrator OR a /// LAN binding requires either running Dragon-ISO as Administrator OR a
/// one-time URL ACL reservation at the OS level: /// one-time URL ACL reservation at the OS level:
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code> /// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
/// If neither is in place the listener throws AccessDeniedException /// If neither is in place the listener throws AccessDeniedException
@ -136,7 +136,7 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
// ObservableCollection-backed Participants list without thread races. 4Hz // ObservableCollection-backed Participants list without thread races. 4Hz
// is fast enough that operators see immediate feedback when they flip an // 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 // ISO on the Stream Deck without us spamming the wire when nothing's
// changing the snapshot serializer dedupes against the previous push. // changing — the snapshot serializer dedupes against the previous push.
var dispatcher = System.Windows.Application.Current?.Dispatcher; var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is not null) if (dispatcher is not null)
{ {
@ -205,7 +205,7 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
var res = ctx.Response; var res = ctx.Response;
// Tracks whether we should call res.Close() in the finally. WebSocket // Tracks whether we should call res.Close() in the finally. WebSocket
// upgrades transfer ownership of the connection to the WebSocket // upgrades transfer ownership of the connection to the WebSocket
// instance closing the response here would tear down the freshly- // instance — closing the response here would tear down the freshly-
// upgraded socket immediately. So we skip the finally close on that // upgraded socket immediately. So we skip the finally close on that
// path. // path.
var closeResponseInFinally = true; var closeResponseInFinally = true;
@ -235,7 +235,7 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
var body = await ReadBodyAsync(req); var body = await ReadBodyAsync(req);
// GET /ui embedded HTML control panel. Served as text/html // GET /ui — embedded HTML control panel. Served as text/html
// rather than JSON so a browser renders it directly. // rather than JSON so a browser renders it directly.
if (req.HttpMethod == "GET" && path == "/ui") if (req.HttpMethod == "GET" && path == "/ui")
{ {
@ -247,7 +247,7 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
return; return;
} }
// GET /participants/{id}/thumbnail.bmp small BMP of the latest // GET /participants/{id}/thumbnail.bmp — small BMP of the latest
// processed frame. Returns 404 when no pipeline is running for // processed frame. Returns 404 when no pipeline is running for
// this participant. The HTML control panel uses this URL with // this participant. The HTML control panel uses this URL with
// a cache-busting query param every ~1s to drive live preview // a cache-busting query param every ~1s to drive live preview
@ -293,7 +293,7 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
("POST", "/teams/share") => InvokeTeams(TeamsControlBridge.OpenShareTray, "share"), ("POST", "/teams/share") => InvokeTeams(TeamsControlBridge.OpenShareTray, "share"),
("POST", "/teams/raise-hand") => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"), ("POST", "/teams/raise-hand") => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"),
// /recording routes removed alongside the rest of the recording surface. // /recording routes removed alongside the rest of the recording surface.
// Topology read the machine NDI config to report whether raw // Topology — read the machine NDI config to report whether raw
// Teams NDI sources are hidden from the LAN, and let the // Teams NDI sources are hidden from the LAN, and let the
// operator apply / restore without leaving the web UI. // operator apply / restore without leaving the web UI.
("GET", "/topology") => GetTopology(), ("GET", "/topology") => GetTopology(),
@ -342,7 +342,7 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
} }
} }
// ─── handlers ─────────────────────────────────────────────────────── // ─── handlers ───────────────────────────────────────────────────────
// //
// Endpoint bodies live in Services/ControlSurface/Endpoints/*.cs as // Endpoint bodies live in Services/ControlSurface/Endpoints/*.cs as
// partials of this class. See HomeEndpoints, ParticipantsEndpoints, // partials of this class. See HomeEndpoints, ParticipantsEndpoints,
@ -353,7 +353,7 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
[SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")] [SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")]
private object NotFound() => new { error = "not found" }; private object NotFound() => new { error = "not found" };
// ─── helpers ──────────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────────
private static async Task<JsonElement> ReadBodyAsync(HttpListenerRequest req) private static async Task<JsonElement> ReadBodyAsync(HttpListenerRequest req)
{ {

View file

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

View file

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

View file

@ -1,13 +1,13 @@
using System.IO; using System.IO;
using System.Text; using System.Text;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Append-only show-notes log. Each call writes a timestamped line to a daily /// Append-only show-notes log. Each call writes a timestamped line to a daily
/// markdown file at <c>%LOCALAPPDATA%\TeamsISO\Notes\&lt;YYYY-MM-DD&gt;.md</c>. /// markdown file at <c>%LOCALAPPDATA%\Dragon-ISO\Notes\&lt;YYYY-MM-DD&gt;.md</c>.
/// Operators stamp notes via the REST <c>POST /notes</c> endpoint or the OSC /// Operators stamp notes via the REST <c>POST /notes</c> endpoint or the OSC
/// <c>/teamsiso/notes "..."</c> address — typically wired to a Stream Deck /// <c>/Dragon-ISO/notes "..."</c> address — typically wired to a Stream Deck
/// button so a note can be left without leaving the show. /// 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 /// We deliberately don't surface the notes inside the WPF UI: the file is
@ -20,17 +20,17 @@ public static class NotesService
private static readonly object _gate = new(); private static readonly object _gate = new();
/// <summary> /// <summary>
/// Test-only seam when set, overrides the default /// Test-only seam — when set, overrides the default
/// %LOCALAPPDATA%\TeamsISO\Notes path. Lets tests write to a /// %LOCALAPPDATA%\Dragon-ISO\Notes path. Lets tests write to a
/// tempdir without polluting the dev's real notes folder. /// tempdir without polluting the dev's real notes folder.
/// InternalsVisibleTo grants TeamsISO.App.Tests access. /// InternalsVisibleTo grants DragonISO.App.Tests access.
/// </summary> /// </summary>
internal static string? DirectoryOverride { get; set; } internal static string? DirectoryOverride { get; set; }
private static string NotesDirectory => private static string NotesDirectory =>
DirectoryOverride ?? Path.Combine( DirectoryOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Notes"); "Dragon-ISO", "Notes");
/// <summary>Today's notes file path (created lazily on first append).</summary> /// <summary>Today's notes file path (created lazily on first append).</summary>
public static string TodayPath => public static string TodayPath =>
@ -50,10 +50,10 @@ public static class NotesService
{ {
Directory.CreateDirectory(NotesDirectory); Directory.CreateDirectory(NotesDirectory);
var path = TodayPath; var path = TodayPath;
var line = $"- **{DateTimeOffset.Now:HH:mm:ss}** {text.Trim()}{Environment.NewLine}"; var line = $"- **{DateTimeOffset.Now:HH:mm:ss}** — {text.Trim()}{Environment.NewLine}";
if (!File.Exists(path)) if (!File.Exists(path))
{ {
var header = $"# TeamsISO show notes — {DateTimeOffset.Now:yyyy-MM-dd}{Environment.NewLine}{Environment.NewLine}"; var header = $"# Dragon-ISO show notes — {DateTimeOffset.Now:yyyy-MM-dd}{Environment.NewLine}{Environment.NewLine}";
File.WriteAllText(path, header, Encoding.UTF8); File.WriteAllText(path, header, Encoding.UTF8);
} }
File.AppendAllText(path, line, Encoding.UTF8); File.AppendAllText(path, line, Encoding.UTF8);

View file

@ -1,7 +1,7 @@
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Persistent named snapshots of which participants should have ISOs enabled and /// Persistent named snapshots of which participants should have ISOs enabled and
@ -10,7 +10,7 @@ namespace TeamsISO.App.Services;
/// meeting load the same preset and auto-enable everyone whose display name /// meeting load the same preset and auto-enable everyone whose display name
/// matches. /// matches.
/// ///
/// Persisted as JSON at <c>%LOCALAPPDATA%\TeamsISO\presets.json</c>. We key by /// Persisted as JSON at <c>%LOCALAPPDATA%\Dragon-ISO\presets.json</c>. We key by
/// participant <see cref="DisplayName"/> rather than <see cref="Guid"/> Id /// participant <see cref="DisplayName"/> rather than <see cref="Guid"/> Id
/// because the Id is freshly generated for every meeting (Teams' NDI source /// because the Id is freshly generated for every meeting (Teams' NDI source
/// identity isn't stable across sessions); display name is the operator's /// identity isn't stable across sessions); display name is the operator's
@ -29,7 +29,7 @@ public static class OperatorPresetStore
private static string PresetsPath => private static string PresetsPath =>
PathOverride ?? Path.Combine( PathOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Dragon-ISO",
"presets.json"); "presets.json");
/// <summary> /// <summary>
@ -77,7 +77,7 @@ public static class OperatorPresetStore
/// <summary> /// <summary>
/// Returns the operator's startup preference (which preset, if any, should be /// 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 /// 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 /// or the file predates the field — older preset.json files deserialize cleanly
/// because both fields are optional with default values. /// because both fields are optional with default values.
/// </summary> /// </summary>
public static StartupPreference GetStartupPreference() public static StartupPreference GetStartupPreference()
@ -153,8 +153,8 @@ public static class OperatorPresetStore
/// <summary> /// <summary>
/// Bundle format for the export/import surface. Wraps the preset list with /// Bundle format for the export/import surface. Wraps the preset list with
/// a version stamp + an export timestamp so a future-format-aware importer /// a version stamp + an export timestamp so a future-format-aware importer
/// can migrate the data. We deliberately export a flat preset list not /// can migrate the data. We deliberately export a flat preset list — not
/// the full <see cref="File"/> envelope because StartupPreference is /// the full <see cref="File"/> envelope — because StartupPreference is
/// machine-local (operator A's "auto-apply Friday Show" shouldn't follow /// machine-local (operator A's "auto-apply Friday Show" shouldn't follow
/// the bundle to operator B's machine). /// the bundle to operator B's machine).
/// </summary> /// </summary>
@ -163,7 +163,7 @@ public static class OperatorPresetStore
DateTimeOffset ExportedAt, DateTimeOffset ExportedAt,
IReadOnlyList<Preset> Presets) IReadOnlyList<Preset> Presets)
{ {
public const string CurrentSchema = "teamsiso-presets-bundle/v1"; public const string CurrentSchema = "Dragon-ISO-presets-bundle/v1";
} }
/// <summary> /// <summary>
@ -181,7 +181,7 @@ public static class OperatorPresetStore
} }
/// <summary> /// <summary>
/// Result of an import attempt counts so the UI can toast a clear summary. /// Result of an import attempt — counts so the UI can toast a clear summary.
/// </summary> /// </summary>
public sealed record ImportResult(int Added, int Overwritten, int Skipped, string? Error) public sealed record ImportResult(int Added, int Overwritten, int Skipped, string? Error)
{ {

View file

@ -1,39 +1,39 @@
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TeamsISO.App.ViewModels; using DragonISO.App.ViewModels;
using TeamsISO.Engine.Controller; using DragonISO.Engine.Controller;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// OSC over UDP. Companion, TouchOSC, Bome, and most lighting consoles speak /// OSC over UDP. Companion, TouchOSC, Bome, and most lighting consoles speak
/// OSC natively, so wrapping the same command surface in OSC opens the /// OSC natively, so wrapping the same command surface in OSC opens the
/// product to the broader live-show ecosystem without a Companion bridge. /// product to the broader live-show ecosystem without a Companion bridge.
/// ///
/// Protocol minimal OSC 1.0: /// Protocol — minimal OSC 1.0:
/// - Address pattern (null-terminated string, padded to 4-byte boundary) /// - Address pattern (null-terminated string, padded to 4-byte boundary)
/// - Type tag (",iiisf" etc., null-terminated, padded to 4) /// - Type tag (",iiisf" etc., null-terminated, padded to 4)
/// - Args in order /// - Args in order
/// ///
/// We don't implement bundles, time tags, blob args, or pattern matching /// 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 /// — none are needed for the verbs we support. If a sender uses bundles
/// we ignore them; if a sender uses a wildcard address ("/teamsiso/*") we /// we ignore them; if a sender uses a wildcard address ("/Dragon-ISO/*") we
/// ignore it. Operators get a clear log line in either case. /// ignore it. Operators get a clear log line in either case.
/// ///
/// Routes: /// Routes:
/// /teamsiso/iso "DisplayName" {0|1} — toggle/set ISO by display name /// /Dragon-ISO/iso "DisplayName" {0|1} — toggle/set ISO by display name
/// /teamsiso/iso/by-id "guid" {0|1} — toggle/set by Id /// /Dragon-ISO/iso/by-id "guid" {0|1} — toggle/set by Id
/// /teamsiso/preset "Name" — apply preset /// /Dragon-ISO/preset "Name" — apply preset
/// /teamsiso/teams/mute — UIA toggle mute /// /Dragon-ISO/teams/mute — UIA toggle mute
/// /teamsiso/teams/camera — UIA toggle camera /// /Dragon-ISO/teams/camera — UIA toggle camera
/// /teamsiso/teams/leave — UIA leave /// /Dragon-ISO/teams/leave — UIA leave
/// /teamsiso/teams/share — UIA share tray /// /Dragon-ISO/teams/share — UIA share tray
/// /teamsiso/teams/raise-hand — UIA raise hand /// /Dragon-ISO/teams/raise-hand — UIA raise hand
/// /teamsiso/refresh-discovery — rebuild NDI finder /// /Dragon-ISO/refresh-discovery — rebuild NDI finder
/// /teamsiso/stop-all — disable every ISO /// /Dragon-ISO/stop-all — disable every ISO
/// /teamsiso/recording {0|1} — recording on/off (default dir) /// /Dragon-ISO/recording {0|1} — recording on/off (default dir)
/// </summary> /// </summary>
public sealed class OscBridge : IAsyncDisposable public sealed class OscBridge : IAsyncDisposable
{ {
@ -63,9 +63,9 @@ public sealed class OscBridge : IAsyncDisposable
/// <summary> /// <summary>
/// Start the OSC listener on the given UDP port. <paramref name="bindToLan"/> /// Start the OSC listener on the given UDP port. <paramref name="bindToLan"/>
/// flag selects between loopback (default only this machine) and any- /// flag selects between loopback (default — only this machine) and any-
/// interface binding (LAN-reachable, for thin-client controllers). /// interface binding (LAN-reachable, for thin-client controllers).
/// Unlike the REST surface, UDP doesn't need a URL ACL binding 0.0.0.0 /// Unlike the REST surface, UDP doesn't need a URL ACL — binding 0.0.0.0
/// is just an unprivileged port reservation. /// is just an unprivileged port reservation.
/// </summary> /// </summary>
public void Start(int port, bool bindToLan = false) public void Start(int port, bool bindToLan = false)
@ -147,25 +147,25 @@ public sealed class OscBridge : IAsyncDisposable
var addr = msg.Address; var addr = msg.Address;
switch (addr) switch (addr)
{ {
case "/teamsiso/teams/mute": InvokeTeams(TeamsControlBridge.ToggleMute); return; case "/Dragon-ISO/teams/mute": InvokeTeams(TeamsControlBridge.ToggleMute); return;
case "/teamsiso/teams/camera": InvokeTeams(TeamsControlBridge.ToggleCamera); return; case "/Dragon-ISO/teams/camera": InvokeTeams(TeamsControlBridge.ToggleCamera); return;
case "/teamsiso/teams/leave": InvokeTeams(TeamsControlBridge.LeaveCall); return; case "/Dragon-ISO/teams/leave": InvokeTeams(TeamsControlBridge.LeaveCall); return;
case "/teamsiso/teams/share": InvokeTeams(TeamsControlBridge.OpenShareTray); return; case "/Dragon-ISO/teams/share": InvokeTeams(TeamsControlBridge.OpenShareTray); return;
case "/teamsiso/teams/raise-hand":InvokeTeams(TeamsControlBridge.ToggleRaiseHand); return; case "/Dragon-ISO/teams/raise-hand":InvokeTeams(TeamsControlBridge.ToggleRaiseHand); return;
case "/teamsiso/refresh-discovery":_controller.RefreshDiscovery(); return; case "/Dragon-ISO/refresh-discovery":_controller.RefreshDiscovery(); return;
case "/teamsiso/stop-all": await StopAllAsync(); return; case "/Dragon-ISO/stop-all": await StopAllAsync(); return;
// /teamsiso/recording routes removed alongside the rest of the recording surface. // /Dragon-ISO/recording routes removed alongside the rest of the recording surface.
case "/teamsiso/notes": AppendNote(msg); return; case "/Dragon-ISO/notes": AppendNote(msg); return;
case "/teamsiso/iso": await ToggleByNameAsync(msg); return; case "/Dragon-ISO/iso": await ToggleByNameAsync(msg); return;
case "/teamsiso/iso/by-id": await ToggleByIdAsync(msg); return; case "/Dragon-ISO/iso/by-id": await ToggleByIdAsync(msg); return;
case "/teamsiso/preset": await ApplyPresetAsync(msg); return; case "/Dragon-ISO/preset": await ApplyPresetAsync(msg); return;
default: default:
_logger?.LogDebug("Ignoring unknown OSC address {Addr}", addr); _logger?.LogDebug("Ignoring unknown OSC address {Addr}", addr);
return; return;
} }
} }
// ─── handler helpers ──────────────────────────────────────────────── // ─── handler helpers ────────────────────────────────────────────────
private static void InvokeTeams(Func<TeamsControlBridge.InvokeResult> action) => _ = action(); private static void InvokeTeams(Func<TeamsControlBridge.InvokeResult> action) => _ = action();
@ -265,12 +265,12 @@ public sealed class OscBridge : IAsyncDisposable
} }
} }
// ─── OSC message parser ───────────────────────────────────────────────── // ─── OSC message parser ─────────────────────────────────────────────────
/// <summary> /// <summary>
/// Minimal OSC 1.0 message parser. Supports the subset we care about: /// Minimal OSC 1.0 message parser. Supports the subset we care about:
/// integer (i), float (f), string (s) args. Bundles / time tags / blobs are /// integer (i), float (f), string (s) args. Bundles / time tags / blobs are
/// not implemented incoming packets that look like bundles return null /// not implemented — incoming packets that look like bundles return null
/// and the caller logs + skips them. /// and the caller logs + skips them.
/// </summary> /// </summary>
internal sealed class OscMessage internal sealed class OscMessage
@ -283,7 +283,7 @@ internal sealed class OscMessage
public static OscMessage? TryParse(byte[] bytes) public static OscMessage? TryParse(byte[] bytes)
{ {
if (bytes.Length < 8) return null; if (bytes.Length < 8) return null;
// Bundle marker we don't support bundles. Skip. // Bundle marker — we don't support bundles. Skip.
if (bytes[0] == '#') return null; if (bytes[0] == '#') return null;
var idx = 0; var idx = 0;
@ -317,7 +317,7 @@ internal sealed class OscMessage
case 'T': args.Add(true); break; case 'T': args.Add(true); break;
case 'F': args.Add(false); break; case 'F': args.Add(false); break;
default: default:
// Unknown type bail rather than mis-aligning subsequent args. // Unknown type — bail rather than mis-aligning subsequent args.
return null; return null;
} }
} }

View file

@ -1,16 +1,16 @@
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// User-editable template for the NDI source name a participant's ISO is /// User-editable template for the NDI source name a participant's ISO is
/// published as. Default <c>"{name}"</c> renders the speaker's display name /// published as. Default <c>"{name}"</c> renders the speaker's display name
/// directly, which is what downstream switchers want when they key on /// directly, which is what downstream switchers want when they key on
/// readable identifiers. Operators can override globally to /// readable identifiers. Operators can override globally to
/// <c>"TEAMSISO_{guid}"</c> for the legacy stable-id behavior, or /// <c>"Dragon-ISO_{guid}"</c> for the legacy stable-id behavior, or
/// <c>"TEAMSISO_{machine}_{name}"</c> when multiple TeamsISO machines feed /// <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. /// the same NDI network and you want the source name to carry both.
/// Per-participant overrides take priority over whatever template is set. /// Per-participant overrides take priority over whatever template is set.
/// ///
@ -22,16 +22,16 @@ namespace TeamsISO.App.Services;
/// ///
/// Empty-name fallback: if the rendered result is empty/whitespace (e.g. /// Empty-name fallback: if the rendered result is empty/whitespace (e.g.
/// template was <c>"{name}"</c> and the participant joined with no display /// template was <c>"{name}"</c> and the participant joined with no display
/// name yet), <see cref="Render"/> falls back to <c>TEAMSISO_{guid}</c> so /// name yet), <see cref="Render"/> falls back to <c>Dragon-ISO_{guid}</c> so
/// the NDI sender always has a usable, unique identifier. /// the NDI sender always has a usable, unique identifier.
/// ///
/// Persisted to <c>%LOCALAPPDATA%\TeamsISO\output-name-template.txt</c>. /// Persisted to <c>%LOCALAPPDATA%\Dragon-ISO\output-name-template.txt</c>.
/// </summary> /// </summary>
public static class OutputNameTemplate public static class OutputNameTemplate
{ {
/// <summary> /// <summary>
/// Default template renders just the speaker's display name. Was /// Default template — renders just the speaker's display name. Was
/// <c>"TEAMSISO_{guid}"</c> in pre-v1 builds; switched 2026-05-16 so /// <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. /// new installs get human-readable source names out of the box.
/// </summary> /// </summary>
public const string DefaultTemplate = "{name}"; public const string DefaultTemplate = "{name}";
@ -42,12 +42,12 @@ public static class OutputNameTemplate
/// Mirrors the engine's legacy DefaultOutputName so the NDI sender is /// Mirrors the engine's legacy DefaultOutputName so the NDI sender is
/// always uniquely identifiable. /// always uniquely identifiable.
/// </summary> /// </summary>
private const string EmptyNameFallback = "TEAMSISO_{guid}"; private const string EmptyNameFallback = "Dragon-ISO_{guid}";
private static string TemplatePath => private static string TemplatePath =>
Path.Combine( Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "output-name-template.txt"); "Dragon-ISO", "output-name-template.txt");
/// <summary> /// <summary>
/// Get the operator's current template, or the shipped default when no /// Get the operator's current template, or the shipped default when no
@ -65,7 +65,7 @@ public static class OutputNameTemplate
} }
catch catch
{ {
// Disk read failure fall through to default. The next Set() call // Disk read failure → fall through to default. The next Set() call
// will overwrite cleanly. // will overwrite cleanly.
} }
return DefaultTemplate; return DefaultTemplate;
@ -105,7 +105,7 @@ public static class OutputNameTemplate
.Replace("{machine}", machine) .Replace("{machine}", machine)
.Replace("{timestamp}", timestamp); .Replace("{timestamp}", timestamp);
// Final sanitize on the rendered result protects against a template // Final sanitize on the rendered result — protects against a template
// that includes literal characters NDI doesn't accept. // that includes literal characters NDI doesn't accept.
var sanitized = SanitizeForNdi(result); var sanitized = SanitizeForNdi(result);
@ -114,13 +114,13 @@ public static class OutputNameTemplate
// populated yet (Teams sometimes delivers the displayName a tick // populated yet (Teams sometimes delivers the displayName a tick
// after the participant join event). Two failure modes to catch: // after the participant join event). Two failure modes to catch:
// //
// • DisplayName == "" → "{name}" expands to "" → sanitized "". // • DisplayName == "" → "{name}" expands to "" → sanitized "".
// • DisplayName == " " → "{name}" expands to "___" because the // • DisplayName == " " → "{name}" expands to "___" because the
// sanitizer converts whitespace to underscores. // sanitizer converts whitespace to underscores.
// //
// Neither is a meaningful NDI source identifier, so we substitute // Neither is a meaningful NDI source identifier, so we substitute
// TEAMSISO_{guid}. The Any(char.IsLetterOrDigit) check covers both // Dragon-ISO_{guid}. The Any(char.IsLetterOrDigit) check covers both
// cases anything without at least one alphanumeric is unusable. // cases — anything without at least one alphanumeric is unusable.
// We apply this AFTER token expansion (not on the raw input) so a // We apply this AFTER token expansion (not on the raw input) so a
// template like "PFX_{name}" with empty displayName still works: // template like "PFX_{name}" with empty displayName still works:
// it renders to "PFX_" which contains alphanumerics and is left // it renders to "PFX_" which contains alphanumerics and is left

View file

@ -1,8 +1,8 @@
using System.Windows.Threading; using System.Windows.Threading;
using TeamsISO.App.ViewModels; using DragonISO.App.ViewModels;
using TeamsISO.Engine.Controller; using DragonISO.Engine.Controller;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Shared preset-application logic. Originally lived inline in /// Shared preset-application logic. Originally lived inline in
@ -36,7 +36,7 @@ public static class PresetApplier
Dispatcher? dispatcher = null, Dispatcher? dispatcher = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
// Build the lookup once, case-insensitive Teams display names are // Build the lookup once, case-insensitive — Teams display names are
// human-typed, so "Jane" and "jane" should match the same row. // human-typed, so "Jane" and "jane" should match the same row.
var byName = preset.Assignments.ToDictionary( var byName = preset.Assignments.ToDictionary(
a => a.DisplayName, a => a.DisplayName,

View file

@ -1,10 +1,10 @@
using System.Diagnostics; using System.Diagnostics;
using System.Windows.Automation; using System.Windows.Automation;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Phase E.3 UIAutomation bridge for the in-call controls (mute, camera, /// Phase E.3 — UIAutomation bridge for the in-call controls (mute, camera,
/// leave, share screen). Walks Teams' automation tree to locate the relevant /// leave, share screen). Walks Teams' automation tree to locate the relevant
/// buttons and invokes their <see cref="InvokePattern"/> or <see cref="TogglePattern"/>. /// buttons and invokes their <see cref="InvokePattern"/> or <see cref="TogglePattern"/>.
/// ///
@ -21,20 +21,20 @@ namespace TeamsISO.App.Services;
/// window; we enumerate every top-level window owned by every Teams process, /// window; we enumerate every top-level window owned by every Teams process,
/// so we'll find it wherever it lives. /// so we'll find it wherever it lives.
/// - Hidden windows (after <see cref="TeamsLauncher.HideWindows"/>) are still /// - Hidden windows (after <see cref="TeamsLauncher.HideWindows"/>) are still
/// traversable by UIAutomation the buttons exist in the automation tree /// traversable by UIAutomation — the buttons exist in the automation tree
/// even when their HWND is SW_HIDDEN. This is what makes the "hide Teams, /// even when their HWND is SW_HIDDEN. This is what makes the "hide Teams,
/// drive it from TeamsISO" workflow viable. /// drive it from Dragon-ISO" workflow viable.
/// </summary> /// </summary>
public static class TeamsControlBridge public static class TeamsControlBridge
{ {
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
// Localized candidate-name lists. // Localized candidate-name lists.
// //
// Teams localizes the AutomationElement.Name we match against. The lookup // Teams localizes the AutomationElement.Name we match against. The lookup
// strategy is: ALL candidate strings across all locales are tried for each // 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 // 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 // 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 // — 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 // with the German word "Stumm" in its name would false-positive). In
// practice Teams' button Names are highly distinctive and we haven't seen // practice Teams' button Names are highly distinctive and we haven't seen
// false positives during development. // false positives during development.
@ -42,7 +42,7 @@ public static class TeamsControlBridge
// Adding a locale: append the localized strings to each command's array. // Adding a locale: append the localized strings to each command's array.
// Order doesn't matter for correctness; for performance we put the most // Order doesn't matter for correctness; for performance we put the most
// common locales first since the array is iterated in order. // common locales first since the array is iterated in order.
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
private static readonly string[] MuteCandidates = private static readonly string[] MuteCandidates =
{ {
@ -51,13 +51,13 @@ public static class TeamsControlBridge
// German // German
"Stumm", "Stummschaltung", "Stummschalten", "Stummschaltung aufheben", "Mikrofon", "Stumm", "Stummschaltung", "Stummschalten", "Stummschaltung aufheben", "Mikrofon",
// Spanish // Spanish
"Silenciar", "Activar audio", "Micrófono", "Silenciar", "Activar audio", "Micrófono",
// French // French
"Désactiver le micro", "Activer le micro", "Micro", "Microphone", "Désactiver le micro", "Activer le micro", "Micro", "Microphone",
// Portuguese // Portuguese
"Desativar áudio", "Ativar áudio", "Microfone", "Desativar áudio", "Ativar áudio", "Microfone",
// Japanese // Japanese
"ミュート", "ミュート解除", "マイク", "ミュート", "ミュート解除", "マイク",
}; };
private static readonly string[] CameraCandidates = private static readonly string[] CameraCandidates =
@ -66,13 +66,13 @@ public static class TeamsControlBridge
// German // German
"Kamera", "Kamera einschalten", "Kamera ausschalten", "Video", "Kamera", "Kamera einschalten", "Kamera ausschalten", "Video",
// Spanish // Spanish
"Cámara", "Activar cámara", "Desactivar cámara", "Vídeo", "Cámara", "Activar cámara", "Desactivar cámara", "Vídeo",
// French // French
"Caméra", "Activer la caméra", "Désactiver la caméra", "Vidéo", "Caméra", "Activer la caméra", "Désactiver la caméra", "Vidéo",
// Portuguese // Portuguese
"Câmera", "Ativar câmera", "Desativar câmera", "Câmera", "Ativar câmera", "Desativar câmera",
// Japanese // Japanese
"カメラ", "ビデオ", "カメラ", "ビデオ",
}; };
private static readonly string[] LeaveCandidates = private static readonly string[] LeaveCandidates =
@ -87,7 +87,7 @@ public static class TeamsControlBridge
// Portuguese // Portuguese
"Sair", "Desligar", "Encerrar chamada", "Sair", "Desligar", "Encerrar chamada",
// Japanese // Japanese
"退出", "通話を終了", "退出", "通話を終了",
}; };
private static readonly string[] ShareCandidates = private static readonly string[] ShareCandidates =
@ -98,11 +98,11 @@ public static class TeamsControlBridge
// Spanish // Spanish
"Compartir", "Compartir contenido", "Compartir pantalla", "Compartir", "Compartir contenido", "Compartir pantalla",
// French // French
"Partager", "Partager du contenu", "Partager l'écran", "Partager", "Partager du contenu", "Partager l'écran",
// Portuguese // Portuguese
"Compartilhar", "Compartilhar conteúdo", "Compartilhar tela", "Compartilhar", "Compartilhar conteúdo", "Compartilhar tela",
// Japanese // Japanese
"共有", "コンテンツの共有", "画面を共有", "共有", "コンテンツの共有", "画面を共有",
}; };
private static readonly string[] RaiseHandCandidates = private static readonly string[] RaiseHandCandidates =
@ -115,9 +115,9 @@ public static class TeamsControlBridge
// French // French
"Lever la main", "Baisser la main", "Lever la main", "Baisser la main",
// Portuguese // Portuguese
"Levantar a mão", "Abaixar a mão", "Levantar a mão", "Abaixar a mão",
// Japanese // Japanese
"手を挙げる", "手を下ろす", "手を挙げる", "手を下ろす",
}; };
private static readonly string[] ToggleChatCandidates = private static readonly string[] ToggleChatCandidates =
@ -126,13 +126,13 @@ public static class TeamsControlBridge
// German // German
"Unterhaltung anzeigen", "Unterhaltung ausblenden", "Chat", "Unterhaltung anzeigen", "Unterhaltung ausblenden", "Chat",
// Spanish // Spanish
"Mostrar conversación", "Ocultar conversación", "Chat", "Mostrar conversación", "Ocultar conversación", "Chat",
// French // French
"Afficher la conversation", "Masquer la conversation", "Conversation", "Afficher la conversation", "Masquer la conversation", "Conversation",
// Portuguese // Portuguese
"Mostrar conversa", "Ocultar conversa", "Chat", "Mostrar conversa", "Ocultar conversa", "Chat",
// Japanese // Japanese
"会話を表示", "会話を非表示", "チャット", "会話を表示", "会話を非表示", "チャット",
}; };
private static readonly string[] BackgroundBlurCandidates = private static readonly string[] BackgroundBlurCandidates =
@ -143,11 +143,11 @@ public static class TeamsControlBridge
// Spanish // Spanish
"Efectos de fondo", "Filtros de fondo", "Efectos de fondo", "Filtros de fondo",
// French // French
"Effets d'arrière-plan", "Filtres d'arrière-plan", "Effets d'arrière-plan", "Filtres d'arrière-plan",
// Portuguese // Portuguese
"Efeitos de plano de fundo", "Filtros de plano de fundo", "Efeitos de plano de fundo", "Filtros de plano de fundo",
// Japanese // Japanese
"背景効果", "背景フィルター", "背景効果", "背景フィルター",
}; };
/// <summary>Result of attempting one of the in-call commands.</summary> /// <summary>Result of attempting one of the in-call commands.</summary>
@ -186,7 +186,7 @@ public static class TeamsControlBridge
/// ///
/// Returns IsInCall=false if Teams isn't running or no Leave button /// Returns IsInCall=false if Teams isn't running or no Leave button
/// exists. Returns IsMuted/IsCameraOff as null if those buttons aren't /// exists. Returns IsMuted/IsCameraOff as null if those buttons aren't
/// found in this build (defensive Teams sometimes uses different /// found in this build (defensive — Teams sometimes uses different
/// candidate names across locales). /// candidate names across locales).
/// </summary> /// </summary>
public static CallStateSnapshot DetectCallState() public static CallStateSnapshot DetectCallState()
@ -225,11 +225,11 @@ public static class TeamsControlBridge
{ {
if (lower.Contains("unmute") || lower.Contains("stummschaltung aufheben") || if (lower.Contains("unmute") || lower.Contains("stummschaltung aufheben") ||
lower.Contains("activar audio") || lower.Contains("activer le micro") || lower.Contains("activar audio") || lower.Contains("activer le micro") ||
lower.Contains("ativar áudio") || lower.Contains("ミュート解除")) lower.Contains("ativar áudio") || lower.Contains("ミュート解除"))
muted = true; muted = true;
else if (lower.Contains("mute") || lower.Contains("stummschalten") || else if (lower.Contains("mute") || lower.Contains("stummschalten") ||
lower.Contains("silenciar") || lower.Contains("désactiver le micro") || lower.Contains("silenciar") || lower.Contains("désactiver le micro") ||
lower.Contains("desativar áudio") || lower.Contains("ミュート")) lower.Contains("desativar áudio") || lower.Contains("ミュート"))
muted = false; muted = false;
} }
@ -238,12 +238,12 @@ public static class TeamsControlBridge
if (camOff is null) if (camOff is null)
{ {
if (lower.Contains("turn camera on") || lower.Contains("kamera einschalten") || if (lower.Contains("turn camera on") || lower.Contains("kamera einschalten") ||
lower.Contains("activar cámara") || lower.Contains("activer la caméra") || lower.Contains("activar cámara") || lower.Contains("activer la caméra") ||
lower.Contains("ativar câmera")) lower.Contains("ativar câmera"))
camOff = true; camOff = true;
else if (lower.Contains("turn camera off") || lower.Contains("kamera ausschalten") || 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("desactivar cámara") || lower.Contains("désactiver la caméra") ||
lower.Contains("desativar câmera")) lower.Contains("desativar câmera"))
camOff = false; camOff = false;
} }
} }
@ -259,8 +259,8 @@ public static class TeamsControlBridge
/// UI is in a state we don't recognize. /// UI is in a state we don't recognize.
/// ///
/// This is the "tell me what Teams is doing without me having to look /// 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 /// at it" probe — operators using auto-hide Teams want a status pill
/// that says "In call · ready" without having to restore the Teams /// that says "In call · ready" without having to restore the Teams
/// window. Safe to call from any thread (UIA traversal is thread-safe); /// window. Safe to call from any thread (UIA traversal is thread-safe);
/// not free (walks the descendant tree) so callers should poll at most /// not free (walks the descendant tree) so callers should poll at most
/// a few times per second. /// a few times per second.
@ -305,7 +305,7 @@ public static class TeamsControlBridge
{ {
// Search by Name first (most common case for Teams). Use a NameProperty // Search by Name first (most common case for Teams). Use a NameProperty
// contains-style match by collecting all Buttons in the subtree and then // contains-style match by collecting all Buttons in the subtree and then
// filtering manually Condition only supports equality, and Teams' // filtering manually — Condition only supports equality, and Teams'
// labels can include trailing state ("(unmuted)") that breaks equality. // labels can include trailing state ("(unmuted)") that breaks equality.
var allButtons = root.FindAll( var allButtons = root.FindAll(
TreeScope.Descendants, TreeScope.Descendants,
@ -388,7 +388,7 @@ public static class TeamsControlBridge
} }
catch catch
{ {
// ElementNotEnabledException, ElementNotAvailableException Teams // ElementNotEnabledException, ElementNotAvailableException — Teams
// disabled the button mid-traversal (e.g. mute is disabled before // disabled the button mid-traversal (e.g. mute is disabled before
// joining a call). Treat as "found but couldn't invoke" so the // joining a call). Treat as "found but couldn't invoke" so the
// caller can surface a useful message. // caller can surface a useful message.

View file

@ -1,11 +1,11 @@
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Phase E.4 Embedded Teams via SetParent. /// Phase E.4 — Embedded Teams via SetParent.
/// ///
/// Reparents Teams' main top-level window into a TeamsISO-owned host /// Reparents Teams' main top-level window into a Dragon-ISO-owned host
/// (typically a Border element's HWND). Strips the captured window's /// (typically a Border element's HWND). Strips the captured window's
/// caption + thick frame so it integrates flush with the host, and /// caption + thick frame so it integrates flush with the host, and
/// remembers enough about the original to restore it cleanly later. /// remembers enough about the original to restore it cleanly later.
@ -18,8 +18,8 @@ namespace TeamsISO.App.Services;
/// caller wraps Embed in a finally block) so operators can fall back to /// caller wraps Embed in a finally block) so operators can fall back to
/// auto-hide mode if embedding misbehaves on their specific Teams build. /// auto-hide mode if embedding misbehaves on their specific Teams build.
/// ///
/// Lives in its own static class separated from <see cref="TeamsLauncher"/> /// Lives in its own static class — separated from <see cref="TeamsLauncher"/>
/// because the embedding lifecycle (reparent → resize → restore) is its /// because the embedding lifecycle (reparent → resize → restore) is its
/// own thing, and the Win32 surface it requires (SetParent / window-style /// own thing, and the Win32 surface it requires (SetParent / window-style
/// muck / SetWindowPos / MoveWindow) isn't reused by the launch / hide / /// muck / SetWindowPos / MoveWindow) isn't reused by the launch / hide /
/// in-call control paths. /// in-call control paths.
@ -67,7 +67,7 @@ public static class TeamsEmbedHost
private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState; private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState;
private static IntPtr _embeddedHwnd = IntPtr.Zero; private static IntPtr _embeddedHwnd = IntPtr.Zero;
/// <summary>True when a Teams window is currently parented inside a TeamsISO host.</summary> /// <summary>True when a Teams window is currently parented inside a Dragon-ISO host.</summary>
public static bool IsEmbedded => _embeddedHwnd != IntPtr.Zero; public static bool IsEmbedded => _embeddedHwnd != IntPtr.Zero;
/// <summary> /// <summary>
@ -79,7 +79,7 @@ public static class TeamsEmbedHost
/// The host HWND is typically obtained via: /// The host HWND is typically obtained via:
/// var src = (System.Windows.Interop.HwndSource) /// var src = (System.Windows.Interop.HwndSource)
/// PresentationSource.FromVisual(MyHostBorder); /// PresentationSource.FromVisual(MyHostBorder);
/// src.Handle // IntPtr suitable for hostHwnd /// src.Handle // → IntPtr suitable for hostHwnd
/// </summary> /// </summary>
public static bool EmbedTeamsInto(IntPtr hostHwnd, int width, int height) public static bool EmbedTeamsInto(IntPtr hostHwnd, int width, int height)
{ {
@ -87,7 +87,7 @@ public static class TeamsEmbedHost
var teamsWindows = TeamsLauncher.EnumerateTopLevelTeamsWindows(); var teamsWindows = TeamsLauncher.EnumerateTopLevelTeamsWindows();
if (teamsWindows.Count == 0) return false; if (teamsWindows.Count == 0) return false;
// Pick the longest-title window as the "main" one same // Pick the longest-title window as the "main" one — same
// heuristic GetActiveWindowTitle uses; matches the call / // heuristic GetActiveWindowTitle uses; matches the call /
// meeting window. // meeting window.
IntPtr best = IntPtr.Zero; IntPtr best = IntPtr.Zero;
@ -135,7 +135,7 @@ public static class TeamsEmbedHost
/// <summary> /// <summary>
/// Resize the currently-embedded Teams window to <paramref name="width"/> /// Resize the currently-embedded Teams window to <paramref name="width"/>
/// × <paramref name="height"/>. Called when the host element resizes /// × <paramref name="height"/>. Called when the host element resizes
/// (window resize, layout change, etc.). No-op if nothing is embedded. /// (window resize, layout change, etc.). No-op if nothing is embedded.
/// </summary> /// </summary>
public static void ResizeEmbedded(int width, int height) public static void ResizeEmbedded(int width, int height)
@ -147,7 +147,7 @@ public static class TeamsEmbedHost
/// <summary> /// <summary>
/// Reverse an active embed: SetParent back to desktop + restore the /// Reverse an active embed: SetParent back to desktop + restore the
/// original window style so Teams looks/behaves like a normal /// original window style so Teams looks/behaves like a normal
/// top-level window again. Safe to call when nothing is embedded /// top-level window again. Safe to call when nothing is embedded —
/// no-op. /// no-op.
/// </summary> /// </summary>
public static void RestoreEmbed() public static void RestoreEmbed()
@ -167,7 +167,7 @@ public static class TeamsEmbedHost
SetWindowPos(_embeddedHwnd, IntPtr.Zero, 0, 0, 0, 0, SetWindowPos(_embeddedHwnd, IntPtr.Zero, 0, 0, 0, 0,
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
} }
catch { /* defensive restore must never throw */ } catch { /* defensive — restore must never throw */ }
finally finally
{ {
_embedSavedState = null; _embedSavedState = null;

View file

@ -1,14 +1,14 @@
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Small Win32 wrapper that launches the Microsoft Teams desktop client as a /// Small Win32 wrapper that launches the Microsoft Teams desktop client as a
/// subprocess of TeamsISO. First step toward Phase E.1 of the embedded-Teams /// 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): /// roadmap (see docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md):
/// the operator can launch Teams from within TeamsISO so they don't have to /// the operator can launch Teams from within Dragon-ISO so they don't have to
/// switch apps to start a meeting. /// switch apps to start a meeting.
/// ///
/// The launcher tries (in order): /// The launcher tries (in order):
@ -17,7 +17,7 @@ namespace TeamsISO.App.Services;
/// 3. Teams.exe in %LOCALAPPDATA%\Microsoft\Teams\Update.exe (legacy) /// 3. Teams.exe in %LOCALAPPDATA%\Microsoft\Teams\Update.exe (legacy)
/// ///
/// Group-routing automation (writing NDI Access Manager config so Teams /// 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 /// 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 /// document the manual steps in RELEASING.md and trust the operator to set
/// them once per machine. /// them once per machine.
/// </summary> /// </summary>
@ -48,12 +48,12 @@ public static class TeamsLauncher
/// reasons each attempt was rejected so the operator can see why. /// reasons each attempt was rejected so the operator can see why.
/// ///
/// Path order matters: /// Path order matters:
/// 1. <c>ms-teams:</c> URI new Teams (MSTeams AppX) registers this /// 1. <c>ms-teams:</c> URI — new Teams (MSTeams AppX) registers this
/// handler at install. Activates through the AppX shell so the /// handler at install. Activates through the AppX shell so the
/// stub <c>ms-teams.exe</c> in WindowsApps gets the right context. /// stub <c>ms-teams.exe</c> in WindowsApps gets the right context.
/// 2. AppsFolder shell verb direct AppX activation. Belt-and-braces /// 2. AppsFolder shell verb — direct AppX activation. Belt-and-braces
/// fallback if a misconfigured registry breaks the URI handler. /// fallback if a misconfigured registry breaks the URI handler.
/// 3. Classic Teams Update.exe pre-2024 Teams installations. /// 3. Classic Teams Update.exe — pre-2024 Teams installations.
/// We deliberately DON'T try the bare <c>ms-teams.exe</c> WindowsApps /// 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 /// path: it's a 0-byte AppX placeholder that fails silently when invoked
/// without AppX activation context. Looked plausible, never worked. /// without AppX activation context. Looked plausible, never worked.
@ -65,9 +65,9 @@ public static class TeamsLauncher
// Path 1: URI scheme. The shell handler picks the registered Teams // Path 1: URI scheme. The shell handler picks the registered Teams
// (new MSTeams takes priority on modern Windows). UseShellExecute=true // (new MSTeams takes priority on modern Windows). UseShellExecute=true
// is required Win32 Process creation can't open URIs directly. // is required — Win32 Process creation can't open URIs directly.
if (TryStart("ms-teams:", useShell: true, out var err1)) return true; if (TryStart("ms-teams:", useShell: true, out var err1)) return true;
attempts.Add($"ms-teams: URI {err1}"); attempts.Add($"ms-teams: URI → {err1}");
// Path 2: AppX activation via the explorer.exe shell. Modern Teams // Path 2: AppX activation via the explorer.exe shell. Modern Teams
// ships as MSTeams_8wekyb3d8bbwe; if other code on the box has // ships as MSTeams_8wekyb3d8bbwe; if other code on the box has
@ -76,7 +76,7 @@ public static class TeamsLauncher
if (TryStart("explorer.exe", false, out var err2, if (TryStart("explorer.exe", false, out var err2,
arguments: "shell:appsFolder\\MSTeams_8wekyb3d8bbwe!MSTeams")) arguments: "shell:appsFolder\\MSTeams_8wekyb3d8bbwe!MSTeams"))
return true; return true;
attempts.Add($"AppsFolder shell {err2}"); attempts.Add($"AppsFolder shell → {err2}");
// Path 3: classic Teams Update.exe with --processStart hands off to // Path 3: classic Teams Update.exe with --processStart hands off to
// the actual Teams.exe via Squirrel. // the actual Teams.exe via Squirrel.
@ -98,24 +98,24 @@ public static class TeamsLauncher
} }
catch (Exception ex) catch (Exception ex)
{ {
attempts.Add($"classic Update.exe {ex.Message}"); attempts.Add($"classic Update.exe → {ex.Message}");
} }
} }
else else
{ {
attempts.Add($"classic Update.exe not found at {classicUpdater}"); attempts.Add($"classic Update.exe → not found at {classicUpdater}");
} }
errorMessage = "No Microsoft Teams installation could be launched. " + errorMessage = "No Microsoft Teams installation could be launched. " +
"Install Teams from https://www.microsoft.com/microsoft-teams and try again.\n\n" + "Install Teams from https://www.microsoft.com/microsoft-teams and try again.\n\n" +
"Attempts:\n • " + string.Join("\n • ", attempts); "Attempts:\n • " + string.Join("\n • ", attempts);
return false; return false;
} }
/// <summary> /// <summary>
/// Asks every running Teams process to close gracefully via WM_CLOSE /// Asks every running Teams process to close gracefully via WM_CLOSE
/// (CloseMainWindow). Returns the count of processes that exited cleanly within /// (CloseMainWindow). Returns the count of processes that exited cleanly within
/// <paramref name="gracePeriod"/>. Stragglers are NOT force-killed Teams' own /// <paramref name="gracePeriod"/>. Stragglers are NOT force-killed — Teams' own
/// "are you sure" prompt may legitimately keep a process alive briefly, and we /// "are you sure" prompt may legitimately keep a process alive briefly, and we
/// don't want to nuke the user's call mid-transition. /// don't want to nuke the user's call mid-transition.
/// </summary> /// </summary>
@ -151,14 +151,14 @@ public static class TeamsLauncher
/// Hand a meeting URL off to the Teams shell handler. Accepts both the /// 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 /// <c>https://teams.microsoft.com/l/meetup-join/...</c> web format and
/// the <c>msteams:/l/meetup-join/...</c> deep-link form (either causes /// 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 to launch + join the meeting in one shot — the OS shell maps
/// teams.microsoft.com URLs to the registered ms-teams: handler). /// teams.microsoft.com URLs to the registered ms-teams: handler).
/// ///
/// Use case: operator pastes a meeting link they got over email / chat /// Use case: operator pastes a meeting link they got over email / chat
/// into TeamsISO's quick-join field instead of opening Teams, /// into Dragon-ISO's quick-join field instead of opening Teams,
/// hunting down the calendar entry, and clicking Join. With auto-hide /// hunting down the calendar entry, and clicking Join. With auto-hide
/// on, the Teams window flashes briefly then disappears; the operator /// on, the Teams window flashes briefly then disappears; the operator
/// is now in the meeting, driving routing from TeamsISO. /// is now in the meeting, driving routing from DragonISO.
/// ///
/// Returns true if the shell accepted the URL; false if URL is malformed /// Returns true if the shell accepted the URL; false if URL is malformed
/// or rejected. errorMessage populated on failure. /// or rejected. errorMessage populated on failure.
@ -176,7 +176,7 @@ public static class TeamsLauncher
// Defensive sanity-check: only accept URLs that obviously target // Defensive sanity-check: only accept URLs that obviously target
// Teams. We don't want to invoke arbitrary shell handlers from a // Teams. We don't want to invoke arbitrary shell handlers from a
// clipboard paste if someone pastes "calc.exe" into the input we // clipboard paste — if someone pastes "calc.exe" into the input we
// shouldn't launch it. Specifically: http(s) URLs must contain // shouldn't launch it. Specifically: http(s) URLs must contain
// "teams.microsoft.com" or "teams.live.com"; otherwise must start // "teams.microsoft.com" or "teams.live.com"; otherwise must start
// with "msteams:". // with "msteams:".
@ -221,11 +221,11 @@ public static class TeamsLauncher
} }
} }
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
// Phase E.2 window orchestration // Phase E.2 — window orchestration
// //
// Once Teams is running, we want to be able to hide its main window so the // Once Teams is running, we want to be able to hide its main window so the
// operator only sees TeamsISO. We do this by enumerating top-level windows, // operator only sees DragonISO. We do this by enumerating top-level windows,
// matching against each Teams process Id, and ShowWindow(SW_HIDE)-ing each // matching against each Teams process Id, and ShowWindow(SW_HIDE)-ing each
// match. To restore we ShowWindow(SW_SHOW) and SetForegroundWindow. // match. To restore we ShowWindow(SW_SHOW) and SetForegroundWindow.
// //
@ -234,7 +234,7 @@ public static class TeamsLauncher
// process and Process picks an inconsistent one across launches; iterating // process and Process picks an inconsistent one across launches; iterating
// via EnumWindows + GetWindowThreadProcessId catches every visible window // via EnumWindows + GetWindowThreadProcessId catches every visible window
// owned by the process. // owned by the process.
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
private const int SW_HIDE = 0; private const int SW_HIDE = 0;
private const int SW_SHOWNORMAL = 1; private const int SW_SHOWNORMAL = 1;
@ -276,9 +276,9 @@ public static class TeamsLauncher
/// window, or empty string if Teams isn't running. Modern Teams puts /// 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 /// the meeting title in the window title while in a call ("Meeting with
/// Alice | Microsoft Teams"), so this is the cheapest way to surface /// Alice | Microsoft Teams"), so this is the cheapest way to surface
/// meeting context to TeamsISO's UI without burning a UIA traversal. /// meeting context to Dragon-ISO's UI without burning a UIA traversal.
/// ///
/// Includes hidden windows operators using auto-hide still get the /// Includes hidden windows — operators using auto-hide still get the
/// title surfaced, which is the whole point. /// title surfaced, which is the whole point.
/// </summary> /// </summary>
public static string GetActiveWindowTitle() public static string GetActiveWindowTitle()
@ -363,12 +363,12 @@ public static class TeamsLauncher
/// Fire-and-forget background watcher that polls every 250ms for up to /// Fire-and-forget background watcher that polls every 250ms for up to
/// <paramref name="timeout"/> and hides any visible top-level Teams /// <paramref name="timeout"/> and hides any visible top-level Teams
/// windows it finds. Used after launch so the operator never sees the /// 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 /// Teams UI flash on screen — Teams takes 2-5s to splash + render its
/// main window, and the splash arrives separately from the main window /// main window, and the splash arrives separately from the main window
/// (so we keep polling past the first hide to catch follow-up windows). /// (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 /// Returns the Task so callers can await completion if they want, but
/// production code should fire-and-forget. Exceptions are swallowed /// production code should fire-and-forget. Exceptions are swallowed —
/// failure to hide is harmless (user just sees Teams briefly). /// failure to hide is harmless (user just sees Teams briefly).
/// </summary> /// </summary>
public static Task AutoHideAfterLaunchAsync(TimeSpan? timeout = null, CancellationToken ct = default) public static Task AutoHideAfterLaunchAsync(TimeSpan? timeout = null, CancellationToken ct = default)
@ -382,7 +382,7 @@ public static class TeamsLauncher
while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline) while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline)
{ {
// Poll for visible windows. Each iteration may catch new // Poll for visible windows. Each iteration may catch new
// ones Teams sometimes opens a small splash, then a // ones — Teams sometimes opens a small splash, then a
// larger main window 1-2s later, then a "What's new" // larger main window 1-2s later, then a "What's new"
// banner. Keep hiding until we've gone a full second // banner. Keep hiding until we've gone a full second
// with nothing new appearing. // with nothing new appearing.
@ -409,21 +409,21 @@ public static class TeamsLauncher
} }
} }
catch (OperationCanceledException) { /* expected on cancel */ } catch (OperationCanceledException) { /* expected on cancel */ }
catch { /* defensive auto-hide is best-effort, never breaks the app */ } catch { /* defensive — auto-hide is best-effort, never breaks the app */ }
}, ct); }, ct);
} }
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
// Keyboard-shortcut forwarding (PostMessage path). // Keyboard-shortcut forwarding (PostMessage path).
// //
// UIAutomation (TeamsControlBridge) is our preferred way to drive Teams // UIAutomation (TeamsControlBridge) is our preferred way to drive Teams
// because it works regardless of foreground/visibility state. PostMessage // because it works regardless of foreground/visibility state. PostMessage
// is a fallback for shortcuts that don't have a stable UIA-discoverable // is a fallback for shortcuts that don't have a stable UIA-discoverable
// button chat scroll, custom keymap actions, etc. Note: WebView2-hosted // button — chat scroll, custom keymap actions, etc. Note: WebView2-hosted
// Teams (the modern client) frequently ignores PostMessage(WM_KEYDOWN) at // Teams (the modern client) frequently ignores PostMessage(WM_KEYDOWN) at
// its app-shortcut layer because shortcut routing happens after focus // its app-shortcut layer because shortcut routing happens after focus
// changes, not on raw key messages. Treat this as best-effort. // changes, not on raw key messages. Treat this as best-effort.
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
private const uint WM_KEYDOWN = 0x0100; private const uint WM_KEYDOWN = 0x0100;
private const uint WM_KEYUP = 0x0101; private const uint WM_KEYUP = 0x0101;
@ -448,7 +448,7 @@ public static class TeamsLauncher
/// Sends a synthesized key press (modifier-down, key-down, key-up, /// Sends a synthesized key press (modifier-down, key-down, key-up,
/// modifier-up) to the most recently used top-level Teams window via /// 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 /// PostMessage. Returns true if a window was found to send to. Note that
/// returning true doesn't guarantee Teams reacted modern WebView2-based /// returning true doesn't guarantee Teams reacted — modern WebView2-based
/// Teams sometimes ignores synthesized key messages at the app-shortcut /// Teams sometimes ignores synthesized key messages at the app-shortcut
/// layer. Prefer UIA (<see cref="TeamsControlBridge"/>) when an equivalent /// layer. Prefer UIA (<see cref="TeamsControlBridge"/>) when an equivalent
/// button exists. /// button exists.

View file

@ -1,17 +1,17 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Windows; using System.Windows;
using Microsoft.Win32; using Microsoft.Win32;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Owns the active theme for the WPF host. Three preferences: /// Owns the active theme for the WPF host. Three preferences:
/// <list type="bullet"> /// <list type="bullet">
/// <item><c>System</c> follows the Windows app-mode setting (default for new /// <item><c>System</c> — follows the Windows app-mode setting (default for new
/// users; re-reads on <see cref="SystemEvents.UserPreferenceChanged"/>).</item> /// users; re-reads on <see cref="SystemEvents.UserPreferenceChanged"/>).</item>
/// <item><c>Dark</c> pin dark regardless of OS.</item> /// <item><c>Dark</c> — pin dark regardless of OS.</item>
/// <item><c>Light</c> pin light regardless of OS.</item> /// <item><c>Light</c> — pin light regardless of OS.</item>
/// </list> /// </list>
/// The two color files (<c>Theme.Dark.xaml</c> + <c>Theme.Light.xaml</c>) are /// 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 /// kept in lockstep on the same set of brush keys; this manager swaps the
@ -20,7 +20,7 @@ namespace TeamsISO.App.Services;
/// so the visual tree re-resolves without an app restart. /// so the visual tree re-resolves without an app restart.
/// ///
/// Preference is persisted via <see cref="UIPreferences"/>'s <c>Theme</c> field, /// 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 /// reserved on disk during the v1 → v2 rollout so the rebuild doesn't lose the
/// operator's choice. /// operator's choice.
/// </summary> /// </summary>
public sealed class ThemeManager public sealed class ThemeManager
@ -31,12 +31,12 @@ public sealed class ThemeManager
savePreference: TrySavePreferenceToDisk, savePreference: TrySavePreferenceToDisk,
subscribeToSystemPreference: true); subscribeToSystemPreference: true);
// Pack URIs (rather than relative "/Themes/") so the resolution // Pack URIs (rather than relative "/Themes/…") so the resolution
// works equally well from production (where Application.Current's // works equally well from production (where Application.Current's
// base URI is the TeamsISO entry assembly) and from xUnit tests // base URI is the Dragon-ISO entry assembly) and from xUnit tests
// (where it's the test assembly relative URIs would miss). // (where it's the test assembly — relative URIs would miss).
private const string DarkUri = "pack://application:,,,/TeamsISO;component/Themes/Theme.Dark.xaml"; private const string DarkUri = "pack://application:,,,/DragonISO;component/Themes/Theme.Dark.xaml";
private const string LightUri = "pack://application:,,,/TeamsISO;component/Themes/Theme.Light.xaml"; private const string LightUri = "pack://application:,,,/DragonISO;component/Themes/Theme.Light.xaml";
private const string PreferenceKeySystem = "System"; private const string PreferenceKeySystem = "System";
private const string PreferenceKeyDark = "Dark"; private const string PreferenceKeyDark = "Dark";
private const string PreferenceKeyLight = "Light"; private const string PreferenceKeyLight = "Light";
@ -69,7 +69,7 @@ public sealed class ThemeManager
} }
catch catch
{ {
// Defensive ctor must not throw or the app loses theming. // Defensive — ctor must not throw or the app loses theming.
} }
// Re-evaluate when Windows app-mode flips, but only when the // Re-evaluate when Windows app-mode flips, but only when the
@ -134,7 +134,7 @@ public sealed class ThemeManager
/// <summary> /// <summary>
/// Apply the current resolved theme. Should be called once during app /// Apply the current resolved theme. Should be called once during app
/// startup (after Application.Current.Resources is initialized) and /// startup (after Application.Current.Resources is initialized) and
/// whenever <see cref="Preference"/> changes <see cref="Set"/> already /// whenever <see cref="Preference"/> changes — <see cref="Set"/> already
/// does the latter for you. /// does the latter for you.
/// </summary> /// </summary>
public void Apply() public void Apply()
@ -152,7 +152,7 @@ public sealed class ThemeManager
var dicts = app.Resources.MergedDictionaries; var dicts = app.Resources.MergedDictionaries;
// Find the existing theme color dictionary by source URI. We // Find the existing theme color dictionary by source URI. We
// distinguish "color" dictionaries from "WildDragonTheme" by name // distinguish "color" dictionaries from "WildDragonTheme" by name —
// the color files are at Theme.Dark.xaml / Theme.Light.xaml; the // the color files are at Theme.Dark.xaml / Theme.Light.xaml; the
// styles file is at WildDragonTheme.xaml. Replace in place to // styles file is at WildDragonTheme.xaml. Replace in place to
// preserve merge order so DynamicResource refs resolve to the new // preserve merge order so DynamicResource refs resolve to the new
@ -184,7 +184,7 @@ public sealed class ThemeManager
/// <summary> /// <summary>
/// Read Windows' AppsUseLightTheme registry value. 1 = light, 0 = dark. /// Read Windows' AppsUseLightTheme registry value. 1 = light, 0 = dark.
/// Returns true (dark) on any read failure the dark scene is the /// Returns true (dark) on any read failure — the dark scene is the
/// default per DESIGN.md so a missing value still lands somewhere /// default per DESIGN.md so a missing value still lands somewhere
/// sensible. Backs the singleton's _isSystemDark seam. /// sensible. Backs the singleton's _isSystemDark seam.
/// </summary> /// </summary>
@ -208,7 +208,7 @@ public sealed class ThemeManager
/// <summary> /// <summary>
/// Load the operator's persisted theme preference from /// Load the operator's persisted theme preference from
/// %LOCALAPPDATA%\TeamsISO\ui-prefs.json. Returns null on any read /// %LOCALAPPDATA%\Dragon-ISO\ui-prefs.json. Returns null on any read
/// failure (missing file, corrupt JSON, schema mismatch) so the /// failure (missing file, corrupt JSON, schema mismatch) so the
/// caller falls back to the in-memory default of "System". Backs /// caller falls back to the in-memory default of "System". Backs
/// the singleton's loadPreference seam. /// the singleton's loadPreference seam.
@ -221,7 +221,7 @@ public sealed class ThemeManager
/// <summary> /// <summary>
/// Persist the operator's theme preference to ui-prefs.json. Errors /// Persist the operator's theme preference to ui-prefs.json. Errors
/// are swallowed persistence is best-effort and a single failed /// are swallowed — persistence is best-effort and a single failed
/// save shouldn't break the in-session UI experience. Backs the /// save shouldn't break the in-session UI experience. Backs the
/// singleton's savePreference seam. /// singleton's savePreference seam.
/// </summary> /// </summary>
@ -235,7 +235,7 @@ public sealed class ThemeManager
{ {
if (e.Category != UserPreferenceCategory.General) return; if (e.Category != UserPreferenceCategory.General) return;
if (_preference != PreferenceKeySystem) return; if (_preference != PreferenceKeySystem) return;
// Marshal to the UI thread registry events fire on a system pool // Marshal to the UI thread — registry events fire on a system pool
// thread and resource dictionary mutations require dispatcher access. // thread and resource dictionary mutations require dispatcher access.
Application.Current?.Dispatcher.BeginInvoke(new Action(Apply)); Application.Current?.Dispatcher.BeginInvoke(new Action(Apply));
} }

View file

@ -1,16 +1,16 @@
using System.Drawing; using System.Drawing;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using System.Runtime.Versioning; using System.Runtime.Versioning;
using System.Windows; using System.Windows;
using WinForms = System.Windows.Forms; using WinForms = System.Windows.Forms;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Wraps a WinForms <see cref="WinForms.NotifyIcon"/> so the WPF host can /// Wraps a WinForms <see cref="WinForms.NotifyIcon"/> so the WPF host can
/// minimize-to-tray during long shows. Operators with a Stream Deck setup /// minimize-to-tray during long shows. Operators with a Stream Deck setup
/// often want TeamsISO running but invisible — the tray icon keeps the /// often want Dragon-ISO running but invisible — the tray icon keeps the
/// process alive (and the engine routing live) while the window stays /// process alive (and the engine routing live) while the window stays
/// hidden. /// hidden.
/// ///
@ -31,7 +31,7 @@ public sealed class TrayIconHost : IDisposable
_mainWindow = mainWindow; _mainWindow = mainWindow;
_notifyIcon = new WinForms.NotifyIcon _notifyIcon = new WinForms.NotifyIcon
{ {
Text = "TeamsISO", Text = "Dragon-ISO",
Icon = LoadEmbeddedIcon(), Icon = LoadEmbeddedIcon(),
Visible = false, Visible = false,
}; };
@ -76,7 +76,7 @@ public sealed class TrayIconHost : IDisposable
_notifyIcon.Visible = true; _notifyIcon.Visible = true;
_notifyIcon.ShowBalloonTip( _notifyIcon.ShowBalloonTip(
timeout: 1500, timeout: 1500,
tipTitle: "TeamsISO is still running", tipTitle: "Dragon-ISO is still running",
tipText: "Engine + control surface are live. Double-click the tray icon to restore the window.", tipText: "Engine + control surface are live. Double-click the tray icon to restore the window.",
tipIcon: WinForms.ToolTipIcon.Info); tipIcon: WinForms.ToolTipIcon.Info);
} }
@ -93,7 +93,7 @@ public sealed class TrayIconHost : IDisposable
private WinForms.ContextMenuStrip BuildMenu() private WinForms.ContextMenuStrip BuildMenu()
{ {
var menu = new WinForms.ContextMenuStrip(); var menu = new WinForms.ContextMenuStrip();
menu.Items.Add("Show TeamsISO", null, (_, _) => RestoreFromTray()); menu.Items.Add("Show Dragon-ISO", null, (_, _) => RestoreFromTray());
menu.Items.Add("-"); menu.Items.Add("-");
menu.Items.Add("Stop all ISOs", null, (_, _) => menu.Items.Add("Stop all ISOs", null, (_, _) =>
{ {
@ -106,12 +106,12 @@ public sealed class TrayIconHost : IDisposable
} }
}); });
menu.Items.Add("-"); menu.Items.Add("-");
menu.Items.Add("Exit TeamsISO", null, (_, _) => System.Windows.Application.Current.Shutdown()); menu.Items.Add("Exit Dragon-ISO", null, (_, _) => System.Windows.Application.Current.Shutdown());
return menu; return menu;
} }
/// <summary> /// <summary>
/// Load the bundled teamsiso.ico from this assembly's resources. We use /// Load the bundled DragonISO.ico from this assembly's resources. We use
/// the embedded resource rather than the file-system path because the /// 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). /// app may be run from any CWD (via the MSI install or a developer dotnet run).
/// </summary> /// </summary>
@ -120,7 +120,7 @@ public sealed class TrayIconHost : IDisposable
try try
{ {
var asm = Assembly.GetExecutingAssembly(); var asm = Assembly.GetExecutingAssembly();
var uri = new Uri("pack://application:,,,/Assets/teamsiso.ico"); var uri = new Uri("pack://application:,,,/Assets/DragonISO.ico");
using var stream = System.Windows.Application.GetResourceStream(uri)?.Stream; using var stream = System.Windows.Application.GetResourceStream(uri)?.Stream;
if (stream is not null) return new Icon(stream); if (stream is not null) return new Icon(stream);
} }

View file

@ -1,17 +1,17 @@
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Persistent UI-side toggles that don't belong in <see cref="TeamsISO.Engine.Persistence.ConfigStore"/> /// 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). /// (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 /// Each toggle is a property on a single record persisted as JSON at
/// <c>%LOCALAPPDATA%\TeamsISO\ui-prefs.json</c>. Defaults match the original /// <c>%LOCALAPPDATA%\Dragon-ISO\ui-prefs.json</c>. Defaults match the original
/// in-memory behavior: HideLocalSelf=true (filter the operator's own preview /// in-memory behavior: HideLocalSelf=true (filter the operator's own preview
/// out of the participants list) and AutoDisableOnDeparture=false (a participant /// out of the participants list) and AutoDisableOnDeparture=false (a participant
/// going offline doesn't tear down their pipeline by default operators /// going offline doesn't tear down their pipeline by default — operators
/// usually want to keep the routing in case they reconnect). /// usually want to keep the routing in case they reconnect).
/// ///
/// Centralizing these here means the settings VM doesn't have to plumb /// Centralizing these here means the settings VM doesn't have to plumb
@ -24,14 +24,14 @@ public static class UIPreferences
private static string PrefsPath => private static string PrefsPath =>
Path.Combine( Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "ui-prefs.json"); "Dragon-ISO", "ui-prefs.json");
/// <summary> /// <summary>
/// Sort modes for the participants DataGrid. <see cref="JoinOrder"/> is the default /// Sort modes for the participants DataGrid. <see cref="JoinOrder"/> is the default
/// and matches the engine's discovery order (operators with custom Stream Deck /// and matches the engine's discovery order (operators with custom Stream Deck
/// layouts sometimes prefer Alphabetical for stability across meetings). /// layouts sometimes prefer Alphabetical for stability across meetings).
/// <see cref="LoudestFirst"/> resorts at the 1Hz stats tick so the active /// <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. /// speaker bubbles to the top — useful for operators reacting to who's talking.
/// </summary> /// </summary>
public enum SortMode { JoinOrder, Alphabetical, OnlineFirst, LoudestFirst } public enum SortMode { JoinOrder, Alphabetical, OnlineFirst, LoudestFirst }
@ -43,20 +43,20 @@ public static class UIPreferences
bool MinimizeToTray = false, bool MinimizeToTray = false,
bool ControlSurfaceLanReachable = false, bool ControlSurfaceLanReachable = false,
// Phase E.1 / E.2 quality-of-life. With both true, the operator launches // Phase E.1 / E.2 quality-of-life. With both true, the operator launches
// TeamsISO and never sees the Teams UI — Teams auto-starts in the // 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. // background and its windows are auto-hidden as soon as they materialize.
// All control happens via the IN-CALL bar + participants DataGrid. // All control happens via the IN-CALL bar + participants DataGrid.
bool LaunchTeamsOnStartup = false, bool LaunchTeamsOnStartup = false,
bool AutoHideTeamsWindows = false, bool AutoHideTeamsWindows = false,
// Experimental Phase E.4. SetParent-reparents Teams' main window // Experimental Phase E.4. SetParent-reparents Teams' main window
// into a TeamsISO-owned host. WebView2 in modern Teams can render // into a Dragon-ISO-owned host. WebView2 in modern Teams can render
// weirdly after reparent; if so the operator unticks and falls // weirdly after reparent; if so the operator unticks and falls
// back to auto-hide mode. Off by default. // back to auto-hide mode. Off by default.
bool EmbedTeamsWindow = false, bool EmbedTeamsWindow = false,
// Theme preference for the v2 redesign. One of "System" (follow // Theme preference for the v2 redesign. One of "System" (follow
// Windows app-mode), "Dark", or "Light". ThemeManager hydrates // Windows app-mode), "Dark", or "Light". ThemeManager hydrates
// from this on startup and persists back here on toggle. Default // from this on startup and persists back here on toggle. Default
// "System" matches DESIGN.md's "Follow Windows" choice the // "System" matches DESIGN.md's "Follow Windows" choice — the
// operator who doesn't care gets whatever Windows is set to. // operator who doesn't care gets whatever Windows is set to.
string Theme = "System", string Theme = "System",
// REST + WebSocket control surface auto-start. When true, the // REST + WebSocket control surface auto-start. When true, the
@ -100,7 +100,7 @@ public static class UIPreferences
} }
catch catch
{ {
// Disk full / permission denied in-memory state still holds for this session. // Disk full / permission denied — in-memory state still holds for this session.
} }
} }
} }

View file

@ -1,21 +1,21 @@
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Reflection; using System.Reflection;
using System.Text.Json; using System.Text.Json;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Asks Forgejo's REST API whether a newer release tag exists than the one /// 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 /// 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 /// operator can click "Check for updates" in the About dialog whenever they
/// want, and a positive result opens the release page in their browser /// want, and a positive result opens the release page in their browser
/// (rather than auto-downloading; we don't want a long-running show /// (rather than auto-downloading; we don't want a long-running show
/// interrupted by a surprise installer). /// interrupted by a surprise installer).
/// ///
/// We use the public release endpoint so no auth is needed: /// We use the public release endpoint so no auth is needed:
/// GET /api/v1/repos/zgaetano/teamsiso/releases?limit=1 /// GET /api/v1/repos/zgaetano/Dragon-ISO/releases?limit=1
/// ///
/// On any error (offline, DNS failure, repo private, malformed response), /// On any error (offline, DNS failure, repo private, malformed response),
/// the caller gets <see cref="UpdateCheckResult.Failed"/> with a short /// the caller gets <see cref="UpdateCheckResult.Failed"/> with a short
@ -24,10 +24,10 @@ namespace TeamsISO.App.Services;
public static class UpdateChecker public static class UpdateChecker
{ {
private const string ReleasesApi = private const string ReleasesApi =
"https://forge.wilddragon.net/api/v1/repos/zgaetano/teamsiso/releases?limit=1"; "https://forge.wilddragon.net/api/v1/repos/zgaetano/Dragon-ISO/releases?limit=1";
private const string ReleasesPage = private const string ReleasesPage =
"https://forge.wilddragon.net/zgaetano/teamsiso/releases"; "https://forge.wilddragon.net/zgaetano/Dragon-ISO/releases";
/// <summary>Outcome of a single check.</summary> /// <summary>Outcome of a single check.</summary>
public sealed record UpdateCheckResult( public sealed record UpdateCheckResult(
@ -53,7 +53,7 @@ public static class UpdateChecker
try try
{ {
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(8) }; using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
client.DefaultRequestHeaders.Add("User-Agent", "TeamsISO/" + current); client.DefaultRequestHeaders.Add("User-Agent", "Dragon-ISO/" + current);
using var res = await client.GetAsync(ReleasesApi, ct); using var res = await client.GetAsync(ReleasesApi, ct);
if (!res.IsSuccessStatusCode) if (!res.IsSuccessStatusCode)
@ -102,7 +102,7 @@ public static class UpdateChecker
/// <summary> /// <summary>
/// Open the releases page in the user's default browser. Used by the /// Open the releases page in the user's default browser. Used by the
/// "Update available" dialog button we deliberately don't download/run /// "Update available" dialog button — we deliberately don't download/run
/// the MSI ourselves, so the operator decides when to install. /// the MSI ourselves, so the operator decides when to install.
/// </summary> /// </summary>
public static void OpenReleasesPage() public static void OpenReleasesPage()
@ -124,7 +124,7 @@ public static class UpdateChecker
/// <summary> /// <summary>
/// Silent throttled launch-time check. Returns the result if a check actually /// Silent throttled launch-time check. Returns the result if a check actually
/// happened, or null if the cooldown window suppressed it. The cooldown lives /// happened, or null if the cooldown window suppressed it. The cooldown lives
/// in <c>%LOCALAPPDATA%\TeamsISO\last-update-check.txt</c> as an ISO 8601 /// in <c>%LOCALAPPDATA%\Dragon-ISO\last-update-check.txt</c> as an ISO 8601
/// timestamp; a missing file means "never checked, do it now." /// timestamp; a missing file means "never checked, do it now."
/// </summary> /// </summary>
public static async Task<UpdateCheckResult?> CheckIfDueAsync(TimeSpan cooldown, CancellationToken ct = default) public static async Task<UpdateCheckResult?> CheckIfDueAsync(TimeSpan cooldown, CancellationToken ct = default)
@ -165,8 +165,8 @@ public static class UpdateChecker
} }
/// <summary> /// <summary>
/// Test-only seam when set, overrides the default /// Test-only seam — when set, overrides the default
/// %LOCALAPPDATA%\TeamsISO path that holds the cooldown stamp + /// %LOCALAPPDATA%\Dragon-ISO path that holds the cooldown stamp +
/// the opt-out flag. Tests use this to write to a tempdir so /// the opt-out flag. Tests use this to write to a tempdir so
/// CheckIfDueAsync's throttle path can be exercised without /// CheckIfDueAsync's throttle path can be exercised without
/// hitting real disk paths or the real network (the throttle /// hitting real disk paths or the real network (the throttle
@ -177,7 +177,7 @@ public static class UpdateChecker
private static string StateDirectory => StateDirectoryOverride ?? private static string StateDirectory => StateDirectoryOverride ??
Path.Combine( Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO"); "Dragon-ISO");
private static string CooldownPath => private static string CooldownPath =>
Path.Combine(StateDirectory, "last-update-check.txt"); Path.Combine(StateDirectory, "last-update-check.txt");
@ -229,7 +229,7 @@ public static class UpdateChecker
/// <summary> /// <summary>
/// Parse a "vX.Y.Z" or "X.Y.Z(.N)" string into a Version. Strips any /// 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 /// pre-release suffix ("-alpha", "-beta") so the comparison is on
/// numeric components only pre-release vs. release ordering is a /// numeric components only — pre-release vs. release ordering is a
/// follow-up if we need it. Internal so tests can pin parsing /// follow-up if we need it. Internal so tests can pin parsing
/// behaviour without HTTP. /// behaviour without HTTP.
/// </summary> /// </summary>

View file

@ -1,12 +1,12 @@
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Windows; using System.Windows;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Saves / restores the main window's size, position, and state across launches. /// Saves / restores the main window's size, position, and state across launches.
/// Stored as JSON at <c>%LOCALAPPDATA%\TeamsISO\window.json</c>. Multi-monitor /// 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 /// 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 /// rejected on restore so the window doesn't disappear off-screen when a monitor
/// has been disconnected. /// has been disconnected.
@ -14,8 +14,8 @@ namespace TeamsISO.App.Services;
public static class WindowStateStore public static class WindowStateStore
{ {
/// <summary> /// <summary>
/// Test-only seam when set, overrides the default /// Test-only seam — when set, overrides the default
/// %LOCALAPPDATA%\TeamsISO\window.json path. Lets tests verify /// %LOCALAPPDATA%\Dragon-ISO\window.json path. Lets tests verify
/// the serialization round-trip without polluting the dev's /// the serialization round-trip without polluting the dev's
/// real placement state. /// real placement state.
/// </summary> /// </summary>
@ -24,7 +24,7 @@ public static class WindowStateStore
private static string Path => PathOverride ?? private static string Path => PathOverride ??
System.IO.Path.Combine( System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Dragon-ISO",
"window.json"); "window.json");
public sealed record Snapshot( public sealed record Snapshot(
@ -70,7 +70,7 @@ public static class WindowStateStore
var snap = JsonSerializer.Deserialize<Snapshot>(json); var snap = JsonSerializer.Deserialize<Snapshot>(json);
if (snap is null) return false; if (snap is null) return false;
// Sanity-check sizes (don't restore a 0×0 or absurdly large window). // 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 < 320 || snap.Height < 240) return false;
if (snap.Width > 16000 || snap.Height > 12000) return false; if (snap.Width > 16000 || snap.Height > 12000) return false;

View file

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

View file

@ -1,9 +1,9 @@
<Window x:Class="TeamsISO.App.TeamsEmbedWindow" <Window x:Class="DragonISO.App.TeamsEmbedWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework" xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Teams (embedded)" Title="Teams (embedded)"
Icon="/Assets/teamsiso.ico" Icon="/Assets/DragonISO.ico"
Width="1280" Height="720" Width="1280" Height="720"
MinWidth="640" MinHeight="360" MinWidth="640" MinHeight="360"
Background="Black" Background="Black"

View file

@ -1,22 +1,22 @@
using System.Windows; using System.Windows;
using System.Windows.Interop; using System.Windows.Interop;
using TeamsISO.App.Services; using DragonISO.App.Services;
namespace TeamsISO.App; namespace DragonISO.App;
/// <summary> /// <summary>
/// Phase E.4 experimental hosts an embedded copy of the Teams main /// Phase E.4 experimental — hosts an embedded copy of the Teams main
/// window via SetParent. Operator opens this from Settings → DISPLAY → /// window via SetParent. Operator opens this from Settings → DISPLAY →
/// 'Embed Teams window'. The host Border's HWND becomes Teams' parent on /// 'Embed Teams window'. The host Border's HWND becomes Teams' parent on
/// Loaded; SizeChanged keeps Teams fitted; Closing always restores Teams /// Loaded; SizeChanged keeps Teams fitted; Closing always restores Teams
/// to a normal top-level window before we exit. /// to a normal top-level window before we exit.
/// ///
/// Failsafes: /// Failsafes:
/// If no Teams window is found at Loaded, show a friendly message /// • If no Teams window is found at Loaded, show a friendly message
/// instead of leaving the host blank. /// instead of leaving the host blank.
/// Restore-on-close runs in a finally block so a crash mid-host /// • Restore-on-close runs in a finally block so a crash mid-host
/// can't leave Teams orphaned with stripped window styles. /// can't leave Teams orphaned with stripped window styles.
/// • TeamsEmbedHost.RestoreEmbed is idempotent — safe to call even if /// • TeamsEmbedHost.RestoreEmbed is idempotent — safe to call even if
/// embedding never succeeded. /// embedding never succeeded.
/// </summary> /// </summary>
public partial class TeamsEmbedWindow : Window public partial class TeamsEmbedWindow : Window
@ -36,7 +36,7 @@ public partial class TeamsEmbedWindow : Window
MessageBox.Show( MessageBox.Show(
"Couldn't obtain a host HWND for the embed window. " + "Couldn't obtain a host HWND for the embed window. " +
"Try closing and re-opening the embed window.", "Try closing and re-opening the embed window.",
"TeamsISO — embed", "Dragon-ISO — embed",
MessageBoxButton.OK, MessageBoxImage.Warning); MessageBoxButton.OK, MessageBoxImage.Warning);
return; return;
} }
@ -48,7 +48,7 @@ public partial class TeamsEmbedWindow : Window
MessageBox.Show( MessageBox.Show(
"Couldn't find a Microsoft Teams window to embed. " + "Couldn't find a Microsoft Teams window to embed. " +
"Launch Teams first (rail camera icon), then re-open this window.", "Launch Teams first (rail camera icon), then re-open this window.",
"TeamsISO — embed", "Dragon-ISO — embed",
MessageBoxButton.OK, MessageBoxImage.Information); MessageBoxButton.OK, MessageBoxImage.Information);
} }
} }
@ -65,7 +65,7 @@ public partial class TeamsEmbedWindow : Window
// ALWAYS restore Teams to top-level state when this window closes, // ALWAYS restore Teams to top-level state when this window closes,
// even if the embed never succeeded. Idempotent. // even if the embed never succeeded. Idempotent.
try { TeamsEmbedHost.RestoreEmbed(); } try { TeamsEmbedHost.RestoreEmbed(); }
catch { /* defensive restore is best-effort */ } catch { /* defensive — restore is best-effort */ }
} }
private void OnClose(object sender, RoutedEventArgs e) => Close(); private void OnClose(object sender, RoutedEventArgs e) => Close();

View file

@ -58,6 +58,6 @@
of the resource dictionary. of the resource dictionary.
--> -->
<BitmapImage x:Key="Wd.BrandMark.Image" <BitmapImage x:Key="Wd.BrandMark.Image"
UriSource="pack://application:,,,/TeamsISO;component/Assets/dragon-mark-white.png" UriSource="pack://application:,,,/DragonISO;component/Assets/dragon-mark-white.png"
CacheOption="OnLoad"/> CacheOption="OnLoad"/>
</ResourceDictionary> </ResourceDictionary>

View file

@ -55,6 +55,6 @@
See Theme.Dark.xaml's comment for the CacheOption=OnLoad rationale. See Theme.Dark.xaml's comment for the CacheOption=OnLoad rationale.
--> -->
<BitmapImage x:Key="Wd.BrandMark.Image" <BitmapImage x:Key="Wd.BrandMark.Image"
UriSource="pack://application:,,,/TeamsISO;component/Assets/dragon-mark-black.png" UriSource="pack://application:,,,/DragonISO;component/Assets/dragon-mark-black.png"
CacheOption="OnLoad"/> CacheOption="OnLoad"/>
</ResourceDictionary> </ResourceDictionary>

View file

@ -4,7 +4,7 @@
xmlns:sys="clr-namespace:System;assembly=mscorlib"> xmlns:sys="clr-namespace:System;assembly=mscorlib">
<!-- <!--
TeamsISO design system — Wild Dragon brand × Microsoft Teams layout. DragonISO design system — Wild Dragon brand × Microsoft Teams layout.
Brand reference: wilddragon.net Brand reference: wilddragon.net
Primary canvas: #0a0a0a Primary canvas: #0a0a0a

View file

@ -1,6 +1,6 @@
using TeamsISO.Engine.Domain; using DragonISO.Engine.Domain;
namespace TeamsISO.App.ViewModels; namespace DragonISO.App.ViewModels;
public sealed class AlertBannerViewModel : ObservableObject public sealed class AlertBannerViewModel : ObservableObject
{ {

View file

@ -1,18 +1,18 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Windows.Threading; using System.Windows.Threading;
using TeamsISO.App.Services; using DragonISO.App.Services;
namespace TeamsISO.App.ViewModels; namespace DragonISO.App.ViewModels;
/// <summary> /// <summary>
/// View-model for the v2 Ctrl+K command palette. Owns the static list of /// 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 /// commands the operator can invoke, plus a free-text filter that whittles
/// the visible list down. /// the visible list down.
/// ///
/// The palette is the v2 redesign's navigation surface it replaces the /// The palette is the v2 redesign's navigation surface — it replaces the
/// v1 rail's launch / hide / settings buttons (still discoverable in the /// v1 rail's launch / hide / settings buttons (still discoverable in the
/// 32px header) AND the buried-in-tabs operator actions like "Apply /// 32px header) AND the buried-in-tabs operator actions like "Apply
/// transcoder topology" or "Stop all ISOs" that previously needed /// transcoder topology" or "Stop all ISOs" that previously needed
@ -54,7 +54,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
/// <summary>Filtered command list, bound to the palette ListBox.</summary> /// <summary>Filtered command list, bound to the palette ListBox.</summary>
public ObservableCollection<PaletteCommand> Visible { get; } public ObservableCollection<PaletteCommand> Visible { get; }
/// <summary>Currently-highlighted command. Driven by ↑/↓ in the search box and by mouse hover.</summary> /// <summary>Currently-highlighted command. Driven by ↑/↓ in the search box and by mouse hover.</summary>
public PaletteCommand? Selected public PaletteCommand? Selected
{ {
get => _selected; get => _selected;
@ -64,7 +64,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
/// <summary> /// <summary>
/// Move the selection up or down within the visible list, wrapping at the /// Move the selection up or down within the visible list, wrapping at the
/// edges. Called from the palette's PreviewKeyDown when the operator /// edges. Called from the palette's PreviewKeyDown when the operator
/// presses ↑ / ↓ while focus is in the search box. /// presses ↑ / ↓ while focus is in the search box.
/// </summary> /// </summary>
public void MoveSelection(int direction) public void MoveSelection(int direction)
{ {
@ -113,7 +113,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
{ {
if (c.Label.Contains(query, StringComparison.OrdinalIgnoreCase)) return true; if (c.Label.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
if (c.Category.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 // Keywords is a single space-separated string of synonyms — Contains
// over the whole blob suffices for the operator's short-token typing. // over the whole blob suffices for the operator's short-token typing.
if (!string.IsNullOrEmpty(c.Keywords) && if (!string.IsNullOrEmpty(c.Keywords) &&
c.Keywords.Contains(query, StringComparison.OrdinalIgnoreCase)) c.Keywords.Contains(query, StringComparison.OrdinalIgnoreCase))
@ -133,7 +133,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
var vm = _main; var vm = _main;
return new List<PaletteCommand> return new List<PaletteCommand>
{ {
// ─── QUICK ─── operator's top-of-mind verbs // ─── QUICK ─── operator's top-of-mind verbs
new("Quick", "Enable all online", "ISOs enable everyone start everything live", "Ctrl+E", new("Quick", "Enable all online", "ISOs enable everyone start everything live", "Ctrl+E",
() => InvokeIfReady(vm.EnableAllOnlineCommand)), () => InvokeIfReady(vm.EnableAllOnlineCommand)),
new("Quick", "Stop all ISOs", "panic stop everything kill disable", "Ctrl+Shift+S", new("Quick", "Stop all ISOs", "panic stop everything kill disable", "Ctrl+Shift+S",
@ -141,7 +141,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
new("Quick", "Refresh discovery", "rediscover sources rebuild finder NDI", "Ctrl+R", new("Quick", "Refresh discovery", "rediscover sources rebuild finder NDI", "Ctrl+R",
() => InvokeIfReady(vm.RefreshDiscoveryCommand)), () => InvokeIfReady(vm.RefreshDiscoveryCommand)),
// ─── TEAMS ─── direct UIA orchestration // ─── TEAMS ─── direct UIA orchestration
new("Teams", "Mute / unmute", "microphone audio silence toggle", null, new("Teams", "Mute / unmute", "microphone audio silence toggle", null,
() => InvokeIfReady(vm.ToggleMuteCommand)), () => InvokeIfReady(vm.ToggleMuteCommand)),
new("Teams", "Toggle camera", "video webcam on off", null, new("Teams", "Toggle camera", "video webcam on off", null,
@ -154,7 +154,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
() => RunOnUi(() => () => RunOnUi(() =>
{ {
if (!TeamsLauncher.IsRunning() && TeamsLauncher.TryLaunch(out _)) if (!TeamsLauncher.IsRunning() && TeamsLauncher.TryLaunch(out _))
vm.Toast.Show("Launching Microsoft Teams"); vm.Toast.Show("Launching Microsoft Teams…");
else else
TeamsLauncher.ShowWindows(); TeamsLauncher.ShowWindows();
})), })),
@ -171,11 +171,11 @@ public sealed class CommandPaletteViewModel : ObservableObject
vm.Toast.Show(n > 0 ? $"Restored {n} Teams window(s)" : "No Teams windows to restore"); vm.Toast.Show(n > 0 ? $"Restored {n} Teams window(s)" : "No Teams windows to restore");
})), })),
// ─── NETWORK ─── // ─── NETWORK ───
new("Network", "Apply transcoder topology", "ndi groups isolate teamsiso-input private", null, new("Network", "Apply transcoder topology", "ndi groups isolate Dragon-ISO-input private", null,
() => InvokeIfReady(vm.Settings.ApplyTranscoderTopologyCommand)), () => InvokeIfReady(vm.Settings.ApplyTranscoderTopologyCommand)),
// ─── APP ─── // ─── APP ───
new("App", "Theme: dark", "appearance night mode", null, new("App", "Theme: dark", "appearance night mode", null,
() => RunOnUi(() => ThemeManager.Current.Set("Dark"))), () => RunOnUi(() => ThemeManager.Current.Set("Dark"))),
new("App", "Theme: light", "appearance day mode bright", null, new("App", "Theme: light", "appearance day mode bright", null,
@ -199,7 +199,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
/// <summary> /// <summary>
/// One command in the Ctrl+K palette. <see cref="Keywords"/> is an optional /// One command in the Ctrl+K palette. <see cref="Keywords"/> is an optional
/// space of additional search terms the operator might type "ndi" or /// space of additional search terms — the operator might type "ndi" or
/// "private" and still match "Apply transcoder topology". /// "private" and still match "Apply transcoder topology".
/// </summary> /// </summary>
public sealed record PaletteCommand( public sealed record PaletteCommand(

View file

@ -1,10 +1,10 @@
using System.IO; using System.IO;
using System.Windows; using System.Windows;
using TeamsISO.App.Services; using DragonISO.App.Services;
using TeamsISO.Engine.Controller; using DragonISO.Engine.Controller;
using TeamsISO.Engine.Domain; using DragonISO.Engine.Domain;
namespace TeamsISO.App.ViewModels; namespace DragonISO.App.ViewModels;
/// <summary> /// <summary>
/// Bindings for the global settings panel: framerate, resolution, aspect, audio, /// Bindings for the global settings panel: framerate, resolution, aspect, audio,
@ -29,13 +29,13 @@ public sealed class GlobalSettingsViewModel : ObservableObject
private bool _oscBridgeEnabled; private bool _oscBridgeEnabled;
private int _oscBridgePort = OscBridge.DefaultPort; private int _oscBridgePort = OscBridge.DefaultPort;
private bool _updateCheckOnLaunch = UpdateChecker.LaunchCheckEnabled; private bool _updateCheckOnLaunch = UpdateChecker.LaunchCheckEnabled;
private string _outputNameTemplate = TeamsISO.App.Services.OutputNameTemplate.Get(); private string _outputNameTemplate = DragonISO.App.Services.OutputNameTemplate.Get();
private UIPreferences.SortMode _participantSort = UIPreferences.SortMode.JoinOrder; private UIPreferences.SortMode _participantSort = UIPreferences.SortMode.JoinOrder;
private bool _minimizeToTray; private bool _minimizeToTray;
private bool _controlSurfaceLanReachable; private bool _controlSurfaceLanReachable;
private bool _launchTeamsOnStartup; private bool _launchTeamsOnStartup;
private bool _autoHideTeamsWindows; private bool _autoHideTeamsWindows;
// _autoRecordOnCall removed recording surface axed. // _autoRecordOnCall removed — recording surface axed.
private bool _embedTeamsWindow; private bool _embedTeamsWindow;
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null) public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
@ -54,7 +54,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
// Restore persisted UI toggles so the operator's preference survives // Restore persisted UI toggles so the operator's preference survives
// process restarts. UIPreferences keeps a tiny JSON file under // process restarts. UIPreferences keeps a tiny JSON file under
// %LOCALAPPDATA%\TeamsISO\ui-prefs.json — defaults match the original // %LOCALAPPDATA%\Dragon-ISO\ui-prefs.json — defaults match the original
// in-memory init (HideLocalSelf=true, AutoDisableOnDeparture=false). // in-memory init (HideLocalSelf=true, AutoDisableOnDeparture=false).
var uiPrefs = UIPreferences.Load(); var uiPrefs = UIPreferences.Load();
_hideLocalSelf = uiPrefs.HideLocalSelf; _hideLocalSelf = uiPrefs.HideLocalSelf;
@ -64,14 +64,14 @@ public sealed class GlobalSettingsViewModel : ObservableObject
_controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable; _controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable;
_launchTeamsOnStartup = uiPrefs.LaunchTeamsOnStartup; _launchTeamsOnStartup = uiPrefs.LaunchTeamsOnStartup;
_autoHideTeamsWindows = uiPrefs.AutoHideTeamsWindows; _autoHideTeamsWindows = uiPrefs.AutoHideTeamsWindows;
// AutoRecordOnCall removed recording surface axed. // AutoRecordOnCall removed — recording surface axed.
_embedTeamsWindow = uiPrefs.EmbedTeamsWindow; _embedTeamsWindow = uiPrefs.EmbedTeamsWindow;
_controlSurfaceEnabled = uiPrefs.ControlSurfaceEnabled; _controlSurfaceEnabled = uiPrefs.ControlSurfaceEnabled;
// Bring the auto-apply flag in from the presets store so the checkbox // Bring the auto-apply flag in from the presets store so the checkbox
// reflects the user's prior choice when the settings panel opens. // reflects the user's prior choice when the settings panel opens.
try { _autoApplyLastPreset = OperatorPresetStore.GetStartupPreference().AutoApplyOnStartup; } try { _autoApplyLastPreset = OperatorPresetStore.GetStartupPreference().AutoApplyOnStartup; }
catch { /* best-effort disk read failures shouldn't block UI startup */ } catch { /* best-effort — disk read failures shouldn't block UI startup */ }
// Recording-directory init removed alongside the rest of the recording surface. // Recording-directory init removed alongside the rest of the recording surface.
@ -95,7 +95,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
// Hands the URL off to the OS shell so the user's default browser // Hands the URL off to the OS shell so the user's default browser
// opens it. Operators previewing how the control panel looks on // opens it. Operators previewing how the control panel looks on
// their phone / tablet / second monitor would otherwise have to // their phone / tablet / second monitor would otherwise have to
// copy-paste the URL this is a one-click preview. // copy-paste the URL — this is a one-click preview.
try try
{ {
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
@ -114,7 +114,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary> /// <summary>
/// Open the embedded HTML control panel (the <c>/ui</c> endpoint) in the /// Open the embedded HTML control panel (the <c>/ui</c> endpoint) in the
/// default browser. Enabled regardless of whether the control surface is /// default browser. Enabled regardless of whether the control surface is
/// running if it isn't, the browser will show a connection error, which /// running — if it isn't, the browser will show a connection error, which
/// is informative; operators learn the surface needs to be enabled first. /// is informative; operators learn the surface needs to be enabled first.
/// </summary> /// </summary>
public RelayCommand OpenControlSurfaceCommand { get; } public RelayCommand OpenControlSurfaceCommand { get; }
@ -122,9 +122,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
private void ResetOutputDefaults() private void ResetOutputDefaults()
{ {
var confirm = MessageBox.Show( var confirm = MessageBox.Show(
"Reset framerate, resolution, aspect and audio to TeamsISO defaults?\n\n" + "Reset framerate, resolution, aspect and audio to Dragon-ISO defaults?\n\n" +
"This won't touch your NDI group configuration or display toggles.", "This won't touch your NDI group configuration or display toggles.",
"TeamsISO — Reset output defaults", "Dragon-ISO — Reset output defaults",
MessageBoxButton.YesNo, MessageBoxButton.YesNo,
MessageBoxImage.Question, MessageBoxImage.Question,
MessageBoxResult.No); MessageBoxResult.No);
@ -135,7 +135,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
Resolution = defaults.Resolution; Resolution = defaults.Resolution;
Aspect = defaults.Aspect; Aspect = defaults.Aspect;
Audio = defaults.Audio; Audio = defaults.Audio;
_toast?.Show("Output settings reset to defaults click Apply Changes to commit"); _toast?.Show("Output settings reset to defaults — click Apply Changes to commit");
} }
public IEnumerable<TargetFramerate> AvailableFramerates => Enum.GetValues<TargetFramerate>(); public IEnumerable<TargetFramerate> AvailableFramerates => Enum.GetValues<TargetFramerate>();
@ -148,15 +148,15 @@ public sealed class GlobalSettingsViewModel : ObservableObject
public AspectMode Aspect { get => _aspect; set => SetField(ref _aspect, value); } public AspectMode Aspect { get => _aspect; set => SetField(ref _aspect, value); }
public AudioMode Audio { get => _audio; set => SetField(ref _audio, value); } public AudioMode Audio { get => _audio; set => SetField(ref _audio, value); }
/// <summary>NDI discovery group(s) comma-separated. Empty = default (Public).</summary> /// <summary>NDI discovery group(s) — comma-separated. Empty = default (Public).</summary>
public string DiscoveryGroups { get => _discoveryGroups; set => SetField(ref _discoveryGroups, value); } public string DiscoveryGroups { get => _discoveryGroups; set => SetField(ref _discoveryGroups, value); }
/// <summary>NDI output group(s) comma-separated. Empty = default (Public).</summary> /// <summary>NDI output group(s) — comma-separated. Empty = default (Public).</summary>
public string OutputGroups { get => _outputGroups; set => SetField(ref _outputGroups, value); } public string OutputGroups { get => _outputGroups; set => SetField(ref _outputGroups, value); }
/// <summary> /// <summary>
/// Hide the user's own self-preview ("(Local)") from the participants list. /// Hide the user's own self-preview ("(Local)") from the participants list.
/// On by default operators rarely want to ISO-route their own preview. /// On by default — operators rarely want to ISO-route their own preview.
/// Read by <see cref="MainViewModel"/> when filtering the list it presents. /// Read by <see cref="MainViewModel"/> when filtering the list it presents.
/// Persisted to <c>ui-prefs.json</c> via <see cref="UIPreferences"/>. /// Persisted to <c>ui-prefs.json</c> via <see cref="UIPreferences"/>.
/// </summary> /// </summary>
@ -172,7 +172,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary> /// <summary>
/// When a participant leaves the meeting (their NDI source disappears), /// When a participant leaves the meeting (their NDI source disappears),
/// automatically tear down their ISO pipeline. Off by default so transient /// automatically tear down their ISO pipeline. Off by default so transient
/// drops don't lose the operator's routing but useful for clean /// drops don't lose the operator's routing — but useful for clean
/// show-end behavior. Read by MainViewModel when reconciling departures. /// show-end behavior. Read by MainViewModel when reconciling departures.
/// Persisted to <c>ui-prefs.json</c>. /// Persisted to <c>ui-prefs.json</c>.
/// </summary> /// </summary>
@ -193,7 +193,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary> /// <summary>
/// How the participants DataGrid is sorted. Persisted across launches via /// How the participants DataGrid is sorted. Persisted across launches via
/// <see cref="UIPreferences"/>. Reaches into <see cref="MainViewModel.SetSortMode"/> /// <see cref="UIPreferences"/>. Reaches into <see cref="MainViewModel.SetSortMode"/>
/// on Application.Current to actually apply the sort to the live view /// 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 /// the settings VM doesn't directly know about the main VM but App holds
/// both and exposes the main window via its DataContext. /// both and exposes the main window via its DataContext.
/// </summary> /// </summary>
@ -215,7 +215,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// Minimize-to-tray behavior. When on, minimizing the main window hides /// Minimize-to-tray behavior. When on, minimizing the main window hides
/// it from the taskbar and shows a tray icon (double-click to restore). /// 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". /// Right-click menu on the tray icon offers "Show", "Stop all ISOs", "Exit".
/// Useful for long unattended shows where the operator wants TeamsISO /// Useful for long unattended shows where the operator wants Dragon-ISO
/// running but invisible. /// running but invisible.
/// </summary> /// </summary>
public bool MinimizeToTray public bool MinimizeToTray
@ -231,20 +231,20 @@ public sealed class GlobalSettingsViewModel : ObservableObject
var tray = (Application.Current as App)?.TrayIcon; var tray = (Application.Current as App)?.TrayIcon;
if (tray is not null) tray.Enabled = value; if (tray is not null) tray.Enabled = value;
_toast?.Show(value _toast?.Show(value
? "Minimize-to-tray enabled minimizing now hides the window" ? "Minimize-to-tray enabled — minimizing now hides the window"
: "Minimize-to-tray disabled"); : "Minimize-to-tray disabled");
} }
} }
/// <summary> /// <summary>
/// Snapshot the current persistable UI state to disk. Called from any /// Snapshot the current persistable UI state to disk. Called from any
/// <see cref="UIPreferences.Prefs"/>-backed setter. Best-effort disk /// <see cref="UIPreferences.Prefs"/>-backed setter. Best-effort — disk
/// failures don't surface to the operator (the in-memory state still /// failures don't surface to the operator (the in-memory state still
/// reflects their click for this session). /// reflects their click for this session).
/// </summary> /// </summary>
private void PersistUiPrefs() private void PersistUiPrefs()
{ {
// Theme isn't owned by this VM read whatever ThemeManager has // Theme isn't owned by this VM — read whatever ThemeManager has
// persisted (or default) so we don't clobber it on save. // persisted (or default) so we don't clobber it on save.
var existing = UIPreferences.Load(); var existing = UIPreferences.Load();
UIPreferences.Save(new UIPreferences.Prefs( UIPreferences.Save(new UIPreferences.Prefs(
@ -261,9 +261,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
} }
/// <summary> /// <summary>
/// Auto-launch the Microsoft Teams desktop client when TeamsISO starts. /// Auto-launch the Microsoft Teams desktop client when Dragon-ISO starts.
/// Paired with <see cref="AutoHideTeamsWindows"/> gives the operator a /// Paired with <see cref="AutoHideTeamsWindows"/> gives the operator a
/// "TeamsISO is the only window I see" experience — Teams runs in the /// "Dragon-ISO is the only window I see" experience — Teams runs in the
/// background, all interaction happens through the participants DataGrid /// background, all interaction happens through the participants DataGrid
/// + IN-CALL bar. /// + IN-CALL bar.
/// </summary> /// </summary>
@ -296,9 +296,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
// removed alongside the rest of the recording surface. // removed alongside the rest of the recording surface.
/// <summary> /// <summary>
/// EXPERIMENTAL: SetParent-reparents Teams' main window into a TeamsISO- /// EXPERIMENTAL: SetParent-reparents Teams' main window into a Dragon-ISO-
/// owned host so Teams visually appears inside our window. WebView2 in /// owned host so Teams visually appears inside our window. WebView2 in
/// modern Teams may render weirdly after reparent if so, untick and /// modern Teams may render weirdly after reparent — if so, untick and
/// fall back to the auto-hide flow. Polling logic in MainWindow.xaml.cs /// fall back to the auto-hide flow. Polling logic in MainWindow.xaml.cs
/// applies / restores the embed; this property is just the persisted /// applies / restores the embed; this property is just the persisted
/// toggle. /// toggle.
@ -313,7 +313,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
} }
/// <summary> /// <summary>
/// REST control surface Stream Deck / Companion / thin-client controllers. /// REST control surface — Stream Deck / Companion / thin-client controllers.
/// Off by default. Bind address depends on <see cref="ControlSurfaceLanReachable"/>: /// Off by default. Bind address depends on <see cref="ControlSurfaceLanReachable"/>:
/// loopback (127.0.0.1) by default, or all-interfaces when LAN-reachable. /// loopback (127.0.0.1) by default, or all-interfaces when LAN-reachable.
/// </summary> /// </summary>
@ -354,9 +354,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary> /// <summary>
/// LAN-reachable mode. When false (default), control surface binds to /// LAN-reachable mode. When false (default), control surface binds to
/// 127.0.0.1 only this machine. When true, binds to all interfaces so /// 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 /// a thin-client controller on a phone or another laptop can drive
/// TeamsISO. The OSC bridge follows suit if it's running. /// DragonISO. The OSC bridge follows suit if it's running.
/// ///
/// Important: HttpListener requires either Administrator privilege OR a /// Important: HttpListener requires either Administrator privilege OR a
/// one-time URL ACL reservation for non-loopback prefixes: /// one-time URL ACL reservation for non-loopback prefixes:
@ -404,10 +404,10 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary> /// <summary>
/// Best-effort routable IPv4 address suitable for showing the operator a /// Best-effort routable IPv4 address suitable for showing the operator a
/// "paste me into the thin client" URL. Skips: /// "paste me into the thin client" URL. Skips:
/// loopback interfaces (127.x) /// • loopback interfaces (127.x)
/// • tunnel/virtual interfaces (NetworkInterfaceType.Tunnel — e.g. WSL, /// • tunnel/virtual interfaces (NetworkInterfaceType.Tunnel — e.g. WSL,
/// Hyper-V, Tailscale, OpenVPN-style virtuals) /// Hyper-V, Tailscale, OpenVPN-style virtuals)
/// • APIPA/link-local addresses (169.254.x — assigned when DHCP fails; /// • 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) /// 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 /// Prefers Ethernet/Wi-Fi over everything else, then falls back to the
/// first non-link-local non-loopback IPv4. Returns null only if no /// first non-link-local non-loopback IPv4. Returns null only if no
@ -448,7 +448,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
} }
/// <summary> /// <summary>
/// OSC bridge over UDP same command surface as the REST endpoints, /// OSC bridge over UDP — same command surface as the REST endpoints,
/// reachable from Companion / TouchOSC / lighting consoles. Off by default; /// reachable from Companion / TouchOSC / lighting consoles. Off by default;
/// bound to 127.0.0.1 only. /// bound to 127.0.0.1 only.
/// </summary> /// </summary>
@ -488,7 +488,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// Output-name template applied when the operator enables an ISO without /// Output-name template applied when the operator enables an ISO without
/// a per-participant CustomName. Default <c>"{name}"</c> renders the /// a per-participant CustomName. Default <c>"{name}"</c> renders the
/// speaker's display name directly (changed from the legacy /// speaker's display name directly (changed from the legacy
/// <c>"TEAMSISO_{guid}"</c> in 0.9.0-rc19, since downstream switchers /// <c>"Dragon-ISO_{guid}"</c> in 0.9.0-rc19, since downstream switchers
/// almost always want human-readable identifiers). Switch back to a /// almost always want human-readable identifiers). Switch back to a
/// guid-based template if you need stable IDs that survive participant /// guid-based template if you need stable IDs that survive participant
/// name changes. See <see cref="OutputNameTemplate"/> for the supported /// name changes. See <see cref="OutputNameTemplate"/> for the supported
@ -501,7 +501,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
{ {
if (SetField(ref _outputNameTemplate, value)) if (SetField(ref _outputNameTemplate, value))
{ {
TeamsISO.App.Services.OutputNameTemplate.Set(value ?? string.Empty); DragonISO.App.Services.OutputNameTemplate.Set(value ?? string.Empty);
} }
} }
} }
@ -521,7 +521,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
{ {
UpdateChecker.LaunchCheckEnabled = value; UpdateChecker.LaunchCheckEnabled = value;
_toast?.Show(value _toast?.Show(value
? "Update checks enabled runs once per 24h on launch" ? "Update checks enabled — runs once per 24h on launch"
: "Update checks disabled"); : "Update checks disabled");
} }
} }
@ -530,7 +530,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary> /// <summary>
/// On launch, automatically re-apply the most recently applied operator preset. /// On launch, automatically re-apply the most recently applied operator preset.
/// Closes the loop on the recurring-show workflow: the operator clicks Apply /// Closes the loop on the recurring-show workflow: the operator clicks Apply
/// once, and from that point on TeamsISO restores the same routing on every /// once, and from that point on Dragon-ISO restores the same routing on every
/// subsequent launch as soon as the matching participants come online. /// subsequent launch as soon as the matching participants come online.
/// Persisted to <c>presets.json</c>'s <c>AutoApplyOnStartup</c> field via /// Persisted to <c>presets.json</c>'s <c>AutoApplyOnStartup</c> field via
/// <see cref="OperatorPresetStore"/>. /// <see cref="OperatorPresetStore"/>.
@ -559,15 +559,15 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary> /// <summary>
/// Restore Output settings (framerate, resolution, aspect, audio) to the engine's /// Restore Output settings (framerate, resolution, aspect, audio) to the engine's
/// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky /// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky —
/// the operator's transcoder topology is a per-machine setting that survives /// the operator's transcoder topology is a per-machine setting that survives
/// preferences resets) and doesn't touch Display toggles. Confirms first. /// preferences resets) and doesn't touch Display toggles. Confirms first.
/// </summary> /// </summary>
public RelayCommand ResetOutputDefaultsCommand { get; } public RelayCommand ResetOutputDefaultsCommand { get; }
/// <summary> /// <summary>
/// One-click "set up the transcoder topology" writes ndi-config.v1.json so all /// One-click "set up the transcoder topology" — writes ndi-config.v1.json so all
/// local senders broadcast on a private group ("teamsiso-input") while local /// 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 /// 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). /// 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. /// User has to restart Teams for the new ndi-config.v1.json to take effect there.
@ -596,7 +596,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
{ {
MessageBox.Show( MessageBox.Show(
$"Could not write NDI Access Manager config.\n\n{result.ErrorMessage}\n\nPath: {result.ConfigPath}", $"Could not write NDI Access Manager config.\n\n{result.ErrorMessage}\n\nPath: {result.ConfigPath}",
"TeamsISO — Apply transcoder topology", "Dragon-ISO — Apply transcoder topology",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
return; return;
@ -617,16 +617,16 @@ public sealed class GlobalSettingsViewModel : ObservableObject
: $"A backup of your prior NDI config was saved to:\n{result.BackupPath}"; : $"A backup of your prior NDI config was saved to:\n{result.BackupPath}";
MessageBox.Show( MessageBox.Show(
"Transcoder topology applied. \n\n" + "Transcoder topology applied. ✓\n\n" +
"• Local senders (Teams, etc.) will broadcast on group 'teamsiso-input'.\n" + "• Local senders (Teams, etc.) will broadcast on group 'Dragon-ISO-input'.\n" +
"• Local receivers will see both 'public' and 'teamsiso-input'.\n" + "• Local receivers will see both 'public' and 'Dragon-ISO-input'.\n" +
"• TeamsISO will discover from 'teamsiso-input' and re-emit on 'public'.\n\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" + "RESTART Microsoft Teams for the new NDI config to take effect there.\n\n" +
backupNote, backupNote,
"TeamsISO — Apply transcoder topology", "Dragon-ISO — Apply transcoder topology",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Information); MessageBoxImage.Information);
_toast?.Show("Transcoder topology applied restart Teams to take effect"); _toast?.Show("Transcoder topology applied — restart Teams to take effect");
} }
} }

View file

@ -1,10 +1,10 @@
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using TeamsISO.Engine.Controller; using DragonISO.Engine.Controller;
using TeamsISO.Engine.Domain; using DragonISO.Engine.Domain;
namespace TeamsISO.App.ViewModels; namespace DragonISO.App.ViewModels;
/// <summary> /// <summary>
/// View-model for the per-participant <see cref="FrameProcessingSettings"/> override /// View-model for the per-participant <see cref="FrameProcessingSettings"/> override
@ -37,7 +37,7 @@ public sealed class IsoOverrideDialogViewModel : ObservableObject
/// <summary> /// <summary>
/// Reference to the existing <see cref="GlobalSettingsViewModel"/> so the /// Reference to the existing <see cref="GlobalSettingsViewModel"/> so the
/// dialog's ComboBoxes can bind directly to its Available* lists there's /// dialog's ComboBoxes can bind directly to its Available* lists — there's
/// no point duplicating the Enum.GetValues calls here. /// no point duplicating the Enum.GetValues calls here.
/// </summary> /// </summary>
public GlobalSettingsViewModel Settings { get; } public GlobalSettingsViewModel Settings { get; }
@ -63,7 +63,7 @@ public sealed class IsoOverrideDialogViewModel : ObservableObject
DisplayName = displayName; DisplayName = displayName;
// Initialize the four enum values from the existing override (if any) // Initialize the four enum values from the existing override (if any)
// or fall back to the global settings the dialog should always open // or fall back to the global settings — the dialog should always open
// with values that already reflect what this pipeline is using. // with values that already reflect what this pipeline is using.
var source = currentOverride ?? new FrameProcessingSettings( var source = currentOverride ?? new FrameProcessingSettings(
settings.Framerate, settings.Framerate,
@ -107,8 +107,8 @@ public sealed class IsoOverrideDialogViewModel : ObservableObject
} }
/// <summary> /// <summary>
/// Convenience for XAML visibility binding true when we should show the /// Convenience for XAML visibility binding — true when we should show the
/// "Following global settings · Reset to global" affordance. /// "Following global settings · Reset to global" affordance.
/// </summary> /// </summary>
public bool FollowingGlobalsVisible => _hasOverride; public bool FollowingGlobalsVisible => _hasOverride;

View file

@ -1,8 +1,8 @@
using TeamsISO.App.Services; using DragonISO.App.Services;
namespace TeamsISO.App.ViewModels; namespace DragonISO.App.ViewModels;
// Bulk operations that touch every (or every-enabled) participant // Bulk operations that touch every (or every-enabled) participant —
// Stop all ISOs, Enable all online, Snapshot all enabled frames. // Stop all ISOs, Enable all online, Snapshot all enabled frames.
// Split out of MainViewModel.cs so the main file isn't dominated by // Split out of MainViewModel.cs so the main file isn't dominated by
// long async iteration loops. // long async iteration loops.
@ -33,7 +33,7 @@ public sealed partial class MainViewModel
p.Id, p.Id,
p.DisplayName) p.DisplayName)
: p.CustomName; : p.CustomName;
// 3-arg overload (no recordOverride) recording surface axed, // 3-arg overload (no recordOverride) — recording surface axed,
// so the engine's per-pipeline recorder sink stays unattached. // so the engine's per-pipeline recorder sink stays unattached.
await _controller.EnableIsoAsync(p.Id, resolvedName, CancellationToken.None); await _controller.EnableIsoAsync(p.Id, resolvedName, CancellationToken.None);
p.IsEnabled = true; p.IsEnabled = true;
@ -41,7 +41,7 @@ public sealed partial class MainViewModel
} }
catch catch
{ {
// Per-participant best-effort one bad source shouldn't // Per-participant best-effort — one bad source shouldn't
// abort the bulk operation. // abort the bulk operation.
} }
} }
@ -66,7 +66,7 @@ public sealed partial class MainViewModel
} }
var confirm = System.Windows.MessageBox.Show( var confirm = System.Windows.MessageBox.Show(
$"Stop {enabled.Length} running ISO(s)?\n\nThis tears down every active pipeline immediately.", $"Stop {enabled.Length} running ISO(s)?\n\nThis tears down every active pipeline immediately.",
"TeamsISO — Stop all ISOs", "Dragon-ISO — Stop all ISOs",
System.Windows.MessageBoxButton.YesNo, System.Windows.MessageBoxButton.YesNo,
System.Windows.MessageBoxImage.Warning, System.Windows.MessageBoxImage.Warning,
System.Windows.MessageBoxResult.No); System.Windows.MessageBoxResult.No);
@ -84,7 +84,7 @@ public sealed partial class MainViewModel
/// <summary> /// <summary>
/// Save a PNG of every currently-enabled participant's latest /// Save a PNG of every currently-enabled participant's latest
/// processed frame to a timestamped subdirectory under /// processed frame to a timestamped subdirectory under
/// %USERPROFILE%\Pictures\TeamsISO\. One folder per Snapshot All click /// %USERPROFILE%\Pictures\Dragon-ISO\. One folder per Snapshot All click
/// so back-to-back clicks don't comingle. Useful for end-of-meeting /// so back-to-back clicks don't comingle. Useful for end-of-meeting
/// archives, recapping who showed up, etc. /// archives, recapping who showed up, etc.
/// </summary> /// </summary>
@ -99,7 +99,7 @@ public sealed partial class MainViewModel
var rootDir = System.IO.Path.Combine( var rootDir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
"TeamsISO", "Dragon-ISO",
$"snapshots-{DateTimeOffset.Now:yyyyMMdd_HHmmss}"); $"snapshots-{DateTimeOffset.Now:yyyyMMdd_HHmmss}");
try try
@ -143,7 +143,7 @@ public sealed partial class MainViewModel
} }
Toast.Show(failed > 0 Toast.Show(failed > 0
? $"Saved {saved} snapshot(s) ({failed} failed) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}" ? $"Saved {saved} snapshot(s) ({failed} failed) to Pictures\\Dragon-ISO\\{System.IO.Path.GetFileName(rootDir)}"
: $"Saved {saved} snapshot(s) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}"); : $"Saved {saved} snapshot(s) to Pictures\\Dragon-ISO\\{System.IO.Path.GetFileName(rootDir)}");
} }
} }

View file

@ -1,23 +1,23 @@
using System.Windows.Threading; using System.Windows.Threading;
using TeamsISO.App.Services; using DragonISO.App.Services;
namespace TeamsISO.App.ViewModels; namespace DragonISO.App.ViewModels;
// Auto-apply-last-preset path. Split out of MainViewModel.cs so the // Auto-apply-last-preset path. Split out of MainViewModel.cs so the
// pending-preset bookkeeping doesn't clutter the main file. // pending-preset bookkeeping doesn't clutter the main file.
// //
// Lifecycle: // Lifecycle:
// InitializeAsync (in main file) reads operator preference + last-applied // • InitializeAsync (in main file) reads operator preference + last-applied
// name from OperatorPresetStore and sets _pendingPresetName + deadline. // name from OperatorPresetStore and sets _pendingPresetName + deadline.
// OnParticipantsChanged (in main file) calls TryAutoApplyPendingPreset // • OnParticipantsChanged (in main file) calls TryAutoApplyPendingPreset
// once participants populate. // once participants populate.
// RequestApplyPresetOnStartup is the CLI hook: `--apply-preset NAME`. // • RequestApplyPresetOnStartup is the CLI hook: `--apply-preset NAME`.
public sealed partial class MainViewModel public sealed partial class MainViewModel
{ {
// Set on InitializeAsync from disk; cleared once we successfully apply // Set on InitializeAsync from disk; cleared once we successfully apply
// (so we don't re-apply when the participant list later mutates). The // (so we don't re-apply when the participant list later mutates). The
// grace deadline gives Teams enough time to publish all initial sources // grace deadline gives Teams enough time to publish all initial sources
// after engine start before we attempt the apply applying before // after engine start before we attempt the apply — applying before
// everyone's visible would partially-restore the routing and silently // everyone's visible would partially-restore the routing and silently
// drop assignments for late-appearing participants. // drop assignments for late-appearing participants.
private string? _pendingPresetName; private string? _pendingPresetName;
@ -41,7 +41,7 @@ public sealed partial class MainViewModel
/// <summary> /// <summary>
/// Reads the operator's auto-apply preference + last-applied preset name /// Reads the operator's auto-apply preference + last-applied preset name
/// from disk and seeds the pending-preset state. Called by InitializeAsync /// from disk and seeds the pending-preset state. Called by InitializeAsync
/// during engine startup. Failures are swallowed a preset read fault /// during engine startup. Failures are swallowed — a preset read fault
/// should never block the engine from coming up. /// should never block the engine from coming up.
/// </summary> /// </summary>
private void LoadPendingPresetFromPreferences() private void LoadPendingPresetFromPreferences()
@ -53,7 +53,7 @@ public sealed partial class MainViewModel
{ {
_pendingPresetName = pref.LastAppliedName; _pendingPresetName = pref.LastAppliedName;
// 30s grace window is generous: Teams typically advertises all // 30s grace window is generous: Teams typically advertises all
// existing participants within 510s of NDI discovery starting. // existing participants within 5–10s of NDI discovery starting.
// After this deadline we apply with whoever is visible. // After this deadline we apply with whoever is visible.
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30); _pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
} }
@ -64,7 +64,7 @@ public sealed partial class MainViewModel
/// <summary> /// <summary>
/// Attempts to apply <c>_pendingPresetName</c> if either every preset /// Attempts to apply <c>_pendingPresetName</c> if either every preset
/// assignment matches a live participant, or the grace deadline has /// assignment matches a live participant, or the grace deadline has
/// passed. Idempotent repeat calls without state change are no-ops; /// passed. Idempotent — repeat calls without state change are no-ops;
/// once we fire we flag <c>_pendingPresetApplied</c> so subsequent /// once we fire we flag <c>_pendingPresetApplied</c> so subsequent
/// participant churn doesn't trigger a second apply. Failures (missing /// participant churn doesn't trigger a second apply. Failures (missing
/// preset on disk, preset that no longer matches anyone) are swallowed: /// preset on disk, preset that no longer matches anyone) are swallowed:
@ -102,7 +102,7 @@ public sealed partial class MainViewModel
var result = await PresetApplier.ApplyAsync( var result = await PresetApplier.ApplyAsync(
captured, snapshot, _controller, _dispatcher); captured, snapshot, _controller, _dispatcher);
await _dispatcher.InvokeAsync(() => await _dispatcher.InvokeAsync(() =>
Toast.Show($"Auto-applied preset \"{captured.Name}\" {result.Matched} participant(s)")); Toast.Show($"Auto-applied preset \"{captured.Name}\" — {result.Matched} participant(s)"));
}); });
} }
} }

View file

@ -1,9 +1,9 @@
using System.Windows.Threading; using System.Windows.Threading;
using TeamsISO.App.Services; using DragonISO.App.Services;
namespace TeamsISO.App.ViewModels; namespace DragonISO.App.ViewModels;
// Teams launch / in-call / join-by-URL command helpers split out of // Teams launch / in-call / join-by-URL command helpers — split out of
// MainViewModel.cs so the body methods don't live alongside the // MainViewModel.cs so the body methods don't live alongside the
// constructor wiring + reactive subscriptions. The four command // constructor wiring + reactive subscriptions. The four command
// PROPERTIES are declared back in MainViewModel.cs (public API surface); // PROPERTIES are declared back in MainViewModel.cs (public API surface);
@ -28,7 +28,7 @@ public sealed partial class MainViewModel
Toast.Warn("Teams isn't running."); Toast.Warn("Teams isn't running.");
break; break;
case TeamsControlBridge.InvokeResult.ControlNotFound: case TeamsControlBridge.InvokeResult.ControlNotFound:
Toast.Warn($"{label} control not found are you in a call?"); Toast.Warn($"{label} control not found — are you in a call?");
break; break;
case TeamsControlBridge.InvokeResult.InvokeFailed: case TeamsControlBridge.InvokeResult.InvokeFailed:
Toast.Warn($"{label} button found but disabled."); Toast.Warn($"{label} button found but disabled.");
@ -48,7 +48,7 @@ public sealed partial class MainViewModel
if (string.IsNullOrEmpty(url)) return; if (string.IsNullOrEmpty(url)) return;
if (TeamsLauncher.TryJoinMeeting(url, out var error)) if (TeamsLauncher.TryJoinMeeting(url, out var error))
{ {
Toast.Show("Joining Teams meeting"); Toast.Show("Joining Teams meeting…");
JoinMeetingUrl = string.Empty; JoinMeetingUrl = string.Empty;
if (Settings.AutoHideTeamsWindows) if (Settings.AutoHideTeamsWindows)
_ = TeamsLauncher.AutoHideAfterLaunchAsync(); _ = TeamsLauncher.AutoHideAfterLaunchAsync();
@ -79,13 +79,13 @@ public sealed partial class MainViewModel
if (idx > 0) { t = t.Substring(0, idx).Trim(); break; } if (idx > 0) { t = t.Substring(0, idx).Trim(); break; }
} }
if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty; if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty;
if (t.Length > 50) t = t.Substring(0, 47) + ""; if (t.Length > 50) t = t.Substring(0, 47) + "…";
return t; return t;
} }
/// <summary> /// <summary>
/// Meeting-state probe runs on every 1Hz stats tick. We fire the UIA /// Meeting-state probe — runs on every 1Hz stats tick. We fire the UIA
/// traversal on a worker thread because it can take 50200ms in a busy /// 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 /// call; the result is marshalled back to the dispatcher to update the
/// view-model properties. One-tick latency on the displayed state is /// view-model properties. One-tick latency on the displayed state is
/// preferable to a UI hiccup. /// preferable to a UI hiccup.
@ -116,7 +116,7 @@ public sealed partial class MainViewModel
{ {
IsTeamsInCall = inCall; IsTeamsInCall = inCall;
TeamsMeetingState = inCall TeamsMeetingState = inCall
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}") ? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
: "READY"; : "READY";
IsLocalMuted = inCall && (snap.IsMuted ?? false); IsLocalMuted = inCall && (snap.IsMuted ?? false);
IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false); IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false);
@ -125,6 +125,6 @@ public sealed partial class MainViewModel
catch { /* UIA flakiness shouldn't crash the stats tick */ } catch { /* UIA flakiness shouldn't crash the stats tick */ }
}); });
} }
catch { /* defensive probe failures must never break the tick */ } catch { /* defensive — probe failures must never break the tick */ }
} }
} }

View file

@ -1,14 +1,14 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel; using System.ComponentModel;
using System.Reactive.Concurrency; using System.Reactive.Concurrency;
using System.Reactive.Linq; using System.Reactive.Linq;
using System.Windows.Data; using System.Windows.Data;
using System.Windows.Threading; using System.Windows.Threading;
using TeamsISO.App.Services; using DragonISO.App.Services;
using TeamsISO.Engine.Controller; using DragonISO.Engine.Controller;
using TeamsISO.Engine.Domain; using DragonISO.Engine.Domain;
namespace TeamsISO.App.ViewModels; namespace DragonISO.App.ViewModels;
/// <summary> /// <summary>
/// Top-level view model for the main window. Owns the live collection of <see cref="ParticipantViewModel"/>, /// Top-level view model for the main window. Owns the live collection of <see cref="ParticipantViewModel"/>,
@ -16,10 +16,10 @@ namespace TeamsISO.App.ViewModels;
/// and marshals updates onto the UI dispatcher. /// and marshals updates onto the UI dispatcher.
/// ///
/// Split across partial files by responsibility: /// Split across partial files by responsibility:
/// • <c>MainViewModel.cs</c> — fields, properties, constructor (wires commands), OnStatsTick, Dispose /// • <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.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.PresetCommands.cs</c> — auto-apply-last-preset path
/// • <c>MainViewModel.BulkCommands.cs</c> — Stop all / Enable all / Snapshot all /// • <c>MainViewModel.BulkCommands.cs</c> — Stop all / Enable all / Snapshot all
/// </summary> /// </summary>
public sealed partial class MainViewModel : ObservableObject, IDisposable public sealed partial class MainViewModel : ObservableObject, IDisposable
{ {
@ -29,11 +29,11 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
private readonly IDisposable _alertsSub; private readonly IDisposable _alertsSub;
private readonly DispatcherTimer _statsTimer; private readonly DispatcherTimer _statsTimer;
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new(); private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
private string _statusText = "Starting"; private string _statusText = "Starting…";
/// <summary> /// <summary>
/// Wall-clock at which <see cref="InitializeAsync"/> kicked the engine. Used to /// 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 /// gate the "Scanning for NDI sources…" placeholder so it shows for a few
/// seconds after launch even when ParticipantCount == 0 (the bleak /// seconds after launch even when ParticipantCount == 0 (the bleak
/// "no ndi sources yet" empty state was being shown immediately and /// "no ndi sources yet" empty state was being shown immediately and
/// operators assumed the app was broken before discovery had a chance to fire). /// operators assumed the app was broken before discovery had a chance to fire).
@ -41,7 +41,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
/// </summary> /// </summary>
private DateTimeOffset? _engineStartedAt; private DateTimeOffset? _engineStartedAt;
/// <summary>How long after engine start to keep showing "Scanning" instead of the empty-state copy.</summary> /// <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); private static readonly TimeSpan DiscoveryGracePeriod = TimeSpan.FromSeconds(8);
// _pendingPresetName / Deadline / Applied + the auto-apply path // _pendingPresetName / Deadline / Applied + the auto-apply path
@ -148,30 +148,30 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
/// <summary> /// <summary>
/// Force NDI discovery to rebuild its finder. Surfaced as a small "Refresh" pill /// Force NDI discovery to rebuild its finder. Surfaced as a small "Refresh" pill
/// next to the participants header useful right after Apply Transcoder Topology /// next to the participants header — useful right after Apply Transcoder Topology
/// or when Teams restarts mid-session and stale TTLs are masking new sources. /// or when Teams restarts mid-session and stale TTLs are masking new sources.
/// </summary> /// </summary>
public RelayCommand RefreshDiscoveryCommand { get; } public RelayCommand RefreshDiscoveryCommand { get; }
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
// Phase E.3 In-call controls. Each command drives a UIAutomation lookup // Phase E.3 — In-call controls. Each command drives a UIAutomation lookup
// against Teams' window tree and reports a toast on outcome. Best-effort: // against Teams' window tree and reports a toast on outcome. Best-effort:
// a control-not-found result toasts a hint rather than throwing, since // 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). // Teams isn't always in a call (the buttons only appear in-call).
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
public RelayCommand ToggleMuteCommand { get; } public RelayCommand ToggleMuteCommand { get; }
public RelayCommand ToggleCameraCommand { get; } public RelayCommand ToggleCameraCommand { get; }
public RelayCommand LeaveCallCommand { get; } public RelayCommand LeaveCallCommand { get; }
public RelayCommand OpenShareTrayCommand { get; } public RelayCommand OpenShareTrayCommand { get; }
// Recording-marker and roll-recording commands removed recording feature axed. // Recording-marker and roll-recording commands removed — recording feature axed.
/// <summary>F1 binding opens the help / cheat-sheet dialog.</summary> /// <summary>F1 binding — opens the help / cheat-sheet dialog.</summary>
public RelayCommand ShowHelpCommand { get; } public RelayCommand ShowHelpCommand { get; }
/// <summary> /// <summary>
/// Ctrl+T binding — cycles dark ↔ light theme via ThemeManager. /// Ctrl+T binding — cycles dark ↔ light theme via ThemeManager.
/// Persists the operator's choice through UIPreferences.Theme. /// Persists the operator's choice through UIPreferences.Theme.
/// The v2 header surfaces this as a click affordance too; the /// The v2 header surfaces this as a click affordance too; the
/// command exists once so both bindings reach the same path. /// command exists once so both bindings reach the same path.
@ -179,7 +179,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
public RelayCommand ToggleThemeCommand { get; } public RelayCommand ToggleThemeCommand { get; }
/// <summary> /// <summary>
/// Ctrl+K binding opens the v2 command palette. The actual window /// Ctrl+K binding — opens the v2 command palette. The actual window
/// open call lives in <see cref="MainWindow"/> (view-side concern); /// open call lives in <see cref="MainWindow"/> (view-side concern);
/// this command delegates through an Action callback the view sets /// this command delegates through an Action callback the view sets
/// after construction so the VM stays unaware of WPF Window types. /// after construction so the VM stays unaware of WPF Window types.
@ -189,7 +189,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
/// <summary> /// <summary>
/// Wire the view's palette-opening callback. Called by MainWindow's /// Wire the view's palette-opening callback. Called by MainWindow's
/// constructor right after DataContext is set. Idempotent second /// constructor right after DataContext is set. Idempotent — second
/// call replaces the first. /// call replaces the first.
/// </summary> /// </summary>
public void RegisterCommandPaletteOpener(Action openPalette) => public void RegisterCommandPaletteOpener(Action openPalette) =>
@ -198,7 +198,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
/// <summary>Opens the inline notes viewer for today's show-notes file.</summary> /// <summary>Opens the inline notes viewer for today's show-notes file.</summary>
public RelayCommand ShowNotesCommand { get; } public RelayCommand ShowNotesCommand { get; }
/// <summary>Join a Teams meeting from a pasted URL see <see cref="JoinMeetingUrl"/>.</summary> /// <summary>Join a Teams meeting from a pasted URL — see <see cref="JoinMeetingUrl"/>.</summary>
public RelayCommand JoinMeetingCommand { get; } public RelayCommand JoinMeetingCommand { get; }
/// <summary>Save a PNG snapshot of every enabled participant's current frame.</summary> /// <summary>Save a PNG snapshot of every enabled participant's current frame.</summary>
@ -236,7 +236,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
// recording feature was axed. // recording feature was axed.
/// <summary> /// <summary>
/// Total visible participants feeds the v2 transport strip's "PART N" /// Total visible participants — feeds the v2 transport strip's "PART N"
/// readout. Updated on every 1Hz stats tick alongside <see cref="LiveCount"/>. /// readout. Updated on every 1Hz stats tick alongside <see cref="LiveCount"/>.
/// </summary> /// </summary>
public int ParticipantCount public int ParticipantCount
@ -249,9 +249,9 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
/// <summary> /// <summary>
/// True for the first <see cref="DiscoveryGracePeriod"/> after engine start. /// True for the first <see cref="DiscoveryGracePeriod"/> after engine start.
/// The XAML uses this to swap the empty-state placeholder from the bleak /// 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 /// "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 /// as broken to operators who just launched into an active meeting) to a
/// neutral "Scanning for NDI sources" status while NDI Find resolves /// neutral "Scanning for NDI sources…" status while NDI Find resolves
/// mDNS responses. Always false once participants populate. /// mDNS responses. Always false once participants populate.
/// </summary> /// </summary>
public bool IsDiscovering public bool IsDiscovering
@ -262,7 +262,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
private bool _isDiscovering; private bool _isDiscovering;
/// <summary> /// <summary>
/// Currently-enabled (live) ISO count feeds the v2 transport strip's /// Currently-enabled (live) ISO count — feeds the v2 transport strip's
/// "LIVE N" readout. The number is cyan-tinted when non-zero to draw /// "LIVE N" readout. The number is cyan-tinted when non-zero to draw
/// the operator's eye to active state. /// the operator's eye to active state.
/// </summary> /// </summary>
@ -376,7 +376,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
// Subscribe directly (no ObserveOn) and marshal to the UI thread inside // Subscribe directly (no ObserveOn) and marshal to the UI thread inside
// the callback via Dispatcher.InvokeAsync. The previous ObserveOn( // the callback via Dispatcher.InvokeAsync. The previous ObserveOn(
// SynchronizationContextScheduler) path captured SynchronizationContext // SynchronizationContextScheduler) path captured SynchronizationContext
// .Current at subscribe time fragile in WPF startup ordering, where // .Current at subscribe time — fragile in WPF startup ordering, where
// the UI thread's SyncContext can be in a transitional state during // the UI thread's SyncContext can be in a transitional state during
// App.OnStartup and the captured context never pumps subsequent // App.OnStartup and the captured context never pumps subsequent
// OnNext calls. Direct subscribe + explicit dispatcher marshal is the // OnNext calls. Direct subscribe + explicit dispatcher marshal is the
@ -394,7 +394,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
AlertBanner.Current = alert; AlertBanner.Current = alert;
}); });
// 1 Hz stats poll pull live frame counters from each running pipeline and // 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 // 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. // fields on the engine side) and runs on the UI dispatcher so SetField is safe.
_statsTimer = new DispatcherTimer(DispatcherPriority.Background, _dispatcher) _statsTimer = new DispatcherTimer(DispatcherPriority.Background, _dispatcher)
@ -410,7 +410,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
RefreshDiscoveryCommand = new RelayCommand(() => RefreshDiscoveryCommand = new RelayCommand(() =>
{ {
_controller.RefreshDiscovery(); _controller.RefreshDiscovery();
Toast.Show("Refreshing NDI discovery"); Toast.Show("Refreshing NDI discovery…");
}); });
ToggleThemeCommand = new RelayCommand(() => ToggleThemeCommand = new RelayCommand(() =>
@ -424,7 +424,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
ShowHelpCommand = new RelayCommand(() => ShowHelpCommand = new RelayCommand(() =>
{ {
// Showing a Window from a VM violates strict MVVM, but TeamsISO doesn't // 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. // ship a navigation service and a HelpWindow is purely a UI concern.
// Owner is set so the dialog centers and inherits z-order. // Owner is set so the dialog centers and inherits z-order.
var help = new HelpWindow { Owner = System.Windows.Application.Current?.MainWindow }; var help = new HelpWindow { Owner = System.Windows.Application.Current?.MainWindow };
@ -479,10 +479,10 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
} }
// Body methods extracted to themed partial files: // Body methods extracted to themed partial files:
// MainViewModel.BulkCommands.cs EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll // MainViewModel.BulkCommands.cs — EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll
// MainViewModel.TeamsCommands.cs MakeTeamsCommand, JoinPastedMeeting, // MainViewModel.TeamsCommands.cs — MakeTeamsCommand, JoinPastedMeeting,
// ExtractMeetingTitle, PollTeamsMeetingState // ExtractMeetingTitle, PollTeamsMeetingState
// MainViewModel.PresetCommands.cs RequestApplyPresetOnStartup, // MainViewModel.PresetCommands.cs — RequestApplyPresetOnStartup,
// LoadPendingPresetFromPreferences, // LoadPendingPresetFromPreferences,
// TryAutoApplyPendingPreset // TryAutoApplyPendingPreset
@ -533,16 +533,16 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
// If sort mode is LoudestFirst, refresh the view so the new audio // If sort mode is LoudestFirst, refresh the view so the new audio
// peaks re-evaluate the sort. Skipped for the other sort modes // peaks re-evaluate the sort. Skipped for the other sort modes
// since their keys (name, online state) don't change every tick // since their keys (name, online state) don't change every tick —
// no need to pay the Refresh cost. // no need to pay the Refresh cost.
if (_currentSortMode == Services.UIPreferences.SortMode.LoudestFirst) if (_currentSortMode == Services.UIPreferences.SortMode.LoudestFirst)
{ {
try { ParticipantsView.Refresh(); } try { ParticipantsView.Refresh(); }
catch { /* defensive Refresh occasionally throws on collection mutations */ } catch { /* defensive — Refresh occasionally throws on collection mutations */ }
} }
// Update footer badges. Recording count is "ISOs that have a recorder // Update footer badges. Recording count is "ISOs that have a recorder
// attached" _controller.RecordingEnabled tells us the global toggle, // attached" — _controller.RecordingEnabled tells us the global toggle,
// but the actual recorder count = number of running pipelines while // but the actual recorder count = number of running pipelines while
// that toggle was on (transient enables can mean fewer recorders than // that toggle was on (transient enables can mean fewer recorders than
// running pipelines). Approximate by ANDing global toggle + running // running pipelines). Approximate by ANDing global toggle + running
@ -553,12 +553,12 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
// of the recording surface. // of the recording surface.
// Expose counts as VM properties for the v2 transport-strip binding. // Expose counts as VM properties for the v2 transport-strip binding.
// The strip's "PART 4 · LIVE 2" reads these — pushing them on the // The strip's "PART 4 · LIVE 2" reads these — pushing them on the
// 1Hz tick keeps the cost off the per-frame UI path. // 1Hz tick keeps the cost off the per-frame UI path.
ParticipantCount = totalParticipants; ParticipantCount = totalParticipants;
LiveCount = enabledCount; LiveCount = enabledCount;
// IsDiscovering gates the "Scanning for NDI sources" placeholder. // IsDiscovering gates the "Scanning for NDI sources…" placeholder.
// True for DiscoveryGracePeriod after engine start AS LONG AS we // True for DiscoveryGracePeriod after engine start AS LONG AS we
// haven't seen any participants yet; once anything arrives we drop // haven't seen any participants yet; once anything arrives we drop
// out of the discovering state immediately (back to the OK path). // out of the discovering state immediately (back to the OK path).
@ -571,7 +571,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
IsDiscovering = false; IsDiscovering = false;
} }
// Session timer start on first ISO going live, reset when none are // Session timer — start on first ISO going live, reset when none are
// live anymore. Subsequent enables after a full-zero gap restart the // live anymore. Subsequent enables after a full-zero gap restart the
// timer rather than resuming, which is the operator's mental model: // timer rather than resuming, which is the operator's mental model:
// "the show started when the first feed went live." // "the show started when the first feed went live."
@ -591,13 +591,13 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
IsSessionActive = false; IsSessionActive = false;
} }
// Dynamic status text replaces the static "Engine running at X fps" // Dynamic status text — replaces the static "Engine running at X fps"
// once ISOs are live. The framerate target is still implicit (the user // once ISOs are live. The framerate target is still implicit (the user
// set it in OUTPUT settings; surfacing it constantly steals footer // set it in OUTPUT settings; surfacing it constantly steals footer
// real estate from more-actionable info). // real estate from more-actionable info).
if (totalParticipants == 0) if (totalParticipants == 0)
{ {
StatusText = "Discovering NDI sources"; StatusText = "Discovering NDI sources…";
} }
else if (enabledCount == 0) else if (enabledCount == 0)
{ {
@ -610,18 +610,18 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
StatusText = $"{enabledCount}/{totalParticipants} ISOs live"; StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
} }
// Teams meeting state UIA traversal at 1Hz; off-thread so a slow // Teams meeting state — UIA traversal at 1Hz; off-thread so a slow
// UIA call doesn't stall the UI tick. Implementation in // UIA call doesn't stall the UI tick. Implementation in
// MainViewModel.TeamsCommands.cs. // MainViewModel.TeamsCommands.cs.
PollTeamsMeetingState(); PollTeamsMeetingState();
// Control-surface state peek at App's owned services. // Control-surface state — peek at App's owned services.
var app = System.Windows.Application.Current as App; var app = System.Windows.Application.Current as App;
var rest = app?.ControlSurface?.IsRunning ?? false; var rest = app?.ControlSurface?.IsRunning ?? false;
var osc = app?.OscBridge?.IsRunning ?? false; var osc = app?.OscBridge?.IsRunning ?? false;
IsControlSurfaceRunning = rest || osc; IsControlSurfaceRunning = rest || osc;
// When LAN-reachable mode is on, the footer text shows the routable // When LAN-reachable mode is on, the footer text shows the routable
// URL instead of just the port operators setting up a thin client // 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 // shouldn't have to open Settings to find what to type into a
// browser. We trust the Settings VM's ControlSurfaceLanReachable // browser. We trust the Settings VM's ControlSurfaceLanReachable
// boolean since that's where the toggle is persisted. // boolean since that's where the toggle is persisted.
@ -640,14 +640,14 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
public async Task InitializeAsync(CancellationToken cancellationToken) public async Task InitializeAsync(CancellationToken cancellationToken)
{ {
StatusText = "Discovering NDI sources"; StatusText = "Discovering NDI sources…";
_engineStartedAt = DateTimeOffset.UtcNow; _engineStartedAt = DateTimeOffset.UtcNow;
IsDiscovering = true; IsDiscovering = true;
await _controller.StartAsync(cancellationToken); await _controller.StartAsync(cancellationToken);
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target."; StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
// Auto-apply last preset bookkeeping. We don't apply here // Auto-apply last preset bookkeeping. We don't apply here —
// participants haven't been discovered yet instead we record // participants haven't been discovered yet — instead we record
// the intent and let OnParticipantsChanged trigger the apply // the intent and let OnParticipantsChanged trigger the apply
// once the meeting has populated. Implementation in // once the meeting has populated. Implementation in
// MainViewModel.PresetCommands.cs. // MainViewModel.PresetCommands.cs.
@ -662,7 +662,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
foreach (var p in incoming) foreach (var p in incoming)
{ {
// The new Teams client emits a "(Local)" pseudo-participant for the user's // 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 // own preview — operators rarely want it as a routable ISO. Suppress when
// HideLocalSelf is on (default). // HideLocalSelf is on (default).
if (hideLocal && IsLocalSelf(p)) continue; if (hideLocal && IsLocalSelf(p)) continue;
@ -672,9 +672,9 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
var wasOnline = vm.IsOnline; var wasOnline = vm.IsOnline;
vm.Update(p); vm.Update(p);
// Departure: source went from non-null to null. Always toast so the // Departure: source went from non-null to null. Always toast so the
// operator notices, even when AutoDisableOnDeparture is off the // operator notices, even when AutoDisableOnDeparture is off — the
// ISO might still be "running" but emitting a slate frame, which // ISO might still be "running" but emitting a slate frame, which
// looks fine in TeamsISO's UI but is broken downstream. // looks fine in Dragon-ISO's UI but is broken downstream.
if (wasOnline && !vm.IsOnline && vm.IsEnabled) if (wasOnline && !vm.IsOnline && vm.IsEnabled)
{ {
if (autoDisable) if (autoDisable)
@ -695,7 +695,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
{ {
// ISO stays running on a slate frame; warn the operator so // ISO stays running on a slate frame; warn the operator so
// they can decide whether to disable manually. // they can decide whether to disable manually.
Toast.Warn($"{vm.DisplayName} disconnected ISO still running on slate"); Toast.Warn($"{vm.DisplayName} disconnected — ISO still running on slate");
} }
} }
} }
@ -720,7 +720,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
// Auto-apply-last-preset, second half: once participants populate, kick the // Auto-apply-last-preset, second half: once participants populate, kick the
// apply. We fire it under either of two conditions: (a) every display name // 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 // referenced by the preset is present (best case — the meeting is fully
// populated, no skipped assignments), or (b) the grace deadline has passed // populated, no skipped assignments), or (b) the grace deadline has passed
// (give up waiting and apply with whoever's online). // (give up waiting and apply with whoever's online).
if (_pendingPresetName is not null && !_pendingPresetApplied) if (_pendingPresetName is not null && !_pendingPresetApplied)

View file

@ -1,7 +1,7 @@
using System.ComponentModel; using System.ComponentModel;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
namespace TeamsISO.App.ViewModels; namespace DragonISO.App.ViewModels;
/// <summary> /// <summary>
/// Minimal MVVM base class implementing <see cref="INotifyPropertyChanged"/>. /// Minimal MVVM base class implementing <see cref="INotifyPropertyChanged"/>.

View file

@ -1,12 +1,12 @@
using System.Windows; using System.Windows;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using TeamsISO.Engine.Controller; using DragonISO.Engine.Controller;
using TeamsISO.Engine.Domain; using DragonISO.Engine.Domain;
using TeamsISO.Engine.Pipeline; using DragonISO.Engine.Pipeline;
using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats; using IsoHealthStats = DragonISO.Engine.Domain.IsoHealthStats;
namespace TeamsISO.App.ViewModels; namespace DragonISO.App.ViewModels;
/// <summary> /// <summary>
/// Per-row view model for a participant in the participant list. /// Per-row view model for a participant in the participant list.
@ -26,7 +26,7 @@ public sealed class ParticipantViewModel : ObservableObject
private const int ThumbnailHeight = 90; private const int ThumbnailHeight = 90;
/// <summary> /// <summary>
/// Live preview of the most recent processed frame, scaled to <see cref="ThumbnailWidth"/>× /// Live preview of the most recent processed frame, scaled to <see cref="ThumbnailWidth"/>×
/// <see cref="ThumbnailHeight"/>. Updated by <see cref="UpdateThumbnail"/> on the UI /// <see cref="ThumbnailHeight"/>. Updated by <see cref="UpdateThumbnail"/> on the UI
/// thread, called from MainViewModel's stats tick. Null until the first frame arrives. /// thread, called from MainViewModel's stats tick. Null until the first frame arrives.
/// </summary> /// </summary>
@ -46,7 +46,7 @@ public sealed class ParticipantViewModel : ObservableObject
/// <summary> /// <summary>
/// True when this participant is currently the loudest among the live /// True when this participant is currently the loudest among the live
/// set set by MainViewModel at the 1Hz stats tick. Bound to a cyan /// 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 /// border accent on the DataGrid row so operators can spot who's
/// speaking without watching every VU bar individually. /// speaking without watching every VU bar individually.
/// </summary> /// </summary>
@ -81,7 +81,7 @@ public sealed class ParticipantViewModel : ObservableObject
OpenPreviewCommand = new RelayCommand(() => OpenPreviewCommand = new RelayCommand(() =>
{ {
// Non-modal operator can open multiple previews at once. // Non-modal — operator can open multiple previews at once.
// Owner is the main window so the preview centers nicely and // Owner is the main window so the preview centers nicely and
// closes cleanly when the host exits. // closes cleanly when the host exits.
var preview = new PreviewWindow(_controller, Id, DisplayName); var preview = new PreviewWindow(_controller, Id, DisplayName);
@ -96,8 +96,8 @@ public sealed class ParticipantViewModel : ObservableObject
/// <summary> /// <summary>
/// Encode the most recent <see cref="IsoPipeline.LatestProcessedFrame"/> /// Encode the most recent <see cref="IsoPipeline.LatestProcessedFrame"/>
/// as a PNG and write it to <c>%USERPROFILE%\Pictures\TeamsISO\</c>. Used /// as a PNG and write it to <c>%USERPROFILE%\Pictures\Dragon-ISO\</c>. Used
/// by the participants' context menu for grabbing a stillframe useful /// by the participants' context menu for grabbing a stillframe — useful
/// for highlight reels, social posts, bug reports. Best-effort: a no-op /// for highlight reels, social posts, bug reports. Best-effort: a no-op
/// + warn-toast if no frame is currently available (pipeline just spun /// + warn-toast if no frame is currently available (pipeline just spun
/// up, or recording isn't enabled). Filename includes participant name /// up, or recording isn't enabled). Filename includes participant name
@ -110,13 +110,13 @@ public sealed class ParticipantViewModel : ObservableObject
var frame = _controller.GetLatestProcessedFrame(Id); var frame = _controller.GetLatestProcessedFrame(Id);
if (frame is null || frame.Pixels.IsEmpty) if (frame is null || frame.Pixels.IsEmpty)
{ {
_toast?.Warn("No frame available yet try again in a few seconds"); _toast?.Warn("No frame available yet — try again in a few seconds");
return; return;
} }
var dir = System.IO.Path.Combine( var dir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
"TeamsISO"); "Dragon-ISO");
System.IO.Directory.CreateDirectory(dir); System.IO.Directory.CreateDirectory(dir);
var safeName = string.Join("_", DisplayName.Split(System.IO.Path.GetInvalidFileNameChars())); var safeName = string.Join("_", DisplayName.Split(System.IO.Path.GetInvalidFileNameChars()));
@ -193,7 +193,7 @@ public sealed class ParticipantViewModel : ObservableObject
{ {
if (frame is null || frame.Pixels.IsEmpty) if (frame is null || frame.Pixels.IsEmpty)
{ {
// Don't clear a previously-rendered thumbnail on transient null reads // Don't clear a previously-rendered thumbnail on transient null reads —
// a brief gap between frames shouldn't visibly blank the preview. The // a brief gap between frames shouldn't visibly blank the preview. The
// Thumbnail is only set to null when the pipeline genuinely stops, which // Thumbnail is only set to null when the pipeline genuinely stops, which
// we observe by IsEnabled flipping false elsewhere. // we observe by IsEnabled flipping false elsewhere.
@ -202,7 +202,7 @@ public sealed class ParticipantViewModel : ObservableObject
// Defense in depth: if the engine ever hands us a frame whose pixel buffer // 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 // doesn't match the declared dimensions (would imply an engine bug), don't
// crash the UI on IndexOutOfRangeException silently skip this update and // crash the UI on IndexOutOfRangeException — silently skip this update and
// wait for a sane frame. // wait for a sane frame.
var expectedBytes = frame.Width * frame.Height * 4; var expectedBytes = frame.Width * frame.Height * 4;
if (frame.Width <= 0 || frame.Height <= 0 || frame.Pixels.Length < expectedBytes) if (frame.Width <= 0 || frame.Height <= 0 || frame.Pixels.Length < expectedBytes)
@ -239,14 +239,14 @@ public sealed class ParticipantViewModel : ObservableObject
} }
/// <summary> /// <summary>
/// Inline nearest-neighbor scaler copies one downsampled BGRA frame into the /// Inline nearest-neighbor scaler — copies one downsampled BGRA frame into the
/// WriteableBitmap's back buffer. We don't reuse <see cref="ManagedNearestNeighborFrameScaler"/> /// WriteableBitmap's back buffer. We don't reuse <see cref="ManagedNearestNeighborFrameScaler"/>
/// because it allocates a managed buffer per scale; here we want to write /// 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. /// directly into the WriteableBitmap's pinned native memory to avoid a copy.
/// ///
/// The arithmetic is the same: for each destination pixel, compute the source /// 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 /// pixel via integer ratio and copy 4 bytes. At 1Hz × 10 thumbnails that's
/// ~144,000 pixel reads per second negligible CPU. /// ~144,000 pixel reads per second — negligible CPU.
/// </summary> /// </summary>
private static void ScaleNearestNeighborBgra( private static void ScaleNearestNeighborBgra(
ReadOnlySpan<byte> src, int srcW, int srcH, ReadOnlySpan<byte> src, int srcW, int srcH,
@ -254,7 +254,7 @@ public sealed class ParticipantViewModel : ObservableObject
{ {
// Pre-compute the x-ratio table once per call so the inner loop is just two // 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 // multiplies and a memcpy. Java-style fixed-point would be faster but for
// 160×90 the overhead is irrelevant. // 160×90 the overhead is irrelevant.
Span<int> srcXFor = stackalloc int[dstW]; Span<int> srcXFor = stackalloc int[dstW];
for (var x = 0; x < dstW; x++) srcXFor[x] = x * srcW / dstW; for (var x = 0; x < dstW; x++) srcXFor[x] = x * srcW / dstW;
@ -309,8 +309,8 @@ public sealed class ParticipantViewModel : ObservableObject
private long _framesIn; private long _framesIn;
private long _framesOut; private long _framesOut;
private long _framesDropped; private long _framesDropped;
private string _incomingResolution = ""; private string _incomingResolution = "—";
private string _incomingFps = ""; private string _incomingFps = "—";
/// <summary>Number of frames the receiver has captured so far.</summary> /// <summary>Number of frames the receiver has captured so far.</summary>
public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); } public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }
@ -321,7 +321,7 @@ public sealed class ParticipantViewModel : ObservableObject
/// <summary>Frames dropped by the closest-frame strategy when the receiver outpaces the processor.</summary> /// <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); } public long FramesDropped { get => _framesDropped; set => SetField(ref _framesDropped, value); }
private string _stateLabel = ""; private string _stateLabel = "—";
private string _stateColor = "Wd.Text.Tertiary"; private string _stateColor = "Wd.Text.Tertiary";
private double _peakAudioLevel; private double _peakAudioLevel;
private double _displayedAudioLevel; private double _displayedAudioLevel;
@ -347,7 +347,7 @@ public sealed class ParticipantViewModel : ObservableObject
/// </summary> /// </summary>
public double AudioLevelWidthPercent => Math.Clamp(_displayedAudioLevel * 100, 0, 100); public double AudioLevelWidthPercent => Math.Clamp(_displayedAudioLevel * 100, 0, 100);
/// <summary>Human-readable pipeline state ("Receiving", "Error", "").</summary> /// <summary>Human-readable pipeline state ("Receiving", "Error", "—").</summary>
public string StateLabel { get => _stateLabel; set => SetField(ref _stateLabel, value); } public string StateLabel { get => _stateLabel; set => SetField(ref _stateLabel, value); }
/// <summary>Resource key of the brush to color the state pill with.</summary> /// <summary>Resource key of the brush to color the state pill with.</summary>
@ -390,19 +390,19 @@ public sealed class ParticipantViewModel : ObservableObject
FramesOut = stats.FramesOut; FramesOut = stats.FramesOut;
FramesDropped = stats.FramesDropped; FramesDropped = stats.FramesDropped;
IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0 IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0
? $"{stats.IncomingWidth}×{stats.IncomingHeight}" ? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
: ""; : "—";
IncomingFps = stats.IncomingFps > 0 IncomingFps = stats.IncomingFps > 0
? $"{stats.IncomingFps:0.0} fps" ? $"{stats.IncomingFps:0.0} fps"
: ""; : "—";
(StateLabel, StateColor) = stats.State switch (StateLabel, StateColor) = stats.State switch
{ {
IsoState.Receiving => ("LIVE", "Wd.Status.Live"), IsoState.Receiving => ("LIVE", "Wd.Status.Live"),
IsoState.Sending => ("LIVE", "Wd.Status.Live"), IsoState.Sending => ("LIVE", "Wd.Status.Live"),
IsoState.NoSignal => ("NO SIGNAL", "Wd.Status.Warn"), IsoState.NoSignal => ("NO SIGNAL", "Wd.Status.Warn"),
IsoState.Error => ("ERROR", "Wd.Status.Error"), IsoState.Error => ("ERROR", "Wd.Status.Error"),
IsoState.Idle => (IsEnabled ? "STARTING" : "", "Wd.Text.Tertiary"), IsoState.Idle => (IsEnabled ? "STARTING" : "—", "Wd.Text.Tertiary"),
_ => ("", "Wd.Text.Tertiary"), _ => ("—", "Wd.Text.Tertiary"),
}; };
} }
@ -430,10 +430,10 @@ public sealed class ParticipantViewModel : ObservableObject
} }
/// <summary> /// <summary>
/// The NDI source name TeamsISO will broadcast this participant as. Prefers /// The NDI source name Dragon-ISO will broadcast this participant as. Prefers
/// the operator's <see cref="CustomName"/> when set; otherwise renders the /// the operator's <see cref="CustomName"/> when set; otherwise renders the
/// active template (default <c>"{name}"</c>, falling back to /// active template (default <c>"{name}"</c>, falling back to
/// <c>TEAMSISO_{guid}</c> when the participant has no display name yet). /// <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 /// Bound by the v2 participants table's mono "output name" column for
/// read-only display contexts. /// read-only display contexts.
/// </summary> /// </summary>
@ -450,11 +450,11 @@ public sealed class ParticipantViewModel : ObservableObject
/// resolve to whatever name will actually be broadcast (<see cref="OutputName"/>); /// resolve to whatever name will actually be broadcast (<see cref="OutputName"/>);
/// writes set <see cref="CustomName"/> with a couple of UX niceties: /// writes set <see cref="CustomName"/> with a couple of UX niceties:
/// ///
/// Clearing the field (empty / whitespace) reverts to the template /// • Clearing the field (empty / whitespace) reverts to the template
/// default the user doesn't have to remember the template syntax to /// default — the user doesn't have to remember the template syntax to
/// "undo" a customization. /// "undo" a customization.
/// ///
/// Typing a value that exactly matches the resolved default is treated /// • Typing a value that exactly matches the resolved default is treated
/// as a no-op (CustomName stays empty), so the participant continues /// as a no-op (CustomName stays empty), so the participant continues
/// to follow the template when their display name changes upstream. /// to follow the template when their display name changes upstream.
/// Without this, typing the auto-suggested value would silently /// Without this, typing the auto-suggested value would silently
@ -503,7 +503,7 @@ public sealed class ParticipantViewModel : ObservableObject
OnPropertyChanged(nameof(SourceFullName)); OnPropertyChanged(nameof(SourceFullName));
OnPropertyChanged(nameof(IsOnline)); OnPropertyChanged(nameof(IsOnline));
// OutputName/EditableOutputName both derive from _participant.DisplayName // OutputName/EditableOutputName both derive from _participant.DisplayName
// when no per-participant CustomName is set re-notify so the Output // when no per-participant CustomName is set — re-notify so the Output
// column tracks upstream Teams name changes for participants who // column tracks upstream Teams name changes for participants who
// haven't been manually renamed. // haven't been manually renamed.
OnPropertyChanged(nameof(OutputName)); OnPropertyChanged(nameof(OutputName));
@ -525,7 +525,7 @@ public sealed class ParticipantViewModel : ObservableObject
// Resolve the output name: explicit per-participant CustomName // Resolve the output name: explicit per-participant CustomName
// wins; otherwise expand the operator's template (default is // wins; otherwise expand the operator's template (default is
// "{name}" since 0.9.0-rc19, with an empty-name fallback to // "{name}" since 0.9.0-rc19, with an empty-name fallback to
// TEAMSISO_{guid} inside Render). Passing the rendered name // Dragon-ISO_{guid} inside Render). Passing the rendered name
// to EnableIsoAsync as customName overrides the engine's // to EnableIsoAsync as customName overrides the engine's
// DefaultOutputName path. // DefaultOutputName path.
var resolvedName = string.IsNullOrWhiteSpace(_customName) var resolvedName = string.IsNullOrWhiteSpace(_customName)
@ -555,14 +555,14 @@ public sealed class ParticipantViewModel : ObservableObject
// warning toast rather than letting it escape into the dispatcher's // warning toast rather than letting it escape into the dispatcher's
// unhandled-exception channel (which fires a fatal crash dialog). // unhandled-exception channel (which fires a fatal crash dialog).
// //
// Leave IsEnabled at its current value the engine refused the state // Leave IsEnabled at its current value — the engine refused the state
// change, so the VM should reflect the actual engine state. // change, so the VM should reflect the actual engine state.
_toast?.Warn($"{DisplayName} just left the meeting"); _toast?.Warn($"{DisplayName} just left the meeting");
} }
catch (Exception ex) catch (Exception ex)
{ {
// Defensive catch-all for any other engine-side failure (port bind // Defensive catch-all for any other engine-side failure (port bind
// race, pipeline factory throw, etc.). Same reasoning as above // race, pipeline factory throw, etc.). Same reasoning as above —
// an exception from an operator click should never tear down the // an exception from an operator click should never tear down the
// dispatcher. // dispatcher.
_toast?.Warn($"Couldn't toggle ISO for {DisplayName}: {ex.Message}"); _toast?.Warn($"Couldn't toggle ISO for {DisplayName}: {ex.Message}");

View file

@ -1,6 +1,6 @@
using System.Windows.Input; using System.Windows.Input;
namespace TeamsISO.App.ViewModels; namespace DragonISO.App.ViewModels;
/// <summary> /// <summary>
/// Synchronous command that delegates execution to an <see cref="Action"/>. /// Synchronous command that delegates execution to an <see cref="Action"/>.
@ -25,7 +25,7 @@ public sealed class RelayCommand : ICommand
/// <summary> /// <summary>
/// Synchronous command that accepts a typed parameter. Used by hotkeys /// Synchronous command that accepts a typed parameter. Used by hotkeys
/// that need to pass an index (e.g. NumPad1..NumPad9 1..9). The /// that need to pass an index (e.g. NumPad1..NumPad9 → 1..9). The
/// parameter is converted from object via Convert.ChangeType so XAML /// parameter is converted from object via Convert.ChangeType so XAML
/// CommandParameter="1" works for int T. /// CommandParameter="1" works for int T.
/// </summary> /// </summary>

View file

@ -1,7 +1,7 @@
using System.Windows.Input; using System.Windows.Input;
using System.Windows.Threading; using System.Windows.Threading;
namespace TeamsISO.App.ViewModels; namespace DragonISO.App.ViewModels;
/// <summary> /// <summary>
/// Lightweight transient-notification view-model. The main view holds a single /// Lightweight transient-notification view-model. The main view holds a single

View file

@ -1,6 +1,6 @@
using TeamsISO.App.Services; using DragonISO.App.Services;
namespace TeamsISO.App.ViewModels; namespace DragonISO.App.ViewModels;
/// <summary> /// <summary>
/// Persistent banner shown when the launch-time update check finds a newer /// Persistent banner shown when the launch-time update check finds a newer
@ -35,8 +35,8 @@ public sealed class UpdateBannerViewModel : ObservableObject
private set => SetField(ref _currentVersion, value); private set => SetField(ref _currentVersion, value);
} }
/// <summary>Friendly composite "1.0.0 1.1.0" string for the banner.</summary> /// <summary>Friendly composite "1.0.0 → 1.1.0" string for the banner.</summary>
public string Message => $"Update available — v{_currentVersion} → {_latestVersion}"; public string Message => $"Update available — v{_currentVersion} → {_latestVersion}";
public RelayCommand OpenReleasePageCommand { get; } public RelayCommand OpenReleasePageCommand { get; }
public RelayCommand DismissCommand { get; } public RelayCommand DismissCommand { get; }

View file

@ -1,8 +1,8 @@
<Window x:Class="TeamsISO.App.Views.CommandPaletteWindow" <Window x:Class="DragonISO.App.Views.CommandPaletteWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels" xmlns:vm="clr-namespace:DragonISO.App.ViewModels"
xmlns:conv="clr-namespace:TeamsISO.App.Converters" xmlns:conv="clr-namespace:DragonISO.App.Converters"
Width="560" Height="360" Width="560" Height="360"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
WindowStyle="None" WindowStyle="None"

View file

@ -1,9 +1,9 @@
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Input; using System.Windows.Input;
using TeamsISO.App.ViewModels; using DragonISO.App.ViewModels;
namespace TeamsISO.App.Views; namespace DragonISO.App.Views;
/// <summary> /// <summary>
/// The Ctrl+K command palette window. Centered over its <see cref="Window.Owner"/>, /// The Ctrl+K command palette window. Centered over its <see cref="Window.Owner"/>,
@ -12,9 +12,9 @@ namespace TeamsISO.App.Views;
/// Keyboard contract: /// Keyboard contract:
/// <list type="bullet"> /// <list type="bullet">
/// <item>Type to filter (autofocus on the TextBox at open time)</item> /// <item>Type to filter (autofocus on the TextBox at open time)</item>
/// <item>↑ / ↓ — move the selection</item> /// <item>↑ / ↓ — move the selection</item>
/// <item>Enter invoke the highlighted command, then close</item> /// <item>Enter — invoke the highlighted command, then close</item>
/// <item>Esc close without invoking</item> /// <item>Esc — close without invoking</item>
/// </list> /// </list>
/// </summary> /// </summary>
public partial class CommandPaletteWindow : Window public partial class CommandPaletteWindow : Window
@ -23,7 +23,7 @@ public partial class CommandPaletteWindow : Window
{ {
InitializeComponent(); InitializeComponent();
// Autofocus the filter input on open so the operator can start typing // Autofocus the filter input on open so the operator can start typing
// immediately the whole point of Ctrl+K is "no mouse required". // immediately — the whole point of Ctrl+K is "no mouse required".
Loaded += (_, _) => FilterBox.Focus(); Loaded += (_, _) => FilterBox.Focus();
// Closing on click-outside: WindowStyle=None means we don't get the // Closing on click-outside: WindowStyle=None means we don't get the
// standard "deactivated" behavior cleanly, but Deactivated still // standard "deactivated" behavior cleanly, but Deactivated still
@ -40,7 +40,7 @@ public partial class CommandPaletteWindow : Window
/// <summary> /// <summary>
/// Window-level key handling. We use PreviewKeyDown so the TextBox's /// Window-level key handling. We use PreviewKeyDown so the TextBox's
/// internal handling doesn't swallow ↑/↓/Enter before we see them. /// internal handling doesn't swallow ↑/↓/Enter before we see them.
/// </summary> /// </summary>
protected override void OnPreviewKeyDown(System.Windows.Input.KeyEventArgs e) protected override void OnPreviewKeyDown(System.Windows.Input.KeyEventArgs e)
{ {
@ -63,7 +63,7 @@ public partial class CommandPaletteWindow : Window
case Key.Enter: case Key.Enter:
e.Handled = true; e.Handled = true;
var invoked = Vm?.InvokeSelection() ?? false; var invoked = Vm?.InvokeSelection() ?? false;
// Close regardless Enter on an empty palette is "I'm done"; // Close regardless — Enter on an empty palette is "I'm done";
// Enter on a real command means the action fired and the // Enter on a real command means the action fired and the
// operator wants to return to the main shell. // operator wants to return to the main shell.
if (invoked) Close(); if (invoked) Close();

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