Compare commits

..

4 commits
V1.0.1 ... 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
- 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:
push:
@ -21,15 +21,15 @@ jobs:
echo "$HOME/.dotnet" >> $GITHUB_PATH
echo "DOTNET_ROOT=$HOME/.dotnet" >> $GITHUB_ENV
- name: Restore (Linux solution filter excludes Windows-only WPF app)
run: dotnet restore TeamsISO.Linux.slnf
- name: Restore (Linux solution filter — excludes Windows-only WPF app)
run: dotnet restore Dragon-ISO.Linux.slnf
- 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)
run: >
dotnet test TeamsISO.Linux.slnf
dotnet test Dragon-ISO.Linux.slnf
--configuration Release
--no-build
--logger "trx;LogFileName=test-results.trx"
@ -47,7 +47,7 @@ jobs:
-reports:"**/coverage.cobertura.xml" \
-targetdir:coverage-report \
-reporttypes:"Cobertura;TextSummary" \
-assemblyfilters:"+TeamsISO.Engine;-TeamsISO.Engine.NdiInterop"
-assemblyfilters:"+Dragon-ISO.Engine;-Dragon-ISO.Engine.NdiInterop"
- name: Enforce coverage threshold (80%)
run: |

View file

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

6
.gitignore vendored
View file

@ -31,3 +31,9 @@ Thumbs.db
# Local Claude session metadata
.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
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.
### 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
display name.
- **Per-participant ISO outputs** with normalized framerate, resolution,
aspect mode, and audio routing. Each ISO is an individually-addressable
NDI source.
- **NDI Groups** support discovery and sender. One-click "Apply
- **NDI Groups** support — discovery and sender. One-click "Apply
transcoder topology" pins Teams' raw broadcasts to a private
`teamsiso-input` group while TeamsISO re-emits on `Public`.
- **Self-healing finder** if the NDI runtime stalls (zero discovered
`Dragon-ISO-input` group while Dragon-ISO re-emits on `Public`.
- **Self-healing finder** — if the NDI runtime stalls (zero discovered
sources past a startup grace period, or sources go from present to
empty and stay that way), the engine rebuilds the finder automatically.
- **Real-time recording** per-output raw BGRA stream + `manifest.json`
- **Real-time recording** — per-output raw BGRA stream + `manifest.json`
+ an FFmpeg `convert.cmd` script for post-production conversion to
H.264 MKV. Recording is opt-in globally and per-participant.
### UI "Studio Terminal"
### UI — "Studio Terminal"
- **Dark and light themes** with a runtime swap and a system-follow mode.
The Wild Dragon mark, the participants-grid watermark, and every accent
brush respond to the active theme.
- **Header**: brand mark, theme toggle, settings gear.
- **Transport strip**: session timer, participant count, live ISO count,
control-surface URL at-a-glance status.
control-surface URL — at-a-glance status.
- **Participants table**: 24px state LED, 106px live thumbnail preview,
name + caption, 5-bar audio meter, **inline-editable output name**,
CFG button (per-row override editor), ISO enable pill.
- **Settings drawer** slide-over from the right with OUTPUT / NETWORK /
- **Settings drawer** — slide-over from the right with OUTPUT / NETWORK /
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.
- **Live preview thumbnails** in the participants table; right-click
Open preview spawns a non-modal floating window suitable for a
- **Live preview thumbnails** in the participants table; right-click →
Open preview… spawns a non-modal floating window suitable for a
secondary monitor.
### Output name template
- New default: **the speaker's display name** (`{name}`). Per-participant
overrides are inline-editable in the table. Empty-name fallback to
`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.
- Available tokens: `{name}`, `{guid}`, `{machine}`, `{timestamp}`.
### Operator presets
- 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.
### 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
Deck / custom controllers.
- OSC on UDP `127.0.0.1:9000` for TouchOSC.
- Self-contained HTML control panel at `/ui` open from any phone on
- Self-contained HTML control panel at `/ui` — open from any phone on
the LAN.
### Diagnostics & installer
- Rolling daily Serilog logs under `%LOCALAPPDATA%\TeamsISO\logs\`.
- Diagnostic bundle export zips logs + config + presets for bug reports.
- Rolling daily Serilog logs under `%LOCALAPPDATA%\Dragon-ISO\logs\`.
- Diagnostic bundle export — zips logs + config + presets for bug reports.
- Forgejo-backed update check (manual or silent-on-launch, throttled to
24h).
- WiX MSI installer with proper Add/Remove Programs metadata, Start Menu
+ Desktop shortcuts, and in-place upgrade.
[1.0.0]: https://forge.wilddragon.net/zgaetano/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
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{46E05E34-8A87-4986-87D3-FE0DE4E05F44}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine", "src\TeamsISO.Engine\TeamsISO.Engine.csproj", "{F0D24EAE-9225-4DC4-B3D2-6966077287A0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.NdiInterop", "src\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj", "{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DBDF4A1D-4215-42D5-B456-2CE7159DF848}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.Tests", "src\tests\TeamsISO.Engine.Tests\TeamsISO.Engine.Tests.csproj", "{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App", "src\TeamsISO.App\TeamsISO.App.csproj", "{80DCE039-3BBC-4D3F-B44B-51F324591C29}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.IntegrationTests", "src\tests\TeamsISO.Engine.IntegrationTests\TeamsISO.Engine.IntegrationTests.csproj", "{A85E331D-026E-4BDE-B89C-0CC4C95001CE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src\TeamsISO.Console\TeamsISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App.Tests", "src\tests\TeamsISO.App.Tests\TeamsISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.Build.0 = Release|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.Build.0 = Release|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.Build.0 = Release|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.Build.0 = Release|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.Build.0 = Release|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{DBDF4A1D-4215-42D5-B456-2CE7159DF848} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
{80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
EndGlobalSection
EndGlobal

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

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

View file

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

View file

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

View file

@ -0,0 +1,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>
<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). -->
<Platform>x64</Platform>
@ -10,15 +10,15 @@
<!--
Built artifact location. The installer expects a published build of
TeamsISO.App rooted here. CI / local script:
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj
-c Release -r win-x64 (with self contained false)
-o $(SolutionDir)publish/TeamsISO
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\TeamsISO\</PublishDir>
<PublishDir>$(MSBuildThisFileDirectory)..\publish\Dragon-ISO\</PublishDir>
<!-- 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). -->
<SignOutput Condition=" '$(SignOutput)' == '' ">false</SignOutput>
@ -32,4 +32,4 @@
<PackageReference Include="WixToolset.UI.wixext" Version="5.0.2" />
</ItemGroup>
</Project>
</Project>

View file

@ -1,22 +1,27 @@
<?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:
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj -c Release -r win-x64 (no self contained) -o publish/TeamsISO
dotnet build installer/TeamsISO.Installer.wixproj -c Release
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj -c Release -r win-x64 -p:SelfContained=false -o publish/Dragon-ISO
dotnet build installer/Dragon-ISO.Installer.wixproj -c Release
Runtime expectations:
- .NET 8 Desktop runtime present on target (framework-dependent build)
- NDI 6 Runtime present — checked in CheckNdiRuntime; absence WARNS
- 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="TeamsISO"
<Package Name="Dragon-ISO"
Manufacturer="Wild Dragon LLC"
Version="1.0.0.0"
UpgradeCode="9F4A8B2C-1D3E-4A5B-9C6D-8E7F0A1B2C3D"
@ -32,9 +37,9 @@
installer dialogs.
-->
<SummaryInformation
Description="TeamsISO — per-participant NDI ISO controller for Microsoft Teams. Splits each Teams participant into a normalized NDI source for vMix / OBS / Ross / hardware switchers."
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="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
@ -42,13 +47,13 @@
forward-migration path; downgrading would leave operators with a
config the older binary doesn't understand.
-->
<MajorUpgrade DowngradeErrorMessage="A newer version of TeamsISO is already installed. Uninstall it before installing this older version."
<MajorUpgrade DowngradeErrorMessage="A newer version of 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="TeamsISO" Level="1">
<Feature Id="Main" Title="Dragon-ISO" Level="1">
<ComponentGroupRef Id="ApplicationFiles" />
<ComponentGroupRef Id="Shortcuts" />
<ComponentGroupRef Id="DesktopShortcut" />
@ -56,37 +61,51 @@
</Feature>
<!--
Friendly install UI. WixToolset.UI.wixext provides several flavors;
WixUI_InstallDir lets the user pick the directory.
Minimal install UI: Welcome/License -> Progress -> Finish.
No directory picker; installs to Program Files\Wild Dragon\Dragon-ISO.
-->
<ui:WixUI Id="WixUI_InstallDir" />
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
<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.
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="ARPCONTACT" Value="Wild Dragon LLC — support@wilddragon.net" />
<Property Id="ARPCOMMENTS" Value="TeamsISO turns Microsoft Teams' raw NDI broadcast into clean, normalized, per-participant NDI sources for ingestion by a live-production switcher (vMix, OBS, Ross, hardware capture). Each participant gets an individually-addressable source with configurable framerate, resolution, aspect mode, and audio routing." />
<!-- ARPNOMODIFY is set by WixUI_InstallDir; don't redeclare. -->
<Property Id="ARPCOMMENTS" Value="Dragon-ISO turns Microsoft Teams' raw NDI broadcast into clean, normalized, per-participant NDI sources for ingestion by a live-production switcher (vMix, OBS, Ross, hardware capture). Each participant gets an individually-addressable source with configurable framerate, resolution, aspect mode, and audio routing." />
<!-- ARPNOMODIFY is set by WixUI_Minimal. Do not redeclare. -->
<Property Id="ARPNOREPAIR" Value="1" />
<!--
ARP icon references the same .ico the WPF host uses. WiX requires the
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 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.
-->
<Icon Id="TeamsISOIcon" SourceFile="$(var.AssetsDir)teamsiso.ico" />
<Property Id="ARPPRODUCTICON" Value="TeamsISOIcon" />
<Icon Id="DragonISOIcon" SourceFile="$(var.AssetsDir)Dragon-ISO.ico" />
<Property Id="ARPPRODUCTICON" Value="DragonISOIcon" />
<!--
.NET 8 Desktop Runtime detection. The .NET apphost will surface a
"framework not found" dialog naturally if the runtime is absent;
this property is available for future conditional logic.
VBScript-based install-time dialogs are deprecated in WiX v5 / Windows;
rewriting in C++ is overkill for a soft warning on a soft dependency.
-->
<Property Id="DOTNET8DESKTOPRUNTIME" Value="0">
<RegistrySearch Id="DotNet8DesktopRuntimeSearch"
Root="HKLM"
Key="SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App\8.0.0"
Name="Version"
Type="raw" />
</Property>
<!--
NDI Runtime detection. We check for NDI_RUNTIME_DIR_V6 in the system
environment block. Missing → warn during install, don't block. The
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.
-->
@ -107,11 +126,11 @@
-->
<!--
Install layout under Program Files\Wild Dragon\TeamsISO.
Install layout under Program Files\Wild Dragon\Dragon-ISO.
-->
<StandardDirectory Id="ProgramFiles64Folder">
<Directory Id="ManufacturerFolder" Name="Wild Dragon">
<Directory Id="INSTALLFOLDER" Name="TeamsISO" />
<Directory Id="INSTALLFOLDER" Name="Dragon-ISO" />
</Directory>
</StandardDirectory>
@ -129,30 +148,33 @@
</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
else that demotes the spawned process). The SAFER-restricted token
breaks .NET 8 WPF apphost startup: the process appears alive with
a window, but no managed code past BAML parse executes. Verified
empirically 2026-05-16 — letting TeamsISO inherit the launching
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="StartMenuTeamsISO"
Name="TeamsISO"
<Shortcut Id="StartMenuDragonISO"
Name="Dragon-ISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]TeamsISO.exe"
Target="[INSTALLFOLDER]DragonISO.exe"
WorkingDirectory="INSTALLFOLDER"
Icon="TeamsISOIcon" />
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\TeamsISO"
Key="Software\Wild Dragon\Dragon-ISO"
Name="StartMenuShortcut"
Type="integer"
Value="1"
@ -163,14 +185,14 @@
<StandardDirectory Id="DesktopFolder" />
<ComponentGroup Id="DesktopShortcut" Directory="DesktopFolder">
<Component Id="DesktopShortcutComponent" Guid="*">
<Shortcut Id="DesktopTeamsISO"
Name="TeamsISO"
<Shortcut Id="DesktopDragonISO"
Name="Dragon-ISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]TeamsISO.exe"
Target="[INSTALLFOLDER]DragonISO.exe"
WorkingDirectory="INSTALLFOLDER"
Icon="TeamsISOIcon" />
Icon="DragonISOIcon" />
<RegistryValue Root="HKCU"
Key="Software\Wild Dragon\TeamsISO"
Key="Software\Wild Dragon\Dragon-ISO"
Name="DesktopShortcut"
Type="integer"
Value="1"
@ -179,14 +201,14 @@
</ComponentGroup>
<!--
ARP icon registry entry. Optional the MSI auto-fills most ARP
fields from the Package element. We only need to point at the
executable for the ARP icon.
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\TeamsISO"
Key="Software\Wild Dragon\Dragon-ISO"
Name="InstallPath"
Type="string"
Value="[INSTALLFOLDER]"
@ -195,4 +217,4 @@
</ComponentGroup>
</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:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="About TeamsISO"
Icon="/Assets/teamsiso.ico"
Title="About DragonISO"
Icon="/Assets/DragonISO.ico"
Width="460" Height="500"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
@ -36,7 +36,7 @@
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="About TeamsISO"
<TextBlock Text="About DragonISO"
Style="{StaticResource Wd.Text.Caption}"
Margin="20,12,0,0"
VerticalAlignment="Center"/>
@ -62,7 +62,7 @@
Margin="0,0,0,16"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="TeamsISO"
<TextBlock Text="DragonISO"
Style="{StaticResource Wd.Text.Title}"
FontSize="28"
HorizontalAlignment="Center"/>
@ -146,12 +146,12 @@
Click="OnOpenLogs"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Logs in Explorer"/>
ToolTip="Open %LOCALAPPDATA%\DragonISO\Logs in Explorer"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Notes"
Click="OnOpenNotes"
Padding="14,6"
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Notes in Explorer"/>
ToolTip="Open %LOCALAPPDATA%\DragonISO\Notes in Explorer"/>
</StackPanel>
</StackPanel>

View file

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

View file

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

View file

@ -1,17 +1,17 @@
using System.IO;
using System.IO;
using System.Windows;
using System.Windows.Threading;
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
// 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.
//
// 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.
public partial class App
{
@ -23,11 +23,11 @@ public partial class App
private static string LogDirectory =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Logs");
"Dragon-ISO", "Logs");
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
// + show a dialog inline since the process will exit either way.
var ex = e.ExceptionObject as Exception;
@ -40,7 +40,7 @@ public partial class App
TryLogFatal("Dispatcher.UnhandledException", e.Exception);
TryShowCrashDialog(e.Exception, terminating: false);
// Mark Handled so a single bad UI thunk doesn't take the whole app
// down the user has the dialog and the log; they can choose to
// down — the user has the dialog and the log; they can choose to
// keep going.
e.Handled = true;
}
@ -48,7 +48,7 @@ public partial class App
private void OnUnobservedTaskException(object? sender, System.Threading.Tasks.UnobservedTaskExceptionEventArgs e)
{
TryLogFatal("TaskScheduler.UnobservedTaskException", e.Exception);
// Don't show a dialog here these fire from the finalizer thread
// Don't show a dialog here — these fire from the finalizer thread
// and tend to be cleanup-time noise, not user-actionable. Log only.
e.SetObserved();
}
@ -62,7 +62,7 @@ public partial class App
}
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
// handling makes things worse.
}
@ -73,15 +73,15 @@ public partial class App
try
{
var heading = terminating
? "TeamsISO encountered an unrecoverable error and will exit."
: "TeamsISO encountered an error.";
? "Dragon-ISO encountered an unrecoverable error and will exit."
: "Dragon-ISO encountered an error.";
var details = ex?.GetType().Name + ": " + (ex?.Message ?? "(no details)");
var body =
heading + "\n\n" +
details + "\n\n" +
$"A full diagnostic log has been written to:\n{LogDirectory}\n\n" +
"Attach the most recent file from that directory to your bug report.";
MessageBox.Show(body, "TeamsISO — Error",
MessageBox.Show(body, "Dragon-ISO — Error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
catch

View file

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

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:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -1,7 +1,7 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Windows;
namespace TeamsISO.App;
namespace DragonISO.App;
/// <summary>
/// F1 cheat-sheet dialog. Lists keyboard shortcuts, file locations, and a
@ -20,7 +20,7 @@ public partial class HelpWindow : Window
{
Process.Start(new ProcessStartInfo
{
FileName = "https://forge.wilddragon.net/zgaetano/teamsiso",
FileName = "https://forge.wilddragon.net/zgaetano/Dragon-ISO",
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels"
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
xmlns:vm="clr-namespace:DragonISO.App.ViewModels"
xmlns:conv="clr-namespace:DragonISO.App.Converters"
mc:Ignorable="d"
Title="TeamsISO"
Title="DragonISO"
Width="1280" Height="780"
MinWidth="980" MinHeight="640"
WindowStartupLocation="CenterScreen"
@ -19,7 +19,7 @@
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
<!--
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
below it carries the brand mark, wordmark, and two icon buttons:
@ -105,7 +105,7 @@
Background="Transparent"
BorderThickness="0"
Cursor="Hand"
ToolTip="About TeamsISO">
ToolTip="About DragonISO">
<!-- Source bound to Wd.BrandMark.Image so the mark flips
white↔black with the active theme (see Theme.Dark /
Theme.Light). The PNG carries its own AA so HighQuality
@ -114,7 +114,7 @@
Width="20" Height="20"
RenderOptions.BitmapScalingMode="HighQuality"/>
</Button>
<TextBlock Text="TeamsISO"
<TextBlock Text="DragonISO"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="13"
FontWeight="Medium"
@ -427,7 +427,7 @@
crosses its threshold (0.2, 0.4,
0.6, 0.8, 1.0). No averaging.
4. Output name 130px — JetBrains Mono 12 — the NDI source
name TeamsISO broadcasts as.
name DragonISO broadcasts as.
5. ISO toggle pill 100px — LIVE = cyan-muted fill with cyan
text; OFF = hollow neutral; ERROR
gets the existing trigger swap.
@ -638,7 +638,7 @@
</DataGridTemplateColumn>
<!-- 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,
clear the field to revert to the default. EditableOutputName
handles both directions (see ParticipantViewModel comment).
@ -1007,7 +1007,7 @@
HorizontalAlignment="Stretch"
Margin="0,16,0,0"
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}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
@ -1055,7 +1055,7 @@
Height="1"
Background="{DynamicResource Wd.Border}"/>
<CheckBox Content="Launch Microsoft Teams on TeamsISO startup"
<CheckBox Content="Launch Microsoft Teams on DragonISO startup"
IsChecked="{Binding Settings.LaunchTeamsOnStartup}"/>
<CheckBox Content="Auto-hide Teams windows when launched"
IsChecked="{Binding Settings.AutoHideTeamsWindows}"

View file

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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
<Window x:Class="TeamsISO.App.OnboardingWindow"
<Window x:Class="DragonISO.App.OnboardingWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Welcome to TeamsISO"
Icon="/Assets/teamsiso.ico"
Title="Welcome to DragonISO"
Icon="/Assets/DragonISO.ico"
Width="560" Height="600"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
@ -59,7 +59,7 @@
HorizontalAlignment="Left"
Margin="0,0,0,12"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="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}"
TextWrapping="Wrap"/>
<TextBlock Text="A few one-time setup notes before you start."
@ -96,7 +96,7 @@
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
Text="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>
</Border>
@ -152,7 +152,7 @@
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
Text="Settings → NETWORK → Apply transcoder topology. This pins Teams' raw NDI broadcasts to a private '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>
</Border>
@ -180,11 +180,11 @@
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
Text="Once you've toggled the right ISOs, click Presets in the participants header to save them by display name. Turn on 'Auto-apply last preset on launch' under DISPLAY settings and 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>
</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">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
@ -208,7 +208,7 @@
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
Text="To use 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>
</Border>
@ -236,7 +236,7 @@
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
Text="For headless host PC + thin-client setups: tick 'Control surface' then 'LAN-reachable' under DISPLAY. 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>
</Border>
@ -264,7 +264,7 @@
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
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>
</Border>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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
// surface; humans see it via curl http://127.0.0.1:9755/.
public sealed partial class ControlSurfaceServer
{
private object GetServerInfo()
{
// Best-effort engine snapshot wrapped in TryRead so a transient
// Best-effort engine snapshot — wrapped in TryRead so a transient
// controller error doesn't 500 the homepage poll.
var settings = TryRead(() => _controller.GlobalSettings);
var groups = TryRead(() => _controller.GroupSettings);
return new
{
product = "TeamsISO",
product = "Dragon-ISO",
version = typeof(ControlSurfaceServer).Assembly.GetName().Version?.ToString() ?? "unknown",
engine = new
{

View file

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

View file

@ -1,10 +1,10 @@
namespace TeamsISO.App.Services;
namespace DragonISO.App.Services;
// /presets/* route handlers.
//
// POST /presets/refresh-discovery RefreshDiscovery
// POST /presets/stop-all StopAllAsync
// POST /presets/{name}/apply ApplyPresetAsync
// POST /presets/refresh-discovery → RefreshDiscovery
// POST /presets/stop-all → StopAllAsync
// POST /presets/{name}/apply → ApplyPresetAsync
public sealed partial class ControlSurfaceServer
{
private object RefreshDiscovery()
@ -20,7 +20,7 @@ public sealed partial class ControlSurfaceServer
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null) return new { ok = false, error = "dispatcher not ready" };
// Snapshot the enabled set on the UI thread ObservableCollection
// Snapshot the enabled set on the UI thread — ObservableCollection
// isn't safe to enumerate from a thread-pool task, and reading the
// IsEnabled property indirectly walks the data-binding system.
var enabled = await dispatcher.InvokeAsync(() =>
@ -50,7 +50,7 @@ public sealed partial class ControlSurfaceServer
if (vm is null || dispatcher is null)
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
// happen there. PresetApplier marshals subsequent property writes
// 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
// 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
// without leaving the web UI.
//
// GET /topology GetTopology
// POST /topology/apply ApplyTopologyAsync
// POST /topology/restore RestoreTopologyAsync
// GET /topology → GetTopology
// POST /topology/apply → ApplyTopologyAsync
// POST /topology/restore → RestoreTopologyAsync
public sealed partial class ControlSurfaceServer
{
/// <summary>
/// Report the current NDI machine topology. "mode" is "hidden" when
/// local senders are confined to the private group (raw Teams sources
/// invisible to the rest of the LAN), "public" otherwise. Reads the
/// machine NDI config file directly no caching, so the result
/// machine NDI config file directly — no caching, so the result
/// reflects whatever state the file is in right now (including
/// manual edits).
/// </summary>
@ -37,9 +37,9 @@ public sealed partial class ControlSurfaceServer
}
/// <summary>
/// Apply the transcoder topology: machine senders → <c>teamsiso-input</c>,
/// receivers → <c>public + teamsiso-input</c>; engine groups updated to
/// match (discover from teamsiso-input, broadcast on public). Operator
/// Apply the transcoder topology: machine senders → <c>Dragon-ISO-input</c>,
/// receivers → <c>public + Dragon-ISO-input</c>; engine groups updated to
/// match (discover from Dragon-ISO-input, broadcast on public). Operator
/// MUST restart Teams afterward for it to read the new NDI config.
/// </summary>
private async Task<object> ApplyTopologyAsync()
@ -51,7 +51,7 @@ public sealed partial class ControlSurfaceServer
}
// Mirror what the WPF settings VM does so the engine groups +
// machine config stay in lockstep.
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings(
var ourGroups = new DragonISO.Engine.Domain.NdiGroupSettings(
DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup,
OutputGroups: "public");
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 };
}
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings(
var ourGroups = new DragonISO.Engine.Domain.NdiGroupSettings(
DiscoveryGroups: null,
OutputGroups: null);
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.Json;
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
// stay live-synced without polling /participants.
//
// Lifecycle:
// Server's accept loop upgrades the request and hands the socket here.
// HandleWebSocketAsync owns the connection until the client closes.
// The Start() method wires a 4Hz DispatcherTimer that calls
// • Server's accept loop upgrades the request and hands the socket here.
// • HandleWebSocketAsync owns the connection until the client closes.
// • The Start() method wires a 4Hz DispatcherTimer that calls
// PushSnapshotIfChangedAsync to fan out to every connected client.
public sealed partial class ControlSurfaceServer
{
@ -20,7 +20,7 @@ public sealed partial class ControlSurfaceServer
/// Owns a single client connection until it closes. Sends an immediate
/// snapshot on connect (so the client doesn't have to wait up to 250ms
/// for the next push tick), then sits in a receive loop draining any
/// incoming text — we ignore client→server messages for v1 since all
/// incoming text — we ignore clientâ†server messages for v1 since all
/// commands are REST. The receive loop is the canonical way to detect
/// graceful close: when WebSocket.ReceiveAsync returns CloseReceived,
/// we close back and remove the client.
@ -33,7 +33,7 @@ public sealed partial class ControlSurfaceServer
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.
await SendAsync(ws, await GetSnapshotJsonAsync());
@ -68,7 +68,7 @@ public sealed partial class ControlSurfaceServer
/// Dispatcher-tick handler. Reads the current participants snapshot,
/// and if it differs from what we last pushed, broadcasts the new
/// JSON to every connected client. Diffing on the JSON string is
/// cheap and saves wire bytes when nothing's actually changing
/// cheap and saves wire bytes when nothing's actually changing —
/// typical operator workflow has long periods of no state churn
/// between meetings.
/// </summary>

View file

@ -1,4 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net;
@ -8,39 +8,39 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows.Threading;
using Microsoft.Extensions.Logging;
using TeamsISO.App.ViewModels;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
using DragonISO.App.ViewModels;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Domain;
namespace TeamsISO.App.Services;
namespace DragonISO.App.Services;
/// <summary>
/// Localhost-only HTTP control surface. Lets external controllers (Bitfocus
/// Companion, Stream Deck plugins, Bome MIDI Translator, custom node-RED flows,
/// etc.) drive 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
/// the typical operator workflow is "Stream Deck on the same machine as TeamsISO".
/// Bound to 127.0.0.1 by default — exposing this to LAN would require auth, and
/// the typical operator workflow is "Stream Deck on the same machine as Dragon-ISO".
/// If a future user needs LAN access, add a token check + bind to a configurable
/// address; both are deliberately punted for v1.
///
/// Endpoints (all return application/json):
///
/// GET / server info + endpoint list
/// GET /participants list of {id, displayName, isOnline, isEnabled}
/// POST /participants/{id}/iso body {"enabled":bool,"customName":string?}
/// POST /participants/iso body {"displayName":string,"enabled":bool} (look up by name)
/// POST /presets/{name}/apply apply a saved preset
/// POST /presets/refresh-discovery rebuild NDI finder
/// POST /presets/stop-all disable every running ISO
/// POST /teams/mute toggle mute via UIA
/// POST /teams/camera toggle camera via UIA
/// POST /teams/leave leave the call via UIA
/// POST /teams/share open share tray via UIA
/// POST /teams/raise-hand toggle raise hand via UIA
/// POST /recording body {"enabled":bool,"directory":string?}
/// GET / — server info + endpoint list
/// GET /participants — list of {id, displayName, isOnline, isEnabled}
/// POST /participants/{id}/iso — body {"enabled":bool,"customName":string?}
/// POST /participants/iso — body {"displayName":string,"enabled":bool} (look up by name)
/// POST /presets/{name}/apply — apply a saved preset
/// POST /presets/refresh-discovery — rebuild NDI finder
/// POST /presets/stop-all — disable every running ISO
/// POST /teams/mute — toggle mute via UIA
/// POST /teams/camera — toggle camera via UIA
/// POST /teams/leave — leave the call via UIA
/// POST /teams/share — open share tray via UIA
/// POST /teams/raise-hand — toggle raise hand via UIA
/// POST /recording — body {"enabled":bool,"directory":string?}
///
/// All POST bodies are optional endpoints that take parameters accept them
/// All POST bodies are optional — endpoints that take parameters accept them
/// either via JSON body or via query string (?enabled=true&amp;customName=Host).
/// This is friendly to Companion's "URL with query string" mode.
/// </summary>
@ -93,11 +93,11 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
/// <param name="port">TCP port to listen on.</param>
/// <param name="bindToLan">
/// When true, binds to all interfaces (<c>http://+:port/</c>) so other
/// machines on the LAN can reach the control surface typical for
/// machines on the LAN can reach the control surface — typical for
/// "headless show machine + thin client controller" setups. When false
/// (default), binds to <c>127.0.0.1</c> only.
///
/// LAN binding requires either running 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:
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
/// 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
// is fast enough that operators see immediate feedback when they flip an
// ISO on the Stream Deck without us spamming the wire when nothing's
// changing the snapshot serializer dedupes against the previous push.
// changing — the snapshot serializer dedupes against the previous push.
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is not null)
{
@ -205,7 +205,7 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
var res = ctx.Response;
// Tracks whether we should call res.Close() in the finally. WebSocket
// upgrades transfer ownership of the connection to the WebSocket
// instance closing the response here would tear down the freshly-
// instance — closing the response here would tear down the freshly-
// upgraded socket immediately. So we skip the finally close on that
// path.
var closeResponseInFinally = true;
@ -235,7 +235,7 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
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.
if (req.HttpMethod == "GET" && path == "/ui")
{
@ -247,7 +247,7 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
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
// this participant. The HTML control panel uses this URL with
// 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/raise-hand") => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"),
// /recording routes removed alongside the rest of the recording surface.
// Topology read the machine NDI config to report whether raw
// Topology — read the machine NDI config to report whether raw
// Teams NDI sources are hidden from the LAN, and let the
// operator apply / restore without leaving the web UI.
("GET", "/topology") => GetTopology(),
@ -342,7 +342,7 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
}
}
// ─── handlers ───────────────────────────────────────────────────────
// ─── handlers ───────────────────────────────────────────────────────
//
// Endpoint bodies live in Services/ControlSurface/Endpoints/*.cs as
// 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.")]
private object NotFound() => new { error = "not found" };
// ─── helpers ────────────────────────────────────────────────────────
// ─── helpers ────────────────────────────────────────────────────────
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.Reflection;
using System.Text;
namespace TeamsISO.App.Services;
namespace DragonISO.App.Services;
/// <summary>
/// Gathers logs + config + presets + version metadata into a single .zip the
/// operator can attach to a bug report. Surfaced via the "Export diagnostics"
/// button in About.
///
/// We deliberately do NOT include screenshots or any process/memory dumps
/// We deliberately do NOT include screenshots or any process/memory dumps —
/// that's outside the scope of a v1 support bundle and would raise privacy
/// flags. The bundle has only files the user already wrote with their TeamsISO
/// flags. The bundle has only files the user already wrote with their Dragon-ISO
/// usage; nothing here is hidden state.
/// </summary>
public static class DiagnosticsBundle
{
/// <summary>
/// Build the bundle and return the path it was written to.
/// Throws on disk failure the caller toasts/dialogs.
/// Throws on disk failure — the caller toasts/dialogs.
/// </summary>
public static string Export()
{
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 downloads = Path.Combine(outDir, "Downloads");
if (!Directory.Exists(downloads)) downloads = outDir; // fall back to ~/
@ -51,9 +51,9 @@ public static class DiagnosticsBundle
?? asm.GetName().Version?.ToString()
?? "unknown";
var sb = new StringBuilder();
sb.AppendLine("TeamsISO diagnostic bundle");
sb.AppendLine("Dragon-ISO diagnostic bundle");
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($"OS: {Environment.OSVersion}");
sb.AppendLine($"Machine: {Environment.MachineName}");
@ -115,17 +115,17 @@ public static class DiagnosticsBundle
private static string LogsDirectory =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Logs");
"Dragon-ISO", "Logs");
private static string LocalAppDataPath(string fileName) =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", fileName);
"Dragon-ISO", fileName);
private static string AppDataPath(string fileName) =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"TeamsISO", fileName);
"Dragon-ISO", fileName);
private static string NdiConfigPath() =>
Path.Combine(

View file

@ -1,19 +1,19 @@
using System.IO;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace TeamsISO.App.Services;
namespace DragonISO.App.Services;
/// <summary>
/// Reads and writes NDI Access Manager's per-user config at
/// <c>%APPDATA%\NDI\ndi-config.v1.json</c>. This file controls global NDI behavior for
/// every NDI application on the machine sender groups, receiver groups, RUDP/TCP
/// every NDI application on the machine — sender groups, receiver groups, RUDP/TCP
/// transport toggles, allowed adapters, etc. NDI applications read it on startup, so
/// changes here only take effect after restarting the affected app (Teams, OBS, etc.).
///
/// We use it to implement the "transcoder topology" requested by the user: pin Teams'
/// raw at-source-resolution NDI broadcasts to a private group (<c>teamsiso-input</c>) so
/// they don't pollute the production network, while TeamsISO's own clean normalized ISO
/// raw at-source-resolution NDI broadcasts to a private group (<c>Dragon-ISO-input</c>) so
/// they don't pollute the production network, while Dragon-ISO's own clean normalized ISO
/// outputs continue to broadcast on the standard <c>Public</c> group that downstream
/// switchers and recorders default to.
///
@ -36,7 +36,7 @@ public static class NdiAccessManagerConfig
/// Default name of the private group used for the transcoder topology.
/// Matches the convention referenced in the NDI Network settings UI.
/// </summary>
public const string TranscoderInputGroup = "teamsiso-input";
public const string TranscoderInputGroup = "Dragon-ISO-input";
/// <summary>
/// Result of an apply attempt. <see cref="Success"/> indicates the file was
@ -54,12 +54,12 @@ public static class NdiAccessManagerConfig
/// Configures the machine-wide NDI groups so:
/// <list type="bullet">
/// <item>All local senders (Teams, anything else) broadcast on
/// <paramref name="senderGroup"/> only i.e. the private input group.</item>
/// <paramref name="senderGroup"/> only — i.e. the private input group.</item>
/// <item>All local receivers see both <paramref name="senderGroup"/> and
/// <c>public</c> so 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>
/// </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.
/// </summary>
/// <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;
namespace TeamsISO.App.Services;
namespace DragonISO.App.Services;
/// <summary>
/// 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
/// <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.
///
/// 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();
/// <summary>
/// Test-only seam when set, overrides the default
/// %LOCALAPPDATA%\TeamsISO\Notes path. Lets tests write to a
/// Test-only seam — when set, overrides the default
/// %LOCALAPPDATA%\Dragon-ISO\Notes path. Lets tests write to a
/// tempdir without polluting the dev's real notes folder.
/// InternalsVisibleTo grants TeamsISO.App.Tests access.
/// InternalsVisibleTo grants DragonISO.App.Tests access.
/// </summary>
internal static string? DirectoryOverride { get; set; }
private static string NotesDirectory =>
DirectoryOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Notes");
"Dragon-ISO", "Notes");
/// <summary>Today's notes file path (created lazily on first append).</summary>
public static string TodayPath =>
@ -50,10 +50,10 @@ public static class NotesService
{
Directory.CreateDirectory(NotesDirectory);
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))
{
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.AppendAllText(path, line, Encoding.UTF8);

View file

@ -1,7 +1,7 @@
using System.IO;
using System.IO;
using System.Text.Json;
namespace TeamsISO.App.Services;
namespace DragonISO.App.Services;
/// <summary>
/// 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
/// 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
/// because the Id is freshly generated for every meeting (Teams' NDI source
/// identity isn't stable across sessions); display name is the operator's
@ -29,7 +29,7 @@ public static class OperatorPresetStore
private static string PresetsPath =>
PathOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO",
"Dragon-ISO",
"presets.json");
/// <summary>
@ -77,7 +77,7 @@ public static class OperatorPresetStore
/// <summary>
/// Returns the operator's startup preference (which preset, if any, should be
/// auto-applied on launch). Defaults to <c>(null, false)</c> when no file exists
/// or the file predates the field older preset.json files deserialize cleanly
/// or the file predates the field — older preset.json files deserialize cleanly
/// because both fields are optional with default values.
/// </summary>
public static StartupPreference GetStartupPreference()
@ -153,8 +153,8 @@ public static class OperatorPresetStore
/// <summary>
/// Bundle format for the export/import surface. Wraps the preset list with
/// a version stamp + an export timestamp so a future-format-aware importer
/// can migrate the data. We deliberately export a flat preset list not
/// the full <see cref="File"/> envelope because StartupPreference is
/// can migrate the data. We deliberately export a flat preset list — not
/// the full <see cref="File"/> envelope — because StartupPreference is
/// machine-local (operator A's "auto-apply Friday Show" shouldn't follow
/// the bundle to operator B's machine).
/// </summary>
@ -163,7 +163,7 @@ public static class OperatorPresetStore
DateTimeOffset ExportedAt,
IReadOnlyList<Preset> Presets)
{
public const string CurrentSchema = "teamsiso-presets-bundle/v1";
public const string CurrentSchema = "Dragon-ISO-presets-bundle/v1";
}
/// <summary>
@ -181,7 +181,7 @@ public static class OperatorPresetStore
}
/// <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>
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.Text;
using Microsoft.Extensions.Logging;
using TeamsISO.App.ViewModels;
using TeamsISO.Engine.Controller;
using DragonISO.App.ViewModels;
using DragonISO.Engine.Controller;
namespace TeamsISO.App.Services;
namespace DragonISO.App.Services;
/// <summary>
/// OSC over UDP. Companion, TouchOSC, Bome, and most lighting consoles speak
/// OSC natively, so wrapping the same command surface in OSC opens the
/// product to the broader live-show ecosystem without a Companion bridge.
///
/// Protocol minimal OSC 1.0:
/// Protocol — minimal OSC 1.0:
/// - Address pattern (null-terminated string, padded to 4-byte boundary)
/// - Type tag (",iiisf" etc., null-terminated, padded to 4)
/// - Args in order
///
/// We don't implement bundles, time tags, blob args, or pattern matching
/// none are needed for the verbs we support. If a sender uses bundles
/// we ignore them; if a sender uses a wildcard address ("/teamsiso/*") we
/// — none are needed for the verbs we support. If a sender uses bundles
/// we ignore them; if a sender uses a wildcard address ("/Dragon-ISO/*") we
/// ignore it. Operators get a clear log line in either case.
///
/// Routes:
/// /teamsiso/iso "DisplayName" {0|1} — toggle/set ISO by display name
/// /teamsiso/iso/by-id "guid" {0|1} — toggle/set by Id
/// /teamsiso/preset "Name" — apply preset
/// /teamsiso/teams/mute — UIA toggle mute
/// /teamsiso/teams/camera — UIA toggle camera
/// /teamsiso/teams/leave — UIA leave
/// /teamsiso/teams/share — UIA share tray
/// /teamsiso/teams/raise-hand — UIA raise hand
/// /teamsiso/refresh-discovery — rebuild NDI finder
/// /teamsiso/stop-all — disable every ISO
/// /teamsiso/recording {0|1} — recording on/off (default dir)
/// /Dragon-ISO/iso "DisplayName" {0|1} — toggle/set ISO by display name
/// /Dragon-ISO/iso/by-id "guid" {0|1} — toggle/set by Id
/// /Dragon-ISO/preset "Name" — apply preset
/// /Dragon-ISO/teams/mute — UIA toggle mute
/// /Dragon-ISO/teams/camera — UIA toggle camera
/// /Dragon-ISO/teams/leave — UIA leave
/// /Dragon-ISO/teams/share — UIA share tray
/// /Dragon-ISO/teams/raise-hand — UIA raise hand
/// /Dragon-ISO/refresh-discovery — rebuild NDI finder
/// /Dragon-ISO/stop-all — disable every ISO
/// /Dragon-ISO/recording {0|1} — recording on/off (default dir)
/// </summary>
public sealed class OscBridge : IAsyncDisposable
{
@ -63,9 +63,9 @@ public sealed class OscBridge : IAsyncDisposable
/// <summary>
/// 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).
/// 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.
/// </summary>
public void Start(int port, bool bindToLan = false)
@ -147,25 +147,25 @@ public sealed class OscBridge : IAsyncDisposable
var addr = msg.Address;
switch (addr)
{
case "/teamsiso/teams/mute": InvokeTeams(TeamsControlBridge.ToggleMute); return;
case "/teamsiso/teams/camera": InvokeTeams(TeamsControlBridge.ToggleCamera); return;
case "/teamsiso/teams/leave": InvokeTeams(TeamsControlBridge.LeaveCall); return;
case "/teamsiso/teams/share": InvokeTeams(TeamsControlBridge.OpenShareTray); return;
case "/teamsiso/teams/raise-hand":InvokeTeams(TeamsControlBridge.ToggleRaiseHand); return;
case "/teamsiso/refresh-discovery":_controller.RefreshDiscovery(); return;
case "/teamsiso/stop-all": await StopAllAsync(); return;
// /teamsiso/recording routes removed alongside the rest of the recording surface.
case "/teamsiso/notes": AppendNote(msg); return;
case "/teamsiso/iso": await ToggleByNameAsync(msg); return;
case "/teamsiso/iso/by-id": await ToggleByIdAsync(msg); return;
case "/teamsiso/preset": await ApplyPresetAsync(msg); return;
case "/Dragon-ISO/teams/mute": InvokeTeams(TeamsControlBridge.ToggleMute); return;
case "/Dragon-ISO/teams/camera": InvokeTeams(TeamsControlBridge.ToggleCamera); return;
case "/Dragon-ISO/teams/leave": InvokeTeams(TeamsControlBridge.LeaveCall); return;
case "/Dragon-ISO/teams/share": InvokeTeams(TeamsControlBridge.OpenShareTray); return;
case "/Dragon-ISO/teams/raise-hand":InvokeTeams(TeamsControlBridge.ToggleRaiseHand); return;
case "/Dragon-ISO/refresh-discovery":_controller.RefreshDiscovery(); return;
case "/Dragon-ISO/stop-all": await StopAllAsync(); return;
// /Dragon-ISO/recording routes removed alongside the rest of the recording surface.
case "/Dragon-ISO/notes": AppendNote(msg); return;
case "/Dragon-ISO/iso": await ToggleByNameAsync(msg); return;
case "/Dragon-ISO/iso/by-id": await ToggleByIdAsync(msg); return;
case "/Dragon-ISO/preset": await ApplyPresetAsync(msg); return;
default:
_logger?.LogDebug("Ignoring unknown OSC address {Addr}", addr);
return;
}
}
// ─── handler helpers ────────────────────────────────────────────────
// ─── handler helpers ────────────────────────────────────────────────
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>
/// Minimal OSC 1.0 message parser. Supports the subset we care about:
/// integer (i), float (f), string (s) args. Bundles / time tags / blobs are
/// not implemented incoming packets that look like bundles return null
/// not implemented — incoming packets that look like bundles return null
/// and the caller logs + skips them.
/// </summary>
internal sealed class OscMessage
@ -283,7 +283,7 @@ internal sealed class OscMessage
public static OscMessage? TryParse(byte[] bytes)
{
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;
var idx = 0;
@ -317,7 +317,7 @@ internal sealed class OscMessage
case 'T': args.Add(true); break;
case 'F': args.Add(false); break;
default:
// Unknown type bail rather than mis-aligning subsequent args.
// Unknown type — bail rather than mis-aligning subsequent args.
return null;
}
}

View file

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

View file

@ -1,8 +1,8 @@
using System.Windows.Threading;
using TeamsISO.App.ViewModels;
using TeamsISO.Engine.Controller;
using System.Windows.Threading;
using DragonISO.App.ViewModels;
using DragonISO.Engine.Controller;
namespace TeamsISO.App.Services;
namespace DragonISO.App.Services;
/// <summary>
/// Shared preset-application logic. Originally lived inline in
@ -36,7 +36,7 @@ public static class PresetApplier
Dispatcher? dispatcher = null,
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.
var byName = preset.Assignments.ToDictionary(
a => a.DisplayName,

View file

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

View file

@ -1,11 +1,11 @@
using System.Runtime.InteropServices;
using System.Runtime.InteropServices;
namespace TeamsISO.App.Services;
namespace DragonISO.App.Services;
/// <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
/// caption + thick frame so it integrates flush with the host, and
/// 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
/// auto-hide mode if embedding misbehaves on their specific Teams build.
///
/// Lives in its own static class separated from <see cref="TeamsLauncher"/>
/// because the embedding lifecycle (reparent → resize → restore) is its
/// Lives in its own static class — separated from <see cref="TeamsLauncher"/>
/// because the embedding lifecycle (reparent → resize → restore) is its
/// own thing, and the Win32 surface it requires (SetParent / window-style
/// muck / SetWindowPos / MoveWindow) isn't reused by the launch / hide /
/// in-call control paths.
@ -67,7 +67,7 @@ public static class TeamsEmbedHost
private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState;
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;
/// <summary>
@ -79,7 +79,7 @@ public static class TeamsEmbedHost
/// The host HWND is typically obtained via:
/// var src = (System.Windows.Interop.HwndSource)
/// PresentationSource.FromVisual(MyHostBorder);
/// src.Handle // IntPtr suitable for hostHwnd
/// src.Handle // → IntPtr suitable for hostHwnd
/// </summary>
public static bool EmbedTeamsInto(IntPtr hostHwnd, int width, int height)
{
@ -87,7 +87,7 @@ public static class TeamsEmbedHost
var teamsWindows = TeamsLauncher.EnumerateTopLevelTeamsWindows();
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 /
// meeting window.
IntPtr best = IntPtr.Zero;
@ -135,7 +135,7 @@ public static class TeamsEmbedHost
/// <summary>
/// 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.
/// </summary>
public static void ResizeEmbedded(int width, int height)
@ -147,7 +147,7 @@ public static class TeamsEmbedHost
/// <summary>
/// Reverse an active embed: SetParent back to desktop + restore the
/// original window style so Teams looks/behaves like a normal
/// top-level window again. Safe to call when nothing is embedded
/// top-level window again. Safe to call when nothing is embedded —
/// no-op.
/// </summary>
public static void RestoreEmbed()
@ -167,7 +167,7 @@ public static class TeamsEmbedHost
SetWindowPos(_embeddedHwnd, IntPtr.Zero, 0, 0, 0, 0,
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
}
catch { /* defensive restore must never throw */ }
catch { /* defensive — restore must never throw */ }
finally
{
_embedSavedState = null;

View file

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

View file

@ -1,17 +1,17 @@
using System;
using System;
using System.Linq;
using System.Windows;
using Microsoft.Win32;
namespace TeamsISO.App.Services;
namespace DragonISO.App.Services;
/// <summary>
/// Owns the active theme for the WPF host. Three preferences:
/// <list type="bullet">
/// <item><c>System</c> follows the Windows app-mode setting (default for new
/// <item><c>System</c> — follows the Windows app-mode setting (default for new
/// users; re-reads on <see cref="SystemEvents.UserPreferenceChanged"/>).</item>
/// <item><c>Dark</c> pin dark regardless of OS.</item>
/// <item><c>Light</c> pin light regardless of OS.</item>
/// <item><c>Dark</c> — pin dark regardless of OS.</item>
/// <item><c>Light</c> — pin light regardless of OS.</item>
/// </list>
/// The two color files (<c>Theme.Dark.xaml</c> + <c>Theme.Light.xaml</c>) are
/// kept in lockstep on the same set of brush keys; this manager swaps the
@ -20,7 +20,7 @@ namespace TeamsISO.App.Services;
/// so the visual tree re-resolves without an app restart.
///
/// Preference is persisted via <see cref="UIPreferences"/>'s <c>Theme</c> field,
/// reserved on disk during the v1 v2 rollout so the rebuild doesn't lose the
/// reserved on disk during the v1 → v2 rollout so the rebuild doesn't lose the
/// operator's choice.
/// </summary>
public sealed class ThemeManager
@ -31,12 +31,12 @@ public sealed class ThemeManager
savePreference: TrySavePreferenceToDisk,
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
// base URI is the TeamsISO entry assembly) and from xUnit tests
// (where it's the test assembly relative URIs would miss).
private const string DarkUri = "pack://application:,,,/TeamsISO;component/Themes/Theme.Dark.xaml";
private const string LightUri = "pack://application:,,,/TeamsISO;component/Themes/Theme.Light.xaml";
// base URI is the Dragon-ISO entry assembly) and from xUnit tests
// (where it's the test assembly — relative URIs would miss).
private const string DarkUri = "pack://application:,,,/DragonISO;component/Themes/Theme.Dark.xaml";
private const string LightUri = "pack://application:,,,/DragonISO;component/Themes/Theme.Light.xaml";
private const string PreferenceKeySystem = "System";
private const string PreferenceKeyDark = "Dark";
private const string PreferenceKeyLight = "Light";
@ -69,7 +69,7 @@ public sealed class ThemeManager
}
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
@ -134,7 +134,7 @@ public sealed class ThemeManager
/// <summary>
/// Apply the current resolved theme. Should be called once during app
/// startup (after Application.Current.Resources is initialized) and
/// whenever <see cref="Preference"/> changes <see cref="Set"/> already
/// whenever <see cref="Preference"/> changes — <see cref="Set"/> already
/// does the latter for you.
/// </summary>
public void Apply()
@ -152,7 +152,7 @@ public sealed class ThemeManager
var dicts = app.Resources.MergedDictionaries;
// 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
// styles file is at WildDragonTheme.xaml. Replace in place to
// preserve merge order so DynamicResource refs resolve to the new
@ -184,7 +184,7 @@ public sealed class ThemeManager
/// <summary>
/// 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
/// sensible. Backs the singleton's _isSystemDark seam.
/// </summary>
@ -208,7 +208,7 @@ public sealed class ThemeManager
/// <summary>
/// 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
/// caller falls back to the in-memory default of "System". Backs
/// the singleton's loadPreference seam.
@ -221,7 +221,7 @@ public sealed class ThemeManager
/// <summary>
/// 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
/// singleton's savePreference seam.
/// </summary>
@ -235,7 +235,7 @@ public sealed class ThemeManager
{
if (e.Category != UserPreferenceCategory.General) 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.
Application.Current?.Dispatcher.BeginInvoke(new Action(Apply));
}

View file

@ -1,16 +1,16 @@
using System.Drawing;
using System.Drawing;
using System.IO;
using System.Reflection;
using System.Runtime.Versioning;
using System.Windows;
using WinForms = System.Windows.Forms;
namespace TeamsISO.App.Services;
namespace DragonISO.App.Services;
/// <summary>
/// Wraps a WinForms <see cref="WinForms.NotifyIcon"/> so the WPF host can
/// minimize-to-tray during long shows. Operators with a Stream Deck setup
/// often want 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
/// hidden.
///
@ -31,7 +31,7 @@ public sealed class TrayIconHost : IDisposable
_mainWindow = mainWindow;
_notifyIcon = new WinForms.NotifyIcon
{
Text = "TeamsISO",
Text = "Dragon-ISO",
Icon = LoadEmbeddedIcon(),
Visible = false,
};
@ -76,7 +76,7 @@ public sealed class TrayIconHost : IDisposable
_notifyIcon.Visible = true;
_notifyIcon.ShowBalloonTip(
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.",
tipIcon: WinForms.ToolTipIcon.Info);
}
@ -93,7 +93,7 @@ public sealed class TrayIconHost : IDisposable
private WinForms.ContextMenuStrip BuildMenu()
{
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("Stop all ISOs", null, (_, _) =>
{
@ -106,12 +106,12 @@ public sealed class TrayIconHost : IDisposable
}
});
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;
}
/// <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
/// app may be run from any CWD (via the MSI install or a developer dotnet run).
/// </summary>
@ -120,7 +120,7 @@ public sealed class TrayIconHost : IDisposable
try
{
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;
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;
namespace TeamsISO.App.Services;
namespace DragonISO.App.Services;
/// <summary>
/// Persistent UI-side toggles that don't belong in <see cref="TeamsISO.Engine.Persistence.ConfigStore"/>
/// (which is the engine's domain model framerate, NDI groups, ISO assignments).
/// Persistent UI-side toggles that don't belong in <see cref="DragonISO.Engine.Persistence.ConfigStore"/>
/// (which is the engine's domain model — framerate, NDI groups, ISO assignments).
///
/// Each toggle is a property on a single record persisted as JSON at
/// <c>%LOCALAPPDATA%\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
/// 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).
///
/// Centralizing these here means the settings VM doesn't have to plumb
@ -24,14 +24,14 @@ public static class UIPreferences
private static string PrefsPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "ui-prefs.json");
"Dragon-ISO", "ui-prefs.json");
/// <summary>
/// Sort modes for the participants DataGrid. <see cref="JoinOrder"/> is the default
/// and matches the engine's discovery order (operators with custom Stream Deck
/// layouts sometimes prefer Alphabetical for stability across meetings).
/// <see cref="LoudestFirst"/> resorts at the 1Hz stats tick so the active
/// speaker bubbles to the top useful for operators reacting to who's talking.
/// speaker bubbles to the top — useful for operators reacting to who's talking.
/// </summary>
public enum SortMode { JoinOrder, Alphabetical, OnlineFirst, LoudestFirst }
@ -43,20 +43,20 @@ public static class UIPreferences
bool MinimizeToTray = false,
bool ControlSurfaceLanReachable = false,
// 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.
// All control happens via the IN-CALL bar + participants DataGrid.
bool LaunchTeamsOnStartup = false,
bool AutoHideTeamsWindows = false,
// Experimental Phase E.4. SetParent-reparents Teams' main window
// into a 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
// back to auto-hide mode. Off by default.
bool EmbedTeamsWindow = false,
// Theme preference for the v2 redesign. One of "System" (follow
// Windows app-mode), "Dark", or "Light". ThemeManager hydrates
// from this on startup and persists back here on toggle. Default
// "System" matches DESIGN.md's "Follow Windows" choice the
// "System" matches DESIGN.md's "Follow Windows" choice — the
// operator who doesn't care gets whatever Windows is set to.
string Theme = "System",
// REST + WebSocket control surface auto-start. When true, the
@ -100,7 +100,7 @@ public static class UIPreferences
}
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.Net.Http;
using System.Reflection;
using System.Text.Json;
namespace TeamsISO.App.Services;
namespace DragonISO.App.Services;
/// <summary>
/// Asks Forgejo's REST API whether a newer release tag exists than the one
/// we're running. Manual-only for v1 there's no background polling. The
/// we're running. Manual-only for v1 — there's no background polling. The
/// operator can click "Check for updates" in the About dialog whenever they
/// want, and a positive result opens the release page in their browser
/// (rather than auto-downloading; we don't want a long-running show
/// interrupted by a surprise installer).
///
/// We use the public release endpoint so no auth is needed:
/// GET /api/v1/repos/zgaetano/teamsiso/releases?limit=1
/// GET /api/v1/repos/zgaetano/Dragon-ISO/releases?limit=1
///
/// On any error (offline, DNS failure, repo private, malformed response),
/// the caller gets <see cref="UpdateCheckResult.Failed"/> with a short
@ -24,10 +24,10 @@ namespace TeamsISO.App.Services;
public static class UpdateChecker
{
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 =
"https://forge.wilddragon.net/zgaetano/teamsiso/releases";
"https://forge.wilddragon.net/zgaetano/Dragon-ISO/releases";
/// <summary>Outcome of a single check.</summary>
public sealed record UpdateCheckResult(
@ -53,7 +53,7 @@ public static class UpdateChecker
try
{
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);
if (!res.IsSuccessStatusCode)
@ -102,7 +102,7 @@ public static class UpdateChecker
/// <summary>
/// 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.
/// </summary>
public static void OpenReleasesPage()
@ -124,7 +124,7 @@ public static class UpdateChecker
/// <summary>
/// Silent throttled launch-time check. Returns the result if a check actually
/// happened, or null if the cooldown window suppressed it. The cooldown lives
/// in <c>%LOCALAPPDATA%\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."
/// </summary>
public static async Task<UpdateCheckResult?> CheckIfDueAsync(TimeSpan cooldown, CancellationToken ct = default)
@ -165,8 +165,8 @@ public static class UpdateChecker
}
/// <summary>
/// Test-only seam when set, overrides the default
/// %LOCALAPPDATA%\TeamsISO path that holds the cooldown stamp +
/// Test-only seam — when set, overrides the default
/// %LOCALAPPDATA%\Dragon-ISO path that holds the cooldown stamp +
/// the opt-out flag. Tests use this to write to a tempdir so
/// CheckIfDueAsync's throttle path can be exercised without
/// hitting real disk paths or the real network (the throttle
@ -177,7 +177,7 @@ public static class UpdateChecker
private static string StateDirectory => StateDirectoryOverride ??
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO");
"Dragon-ISO");
private static string CooldownPath =>
Path.Combine(StateDirectory, "last-update-check.txt");
@ -229,7 +229,7 @@ public static class UpdateChecker
/// <summary>
/// Parse a "vX.Y.Z" or "X.Y.Z(.N)" string into a Version. Strips any
/// pre-release suffix ("-alpha", "-beta") so the comparison is on
/// numeric components only pre-release vs. release ordering is a
/// numeric components only — pre-release vs. release ordering is a
/// follow-up if we need it. Internal so tests can pin parsing
/// behaviour without HTTP.
/// </summary>

View file

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

View file

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

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:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Teams (embedded)"
Icon="/Assets/teamsiso.ico"
Icon="/Assets/DragonISO.ico"
Width="1280" Height="720"
MinWidth="640" MinHeight="360"
Background="Black"

View file

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

View file

@ -58,6 +58,6 @@
of the resource dictionary.
-->
<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"/>
</ResourceDictionary>

View file

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

View file

@ -4,7 +4,7 @@
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
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
{

View file

@ -1,18 +1,18 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Threading;
using TeamsISO.App.Services;
using DragonISO.App.Services;
namespace TeamsISO.App.ViewModels;
namespace DragonISO.App.ViewModels;
/// <summary>
/// View-model for the v2 Ctrl+K command palette. Owns the static list of
/// commands the operator can invoke, plus a free-text filter that whittles
/// the visible list down.
///
/// The palette is the v2 redesign's navigation surface it replaces the
/// The palette is the v2 redesign's navigation surface — it replaces the
/// v1 rail's launch / hide / settings buttons (still discoverable in the
/// 32px header) AND the buried-in-tabs operator actions like "Apply
/// transcoder topology" or "Stop all ISOs" that previously needed
@ -54,7 +54,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
/// <summary>Filtered command list, bound to the palette ListBox.</summary>
public ObservableCollection<PaletteCommand> Visible { get; }
/// <summary>Currently-highlighted command. Driven by ↑/↓ in the search box and by mouse hover.</summary>
/// <summary>Currently-highlighted command. Driven by ↑/↓ in the search box and by mouse hover.</summary>
public PaletteCommand? Selected
{
get => _selected;
@ -64,7 +64,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
/// <summary>
/// Move the selection up or down within the visible list, wrapping at the
/// edges. Called from the palette's PreviewKeyDown when the operator
/// presses ↑ / ↓ while focus is in the search box.
/// presses ↑ / ↓ while focus is in the search box.
/// </summary>
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.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.
if (!string.IsNullOrEmpty(c.Keywords) &&
c.Keywords.Contains(query, StringComparison.OrdinalIgnoreCase))
@ -133,7 +133,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
var vm = _main;
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",
() => InvokeIfReady(vm.EnableAllOnlineCommand)),
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",
() => InvokeIfReady(vm.RefreshDiscoveryCommand)),
// ─── TEAMS ─── direct UIA orchestration
// ─── TEAMS ─── direct UIA orchestration
new("Teams", "Mute / unmute", "microphone audio silence toggle", null,
() => InvokeIfReady(vm.ToggleMuteCommand)),
new("Teams", "Toggle camera", "video webcam on off", null,
@ -154,7 +154,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
() => RunOnUi(() =>
{
if (!TeamsLauncher.IsRunning() && TeamsLauncher.TryLaunch(out _))
vm.Toast.Show("Launching Microsoft Teams");
vm.Toast.Show("Launching Microsoft Teams…");
else
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");
})),
// ─── NETWORK ───
new("Network", "Apply transcoder topology", "ndi groups isolate teamsiso-input private", null,
// ─── NETWORK ───
new("Network", "Apply transcoder topology", "ndi groups isolate Dragon-ISO-input private", null,
() => InvokeIfReady(vm.Settings.ApplyTranscoderTopologyCommand)),
// ─── APP ───
// ─── APP ───
new("App", "Theme: dark", "appearance night mode", null,
() => RunOnUi(() => ThemeManager.Current.Set("Dark"))),
new("App", "Theme: light", "appearance day mode bright", null,
@ -199,7 +199,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
/// <summary>
/// One command in the Ctrl+K palette. <see cref="Keywords"/> is an optional
/// space of additional search terms the operator might type "ndi" or
/// space of additional search terms — the operator might type "ndi" or
/// "private" and still match "Apply transcoder topology".
/// </summary>
public sealed record PaletteCommand(

View file

@ -1,10 +1,10 @@
using System.IO;
using System.IO;
using System.Windows;
using TeamsISO.App.Services;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
using DragonISO.App.Services;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Domain;
namespace TeamsISO.App.ViewModels;
namespace DragonISO.App.ViewModels;
/// <summary>
/// Bindings for the global settings panel: framerate, resolution, aspect, audio,
@ -29,13 +29,13 @@ public sealed class GlobalSettingsViewModel : ObservableObject
private bool _oscBridgeEnabled;
private int _oscBridgePort = OscBridge.DefaultPort;
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 bool _minimizeToTray;
private bool _controlSurfaceLanReachable;
private bool _launchTeamsOnStartup;
private bool _autoHideTeamsWindows;
// _autoRecordOnCall removed recording surface axed.
// _autoRecordOnCall removed — recording surface axed.
private bool _embedTeamsWindow;
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
// 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).
var uiPrefs = UIPreferences.Load();
_hideLocalSelf = uiPrefs.HideLocalSelf;
@ -64,14 +64,14 @@ public sealed class GlobalSettingsViewModel : ObservableObject
_controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable;
_launchTeamsOnStartup = uiPrefs.LaunchTeamsOnStartup;
_autoHideTeamsWindows = uiPrefs.AutoHideTeamsWindows;
// AutoRecordOnCall removed recording surface axed.
// AutoRecordOnCall removed — recording surface axed.
_embedTeamsWindow = uiPrefs.EmbedTeamsWindow;
_controlSurfaceEnabled = uiPrefs.ControlSurfaceEnabled;
// Bring the auto-apply flag in from the presets store so the checkbox
// reflects the user's prior choice when the settings panel opens.
try { _autoApplyLastPreset = OperatorPresetStore.GetStartupPreference().AutoApplyOnStartup; }
catch { /* best-effort disk read failures shouldn't block UI startup */ }
catch { /* best-effort — disk read failures shouldn't block UI startup */ }
// 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
// opens it. Operators previewing how the control panel looks on
// their phone / tablet / second monitor would otherwise have to
// copy-paste the URL this is a one-click preview.
// copy-paste the URL — this is a one-click preview.
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
@ -114,7 +114,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary>
/// Open the embedded HTML control panel (the <c>/ui</c> endpoint) in the
/// default browser. Enabled regardless of whether the control surface is
/// running if it isn't, the browser will show a connection error, which
/// running — if it isn't, the browser will show a connection error, which
/// is informative; operators learn the surface needs to be enabled first.
/// </summary>
public RelayCommand OpenControlSurfaceCommand { get; }
@ -122,9 +122,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
private void ResetOutputDefaults()
{
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.",
"TeamsISO — Reset output defaults",
"Dragon-ISO — Reset output defaults",
MessageBoxButton.YesNo,
MessageBoxImage.Question,
MessageBoxResult.No);
@ -135,7 +135,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
Resolution = defaults.Resolution;
Aspect = defaults.Aspect;
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>();
@ -148,15 +148,15 @@ public sealed class GlobalSettingsViewModel : ObservableObject
public AspectMode Aspect { get => _aspect; set => SetField(ref _aspect, value); }
public AudioMode Audio { get => _audio; set => SetField(ref _audio, value); }
/// <summary>NDI discovery group(s) comma-separated. Empty = default (Public).</summary>
/// <summary>NDI discovery group(s) — comma-separated. Empty = default (Public).</summary>
public string DiscoveryGroups { get => _discoveryGroups; set => SetField(ref _discoveryGroups, value); }
/// <summary>NDI output group(s) comma-separated. Empty = default (Public).</summary>
/// <summary>NDI output group(s) — comma-separated. Empty = default (Public).</summary>
public string OutputGroups { get => _outputGroups; set => SetField(ref _outputGroups, value); }
/// <summary>
/// Hide the user's own self-preview ("(Local)") from the participants list.
/// On by default operators rarely want to ISO-route their own preview.
/// On by default — operators rarely want to ISO-route their own preview.
/// Read by <see cref="MainViewModel"/> when filtering the list it presents.
/// Persisted to <c>ui-prefs.json</c> via <see cref="UIPreferences"/>.
/// </summary>
@ -172,7 +172,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary>
/// When a participant leaves the meeting (their NDI source disappears),
/// automatically tear down their ISO pipeline. Off by default so transient
/// drops don't lose the operator's routing but useful for clean
/// drops don't lose the operator's routing — but useful for clean
/// show-end behavior. Read by MainViewModel when reconciling departures.
/// Persisted to <c>ui-prefs.json</c>.
/// </summary>
@ -193,7 +193,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary>
/// How the participants DataGrid is sorted. Persisted across launches via
/// <see cref="UIPreferences"/>. Reaches into <see cref="MainViewModel.SetSortMode"/>
/// on Application.Current to actually apply the sort to the live view
/// on Application.Current to actually apply the sort to the live view —
/// the settings VM doesn't directly know about the main VM but App holds
/// both and exposes the main window via its DataContext.
/// </summary>
@ -215,7 +215,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// Minimize-to-tray behavior. When on, minimizing the main window hides
/// it from the taskbar and shows a tray icon (double-click to restore).
/// Right-click menu on the tray icon offers "Show", "Stop all ISOs", "Exit".
/// Useful for long unattended shows where the operator wants TeamsISO
/// Useful for long unattended shows where the operator wants Dragon-ISO
/// running but invisible.
/// </summary>
public bool MinimizeToTray
@ -231,20 +231,20 @@ public sealed class GlobalSettingsViewModel : ObservableObject
var tray = (Application.Current as App)?.TrayIcon;
if (tray is not null) tray.Enabled = value;
_toast?.Show(value
? "Minimize-to-tray enabled minimizing now hides the window"
? "Minimize-to-tray enabled — minimizing now hides the window"
: "Minimize-to-tray disabled");
}
}
/// <summary>
/// Snapshot the current persistable UI state to disk. Called from any
/// <see cref="UIPreferences.Prefs"/>-backed setter. Best-effort disk
/// <see cref="UIPreferences.Prefs"/>-backed setter. Best-effort — disk
/// failures don't surface to the operator (the in-memory state still
/// reflects their click for this session).
/// </summary>
private void PersistUiPrefs()
{
// Theme isn't owned by this VM read whatever ThemeManager has
// Theme isn't owned by this VM — read whatever ThemeManager has
// persisted (or default) so we don't clobber it on save.
var existing = UIPreferences.Load();
UIPreferences.Save(new UIPreferences.Prefs(
@ -261,9 +261,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
}
/// <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
/// "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
/// + IN-CALL bar.
/// </summary>
@ -296,9 +296,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
// removed alongside the rest of the recording surface.
/// <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
/// 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
/// applies / restores the embed; this property is just the persisted
/// toggle.
@ -313,7 +313,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
}
/// <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"/>:
/// loopback (127.0.0.1) by default, or all-interfaces when LAN-reachable.
/// </summary>
@ -354,9 +354,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary>
/// LAN-reachable mode. When false (default), control surface binds to
/// 127.0.0.1 only this machine. When true, binds to all interfaces so
/// 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
/// 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
/// one-time URL ACL reservation for non-loopback prefixes:
@ -404,10 +404,10 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary>
/// Best-effort routable IPv4 address suitable for showing the operator a
/// "paste me into the thin client" URL. Skips:
/// loopback interfaces (127.x)
/// • tunnel/virtual interfaces (NetworkInterfaceType.Tunnel — e.g. WSL,
/// • loopback interfaces (127.x)
/// • tunnel/virtual interfaces (NetworkInterfaceType.Tunnel — e.g. WSL,
/// Hyper-V, Tailscale, OpenVPN-style virtuals)
/// • APIPA/link-local addresses (169.254.x — assigned when DHCP fails;
/// • APIPA/link-local addresses (169.254.x — assigned when DHCP fails;
/// a host with one of these AND a real DHCP lease should pick the lease)
/// Prefers Ethernet/Wi-Fi over everything else, then falls back to the
/// first non-link-local non-loopback IPv4. Returns null only if no
@ -448,7 +448,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
}
/// <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;
/// bound to 127.0.0.1 only.
/// </summary>
@ -488,7 +488,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// Output-name template applied when the operator enables an ISO without
/// a per-participant CustomName. Default <c>"{name}"</c> renders the
/// speaker's display name directly (changed from the legacy
/// <c>"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
/// guid-based template if you need stable IDs that survive participant
/// name changes. See <see cref="OutputNameTemplate"/> for the supported
@ -501,7 +501,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
{
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;
_toast?.Show(value
? "Update checks enabled runs once per 24h on launch"
? "Update checks enabled — runs once per 24h on launch"
: "Update checks disabled");
}
}
@ -530,7 +530,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary>
/// On launch, automatically re-apply the most recently applied operator preset.
/// Closes the loop on the recurring-show workflow: the operator clicks Apply
/// once, and from that point on 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.
/// Persisted to <c>presets.json</c>'s <c>AutoApplyOnStartup</c> field via
/// <see cref="OperatorPresetStore"/>.
@ -559,15 +559,15 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// <summary>
/// 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
/// preferences resets) and doesn't touch Display toggles. Confirms first.
/// </summary>
public RelayCommand ResetOutputDefaultsCommand { get; }
/// <summary>
/// One-click "set up the transcoder topology" writes ndi-config.v1.json so all
/// local senders broadcast on a private group ("teamsiso-input") while local
/// One-click "set up the transcoder topology" — writes ndi-config.v1.json so all
/// local senders broadcast on a private group ("Dragon-ISO-input") while local
/// receivers can see both that and "public", then sets the engine's discovery and
/// output groups to align (engine receives from the private group, emits on Public).
/// User has to restart Teams for the new ndi-config.v1.json to take effect there.
@ -596,7 +596,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
{
MessageBox.Show(
$"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,
MessageBoxImage.Warning);
return;
@ -617,16 +617,16 @@ public sealed class GlobalSettingsViewModel : ObservableObject
: $"A backup of your prior NDI config was saved to:\n{result.BackupPath}";
MessageBox.Show(
"Transcoder topology applied. \n\n" +
"• Local senders (Teams, etc.) will broadcast on group 'teamsiso-input'.\n" +
"• Local receivers will see both 'public' and 'teamsiso-input'.\n" +
"• TeamsISO will discover from 'teamsiso-input' and re-emit on 'public'.\n\n" +
"Transcoder topology applied. ✓\n\n" +
"• Local senders (Teams, etc.) will broadcast on group 'Dragon-ISO-input'.\n" +
"• Local receivers will see both 'public' and 'Dragon-ISO-input'.\n" +
"• Dragon-ISO will discover from 'Dragon-ISO-input' and re-emit on 'public'.\n\n" +
"RESTART Microsoft Teams for the new NDI config to take effect there.\n\n" +
backupNote,
"TeamsISO — Apply transcoder topology",
"Dragon-ISO — Apply transcoder topology",
MessageBoxButton.OK,
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.Tasks;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Domain;
namespace TeamsISO.App.ViewModels;
namespace DragonISO.App.ViewModels;
/// <summary>
/// View-model for the per-participant <see cref="FrameProcessingSettings"/> override
@ -37,7 +37,7 @@ public sealed class IsoOverrideDialogViewModel : ObservableObject
/// <summary>
/// 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.
/// </summary>
public GlobalSettingsViewModel Settings { get; }
@ -63,7 +63,7 @@ public sealed class IsoOverrideDialogViewModel : ObservableObject
DisplayName = displayName;
// Initialize the four enum values from the existing override (if any)
// or fall back to the global settings the dialog should always open
// or fall back to the global settings — the dialog should always open
// with values that already reflect what this pipeline is using.
var source = currentOverride ?? new FrameProcessingSettings(
settings.Framerate,
@ -107,8 +107,8 @@ public sealed class IsoOverrideDialogViewModel : ObservableObject
}
/// <summary>
/// Convenience for XAML visibility binding true when we should show the
/// "Following global settings · Reset to global" affordance.
/// Convenience for XAML visibility binding — true when we should show the
/// "Following global settings · Reset to global" affordance.
/// </summary>
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.
// Split out of MainViewModel.cs so the main file isn't dominated by
// long async iteration loops.
@ -33,7 +33,7 @@ public sealed partial class MainViewModel
p.Id,
p.DisplayName)
: 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.
await _controller.EnableIsoAsync(p.Id, resolvedName, CancellationToken.None);
p.IsEnabled = true;
@ -41,7 +41,7 @@ public sealed partial class MainViewModel
}
catch
{
// Per-participant best-effort one bad source shouldn't
// Per-participant best-effort — one bad source shouldn't
// abort the bulk operation.
}
}
@ -66,7 +66,7 @@ public sealed partial class MainViewModel
}
var confirm = System.Windows.MessageBox.Show(
$"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.MessageBoxImage.Warning,
System.Windows.MessageBoxResult.No);
@ -84,7 +84,7 @@ public sealed partial class MainViewModel
/// <summary>
/// Save a PNG of every currently-enabled participant's latest
/// 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
/// archives, recapping who showed up, etc.
/// </summary>
@ -99,7 +99,7 @@ public sealed partial class MainViewModel
var rootDir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
"TeamsISO",
"Dragon-ISO",
$"snapshots-{DateTimeOffset.Now:yyyyMMdd_HHmmss}");
try
@ -143,7 +143,7 @@ public sealed partial class MainViewModel
}
Toast.Show(failed > 0
? $"Saved {saved} snapshot(s) ({failed} failed) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}"
: $"Saved {saved} snapshot(s) 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\\Dragon-ISO\\{System.IO.Path.GetFileName(rootDir)}");
}
}

View file

@ -1,23 +1,23 @@
using System.Windows.Threading;
using TeamsISO.App.Services;
using System.Windows.Threading;
using DragonISO.App.Services;
namespace TeamsISO.App.ViewModels;
namespace DragonISO.App.ViewModels;
// Auto-apply-last-preset path. Split out of MainViewModel.cs so the
// pending-preset bookkeeping doesn't clutter the main file.
//
// Lifecycle:
// InitializeAsync (in main file) reads operator preference + last-applied
// • InitializeAsync (in main file) reads operator preference + last-applied
// name from OperatorPresetStore and sets _pendingPresetName + deadline.
// OnParticipantsChanged (in main file) calls TryAutoApplyPendingPreset
// • OnParticipantsChanged (in main file) calls TryAutoApplyPendingPreset
// once participants populate.
// RequestApplyPresetOnStartup is the CLI hook: `--apply-preset NAME`.
// • RequestApplyPresetOnStartup is the CLI hook: `--apply-preset NAME`.
public sealed partial class MainViewModel
{
// Set on InitializeAsync from disk; cleared once we successfully apply
// (so we don't re-apply when the participant list later mutates). The
// grace deadline gives Teams enough time to publish all initial sources
// after engine start before we attempt the apply applying before
// after engine start before we attempt the apply — applying before
// everyone's visible would partially-restore the routing and silently
// drop assignments for late-appearing participants.
private string? _pendingPresetName;
@ -41,7 +41,7 @@ public sealed partial class MainViewModel
/// <summary>
/// Reads the operator's auto-apply preference + last-applied preset name
/// from disk and seeds the pending-preset state. Called by InitializeAsync
/// during engine startup. Failures are swallowed a preset read fault
/// during engine startup. Failures are swallowed — a preset read fault
/// should never block the engine from coming up.
/// </summary>
private void LoadPendingPresetFromPreferences()
@ -53,7 +53,7 @@ public sealed partial class MainViewModel
{
_pendingPresetName = pref.LastAppliedName;
// 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.
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
}
@ -64,7 +64,7 @@ public sealed partial class MainViewModel
/// <summary>
/// Attempts to apply <c>_pendingPresetName</c> if either every preset
/// assignment matches a live participant, or the grace deadline has
/// passed. Idempotent repeat calls without state change are no-ops;
/// passed. Idempotent — repeat calls without state change are no-ops;
/// once we fire we flag <c>_pendingPresetApplied</c> so subsequent
/// participant churn doesn't trigger a second apply. Failures (missing
/// preset on disk, preset that no longer matches anyone) are swallowed:
@ -102,7 +102,7 @@ public sealed partial class MainViewModel
var result = await PresetApplier.ApplyAsync(
captured, snapshot, _controller, _dispatcher);
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 TeamsISO.App.Services;
using System.Windows.Threading;
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
// constructor wiring + reactive subscriptions. The four command
// 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.");
break;
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;
case TeamsControlBridge.InvokeResult.InvokeFailed:
Toast.Warn($"{label} button found but disabled.");
@ -48,7 +48,7 @@ public sealed partial class MainViewModel
if (string.IsNullOrEmpty(url)) return;
if (TeamsLauncher.TryJoinMeeting(url, out var error))
{
Toast.Show("Joining Teams meeting");
Toast.Show("Joining Teams meeting…");
JoinMeetingUrl = string.Empty;
if (Settings.AutoHideTeamsWindows)
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
@ -79,13 +79,13 @@ public sealed partial class MainViewModel
if (idx > 0) { t = t.Substring(0, idx).Trim(); break; }
}
if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty;
if (t.Length > 50) t = t.Substring(0, 47) + "";
if (t.Length > 50) t = t.Substring(0, 47) + "…";
return t;
}
/// <summary>
/// Meeting-state probe runs on every 1Hz stats tick. We fire the UIA
/// traversal on a worker thread because it can take 50200ms in a busy
/// Meeting-state probe — runs on every 1Hz stats tick. We fire the UIA
/// traversal on a worker thread because it can take 50–200ms in a busy
/// call; the result is marshalled back to the dispatcher to update the
/// view-model properties. One-tick latency on the displayed state is
/// preferable to a UI hiccup.
@ -116,7 +116,7 @@ public sealed partial class MainViewModel
{
IsTeamsInCall = inCall;
TeamsMeetingState = inCall
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
: "READY";
IsLocalMuted = inCall && (snap.IsMuted ?? 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 { /* 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.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Windows.Data;
using System.Windows.Threading;
using TeamsISO.App.Services;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
using DragonISO.App.Services;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Domain;
namespace TeamsISO.App.ViewModels;
namespace DragonISO.App.ViewModels;
/// <summary>
/// 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.
///
/// Split across partial files by responsibility:
/// • <c>MainViewModel.cs</c> — fields, properties, constructor (wires commands), OnStatsTick, Dispose
/// • <c>MainViewModel.TeamsCommands.cs</c> — Mute / Cam / Leave / Share + Join URL + Teams meeting-state poll
/// • <c>MainViewModel.PresetCommands.cs</c> — auto-apply-last-preset path
/// • <c>MainViewModel.BulkCommands.cs</c> — Stop all / Enable all / Snapshot all
/// • <c>MainViewModel.cs</c> — fields, properties, constructor (wires commands), OnStatsTick, Dispose
/// • <c>MainViewModel.TeamsCommands.cs</c> — Mute / Cam / Leave / Share + Join URL + Teams meeting-state poll
/// • <c>MainViewModel.PresetCommands.cs</c> — auto-apply-last-preset path
/// • <c>MainViewModel.BulkCommands.cs</c> — Stop all / Enable all / Snapshot all
/// </summary>
public sealed partial class MainViewModel : ObservableObject, IDisposable
{
@ -29,11 +29,11 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
private readonly IDisposable _alertsSub;
private readonly DispatcherTimer _statsTimer;
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
private string _statusText = "Starting";
private string _statusText = "Starting…";
/// <summary>
/// Wall-clock at which <see cref="InitializeAsync"/> kicked the engine. Used to
/// gate the "Scanning for NDI sources" placeholder so it shows for a few
/// gate the "Scanning for NDI sources…" placeholder so it shows for a few
/// seconds after launch even when ParticipantCount == 0 (the bleak
/// "no ndi sources yet" empty state was being shown immediately and
/// operators assumed the app was broken before discovery had a chance to fire).
@ -41,7 +41,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
/// </summary>
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);
// _pendingPresetName / Deadline / Applied + the auto-apply path
@ -148,30 +148,30 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
/// <summary>
/// Force NDI discovery to rebuild its finder. Surfaced as a small "Refresh" pill
/// next to the participants header useful right after Apply Transcoder Topology
/// next to the participants header — useful right after Apply Transcoder Topology
/// or when Teams restarts mid-session and stale TTLs are masking new sources.
/// </summary>
public RelayCommand RefreshDiscoveryCommand { get; }
// ════════════════════════════════════════════════════════════════════════
// Phase E.3 In-call controls. Each command drives a UIAutomation lookup
// ════════════════════════════════════════════════════════════════════════
// Phase E.3 — In-call controls. Each command drives a UIAutomation lookup
// against Teams' window tree and reports a toast on outcome. Best-effort:
// a control-not-found result toasts a hint rather than throwing, since
// Teams isn't always in a call (the buttons only appear in-call).
// ════════════════════════════════════════════════════════════════════════
// ════════════════════════════════════════════════════════════════════════
public RelayCommand ToggleMuteCommand { get; }
public RelayCommand ToggleCameraCommand { get; }
public RelayCommand LeaveCallCommand { get; }
public RelayCommand OpenShareTrayCommand { get; }
// Recording-marker and roll-recording commands removed recording feature axed.
// 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; }
/// <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.
/// The v2 header surfaces this as a click affordance too; the
/// 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; }
/// <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);
/// this command delegates through an Action callback the view sets
/// after construction so the VM stays unaware of WPF Window types.
@ -189,7 +189,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
/// <summary>
/// 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.
/// </summary>
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>
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; }
/// <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.
/// <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"/>.
/// </summary>
public int ParticipantCount
@ -249,9 +249,9 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
/// <summary>
/// True for the first <see cref="DiscoveryGracePeriod"/> after engine start.
/// The XAML uses this to swap the empty-state placeholder from the bleak
/// "no ndi sources yet open teams and start a meeting" copy (which reads
/// "no ndi sources yet — open teams and start a meeting" copy (which reads
/// as broken to operators who just launched into an active meeting) to a
/// neutral "Scanning for NDI sources" status while NDI Find resolves
/// neutral "Scanning for NDI sources…" status while NDI Find resolves
/// mDNS responses. Always false once participants populate.
/// </summary>
public bool IsDiscovering
@ -262,7 +262,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
private bool _isDiscovering;
/// <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
/// the operator's eye to active state.
/// </summary>
@ -376,7 +376,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
// Subscribe directly (no ObserveOn) and marshal to the UI thread inside
// the callback via Dispatcher.InvokeAsync. The previous ObserveOn(
// SynchronizationContextScheduler) path captured SynchronizationContext
// .Current at subscribe time fragile in WPF startup ordering, where
// .Current at subscribe time — fragile in WPF startup ordering, where
// the UI thread's SyncContext can be in a transitional state during
// App.OnStartup and the captured context never pumps subsequent
// OnNext calls. Direct subscribe + explicit dispatcher marshal is the
@ -394,7 +394,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
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
// fields on the engine side) and runs on the UI dispatcher so SetField is safe.
_statsTimer = new DispatcherTimer(DispatcherPriority.Background, _dispatcher)
@ -410,7 +410,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
RefreshDiscoveryCommand = new RelayCommand(() =>
{
_controller.RefreshDiscovery();
Toast.Show("Refreshing NDI discovery");
Toast.Show("Refreshing NDI discovery…");
});
ToggleThemeCommand = new RelayCommand(() =>
@ -424,7 +424,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
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.
// Owner is set so the dialog centers and inherits z-order.
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:
// MainViewModel.BulkCommands.cs EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll
// MainViewModel.TeamsCommands.cs MakeTeamsCommand, JoinPastedMeeting,
// MainViewModel.BulkCommands.cs — EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll
// MainViewModel.TeamsCommands.cs — MakeTeamsCommand, JoinPastedMeeting,
// ExtractMeetingTitle, PollTeamsMeetingState
// MainViewModel.PresetCommands.cs RequestApplyPresetOnStartup,
// MainViewModel.PresetCommands.cs — RequestApplyPresetOnStartup,
// LoadPendingPresetFromPreferences,
// TryAutoApplyPendingPreset
@ -533,16 +533,16 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
// If sort mode is LoudestFirst, refresh the view so the new audio
// peaks re-evaluate the sort. Skipped for the other sort modes
// since their keys (name, online state) don't change every tick
// since their keys (name, online state) don't change every tick —
// no need to pay the Refresh cost.
if (_currentSortMode == Services.UIPreferences.SortMode.LoudestFirst)
{
try { ParticipantsView.Refresh(); }
catch { /* defensive Refresh occasionally throws on collection mutations */ }
catch { /* defensive — Refresh occasionally throws on collection mutations */ }
}
// Update footer badges. Recording count is "ISOs that have a recorder
// attached" _controller.RecordingEnabled tells us the global toggle,
// attached" — _controller.RecordingEnabled tells us the global toggle,
// but the actual recorder count = number of running pipelines while
// that toggle was on (transient enables can mean fewer recorders than
// running pipelines). Approximate by ANDing global toggle + running
@ -553,12 +553,12 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
// of the recording surface.
// Expose counts as VM properties for the v2 transport-strip binding.
// The strip's "PART 4 · LIVE 2" reads these — pushing them on the
// The strip's "PART 4 · LIVE 2" reads these — pushing them on the
// 1Hz tick keeps the cost off the per-frame UI path.
ParticipantCount = totalParticipants;
LiveCount = enabledCount;
// IsDiscovering gates the "Scanning for NDI sources" placeholder.
// IsDiscovering gates the "Scanning for NDI sources…" placeholder.
// True for DiscoveryGracePeriod after engine start AS LONG AS we
// haven't seen any participants yet; once anything arrives we drop
// out of the discovering state immediately (back to the OK path).
@ -571,7 +571,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
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
// timer rather than resuming, which is the operator's mental model:
// "the show started when the first feed went live."
@ -591,13 +591,13 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
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
// set it in OUTPUT settings; surfacing it constantly steals footer
// real estate from more-actionable info).
if (totalParticipants == 0)
{
StatusText = "Discovering NDI sources";
StatusText = "Discovering NDI sources…";
}
else if (enabledCount == 0)
{
@ -610,18 +610,18 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
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
// MainViewModel.TeamsCommands.cs.
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 rest = app?.ControlSurface?.IsRunning ?? false;
var osc = app?.OscBridge?.IsRunning ?? false;
IsControlSurfaceRunning = rest || osc;
// When LAN-reachable mode is on, the footer text shows the routable
// URL instead of just the port operators setting up a thin client
// URL instead of just the port — operators setting up a thin client
// shouldn't have to open Settings to find what to type into a
// browser. We trust the Settings VM's ControlSurfaceLanReachable
// boolean since that's where the toggle is persisted.
@ -640,14 +640,14 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
public async Task InitializeAsync(CancellationToken cancellationToken)
{
StatusText = "Discovering NDI sources";
StatusText = "Discovering NDI sources…";
_engineStartedAt = DateTimeOffset.UtcNow;
IsDiscovering = true;
await _controller.StartAsync(cancellationToken);
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
// Auto-apply last preset bookkeeping. We don't apply here
// participants haven't been discovered yet instead we record
// Auto-apply last preset bookkeeping. We don't apply here —
// participants haven't been discovered yet — instead we record
// the intent and let OnParticipantsChanged trigger the apply
// once the meeting has populated. Implementation in
// MainViewModel.PresetCommands.cs.
@ -662,7 +662,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
foreach (var p in incoming)
{
// The new Teams client emits a "(Local)" pseudo-participant for the user's
// own preview operators rarely want it as a routable ISO. Suppress when
// own preview — operators rarely want it as a routable ISO. Suppress when
// HideLocalSelf is on (default).
if (hideLocal && IsLocalSelf(p)) continue;
@ -672,9 +672,9 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
var wasOnline = vm.IsOnline;
vm.Update(p);
// Departure: source went from non-null to null. Always toast so the
// operator notices, even when AutoDisableOnDeparture is off the
// operator notices, even when AutoDisableOnDeparture is off — the
// 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 (autoDisable)
@ -695,7 +695,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
{
// ISO stays running on a slate frame; warn the operator so
// they can decide whether to disable manually.
Toast.Warn($"{vm.DisplayName} disconnected ISO still running on slate");
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
// 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
// (give up waiting and apply with whoever's online).
if (_pendingPresetName is not null && !_pendingPresetApplied)

View file

@ -1,7 +1,7 @@
using System.ComponentModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace TeamsISO.App.ViewModels;
namespace DragonISO.App.ViewModels;
/// <summary>
/// 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.Imaging;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
using TeamsISO.Engine.Pipeline;
using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Domain;
using DragonISO.Engine.Pipeline;
using IsoHealthStats = DragonISO.Engine.Domain.IsoHealthStats;
namespace TeamsISO.App.ViewModels;
namespace DragonISO.App.ViewModels;
/// <summary>
/// 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;
/// <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
/// thread, called from MainViewModel's stats tick. Null until the first frame arrives.
/// </summary>
@ -46,7 +46,7 @@ public sealed class ParticipantViewModel : ObservableObject
/// <summary>
/// True when this participant is currently the loudest among the live
/// set set by MainViewModel at the 1Hz stats tick. Bound to a cyan
/// set — set by MainViewModel at the 1Hz stats tick. Bound to a cyan
/// border accent on the DataGrid row so operators can spot who's
/// speaking without watching every VU bar individually.
/// </summary>
@ -81,7 +81,7 @@ public sealed class ParticipantViewModel : ObservableObject
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
// closes cleanly when the host exits.
var preview = new PreviewWindow(_controller, Id, DisplayName);
@ -96,8 +96,8 @@ public sealed class ParticipantViewModel : ObservableObject
/// <summary>
/// Encode the most recent <see cref="IsoPipeline.LatestProcessedFrame"/>
/// as a PNG and write it to <c>%USERPROFILE%\Pictures\TeamsISO\</c>. Used
/// by the participants' context menu for grabbing a stillframe useful
/// as a PNG and write it to <c>%USERPROFILE%\Pictures\Dragon-ISO\</c>. Used
/// by the participants' context menu for grabbing a stillframe — useful
/// for highlight reels, social posts, bug reports. Best-effort: a no-op
/// + warn-toast if no frame is currently available (pipeline just spun
/// up, or recording isn't enabled). Filename includes participant name
@ -110,13 +110,13 @@ public sealed class ParticipantViewModel : ObservableObject
var frame = _controller.GetLatestProcessedFrame(Id);
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;
}
var dir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
"TeamsISO");
"Dragon-ISO");
System.IO.Directory.CreateDirectory(dir);
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)
{
// 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
// Thumbnail is only set to null when the pipeline genuinely stops, which
// 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
// 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.
var expectedBytes = frame.Width * frame.Height * 4;
if (frame.Width <= 0 || frame.Height <= 0 || frame.Pixels.Length < expectedBytes)
@ -239,14 +239,14 @@ public sealed class ParticipantViewModel : ObservableObject
}
/// <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"/>
/// because it allocates a managed buffer per scale; here we want to write
/// directly into the WriteableBitmap's pinned native memory to avoid a copy.
///
/// The arithmetic is the same: for each destination pixel, compute the source
/// pixel via integer ratio and copy 4 bytes. At 1Hz × 10 thumbnails that's
/// ~144,000 pixel reads per second negligible CPU.
/// pixel via integer ratio and copy 4 bytes. At 1Hz × 10 thumbnails that's
/// ~144,000 pixel reads per second — negligible CPU.
/// </summary>
private static void ScaleNearestNeighborBgra(
ReadOnlySpan<byte> src, int srcW, int srcH,
@ -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
// 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];
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 _framesOut;
private long _framesDropped;
private string _incomingResolution = "";
private string _incomingFps = "";
private string _incomingResolution = "—";
private string _incomingFps = "—";
/// <summary>Number of frames the receiver has captured so far.</summary>
public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }
@ -321,7 +321,7 @@ public sealed class ParticipantViewModel : ObservableObject
/// <summary>Frames dropped by the closest-frame strategy when the receiver outpaces the processor.</summary>
public long FramesDropped { get => _framesDropped; set => SetField(ref _framesDropped, value); }
private string _stateLabel = "";
private string _stateLabel = "—";
private string _stateColor = "Wd.Text.Tertiary";
private double _peakAudioLevel;
private double _displayedAudioLevel;
@ -347,7 +347,7 @@ public sealed class ParticipantViewModel : ObservableObject
/// </summary>
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); }
/// <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;
FramesDropped = stats.FramesDropped;
IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0
? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
: "";
? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
: "—";
IncomingFps = stats.IncomingFps > 0
? $"{stats.IncomingFps:0.0} fps"
: "";
: "—";
(StateLabel, StateColor) = stats.State switch
{
IsoState.Receiving => ("LIVE", "Wd.Status.Live"),
IsoState.Sending => ("LIVE", "Wd.Status.Live"),
IsoState.NoSignal => ("NO SIGNAL", "Wd.Status.Warn"),
IsoState.Error => ("ERROR", "Wd.Status.Error"),
IsoState.Idle => (IsEnabled ? "STARTING" : "", "Wd.Text.Tertiary"),
_ => ("", "Wd.Text.Tertiary"),
IsoState.Idle => (IsEnabled ? "STARTING" : "—", "Wd.Text.Tertiary"),
_ => ("—", "Wd.Text.Tertiary"),
};
}
@ -430,10 +430,10 @@ public sealed class ParticipantViewModel : ObservableObject
}
/// <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
/// 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
/// read-only display contexts.
/// </summary>
@ -450,11 +450,11 @@ public sealed class ParticipantViewModel : ObservableObject
/// resolve to whatever name will actually be broadcast (<see cref="OutputName"/>);
/// writes set <see cref="CustomName"/> with a couple of UX niceties:
///
/// Clearing the field (empty / whitespace) reverts to the template
/// default the user doesn't have to remember the template syntax to
/// • Clearing the field (empty / whitespace) reverts to the template
/// default — the user doesn't have to remember the template syntax to
/// "undo" a customization.
///
/// Typing a value that exactly matches the resolved default is treated
/// • Typing a value that exactly matches the resolved default is treated
/// as a no-op (CustomName stays empty), so the participant continues
/// to follow the template when their display name changes upstream.
/// Without this, typing the auto-suggested value would silently
@ -503,7 +503,7 @@ public sealed class ParticipantViewModel : ObservableObject
OnPropertyChanged(nameof(SourceFullName));
OnPropertyChanged(nameof(IsOnline));
// OutputName/EditableOutputName both derive from _participant.DisplayName
// when no per-participant CustomName is set re-notify so the Output
// when no per-participant CustomName is set — re-notify so the Output
// column tracks upstream Teams name changes for participants who
// haven't been manually renamed.
OnPropertyChanged(nameof(OutputName));
@ -525,7 +525,7 @@ public sealed class ParticipantViewModel : ObservableObject
// Resolve the output name: explicit per-participant CustomName
// wins; otherwise expand the operator's template (default is
// "{name}" since 0.9.0-rc19, with an empty-name fallback to
// 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
// DefaultOutputName path.
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
// 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.
_toast?.Warn($"{DisplayName} just left the meeting");
}
catch (Exception ex)
{
// Defensive catch-all for any other engine-side failure (port bind
// race, pipeline factory throw, etc.). Same reasoning as above
// race, pipeline factory throw, etc.). Same reasoning as above —
// an exception from an operator click should never tear down the
// dispatcher.
_toast?.Warn($"Couldn't toggle ISO for {DisplayName}: {ex.Message}");

View file

@ -1,6 +1,6 @@
using System.Windows.Input;
using System.Windows.Input;
namespace TeamsISO.App.ViewModels;
namespace DragonISO.App.ViewModels;
/// <summary>
/// Synchronous command that delegates execution to an <see cref="Action"/>.
@ -25,7 +25,7 @@ public sealed class RelayCommand : ICommand
/// <summary>
/// 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
/// CommandParameter="1" works for int T.
/// </summary>

View file

@ -1,7 +1,7 @@
using System.Windows.Input;
using System.Windows.Input;
using System.Windows.Threading;
namespace TeamsISO.App.ViewModels;
namespace DragonISO.App.ViewModels;
/// <summary>
/// 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>
/// 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);
}
/// <summary>Friendly composite "1.0.0 1.1.0" string for the banner.</summary>
public string Message => $"Update available — v{_currentVersion} → {_latestVersion}";
/// <summary>Friendly composite "1.0.0 → 1.1.0" string for the banner.</summary>
public string Message => $"Update available — v{_currentVersion} → {_latestVersion}";
public RelayCommand OpenReleasePageCommand { 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:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels"
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
xmlns:vm="clr-namespace:DragonISO.App.ViewModels"
xmlns:conv="clr-namespace:DragonISO.App.Converters"
Width="560" Height="360"
WindowStartupLocation="CenterOwner"
WindowStyle="None"

View file

@ -1,9 +1,9 @@
using System.Windows;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using TeamsISO.App.ViewModels;
using DragonISO.App.ViewModels;
namespace TeamsISO.App.Views;
namespace DragonISO.App.Views;
/// <summary>
/// The Ctrl+K command palette window. Centered over its <see cref="Window.Owner"/>,
@ -12,9 +12,9 @@ namespace TeamsISO.App.Views;
/// Keyboard contract:
/// <list type="bullet">
/// <item>Type to filter (autofocus on the TextBox at open time)</item>
/// <item>↑ / ↓ — move the selection</item>
/// <item>Enter invoke the highlighted command, then close</item>
/// <item>Esc close without invoking</item>
/// <item>↑ / ↓ — move the selection</item>
/// <item>Enter — invoke the highlighted command, then close</item>
/// <item>Esc — close without invoking</item>
/// </list>
/// </summary>
public partial class CommandPaletteWindow : Window
@ -23,7 +23,7 @@ public partial class CommandPaletteWindow : Window
{
InitializeComponent();
// 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();
// Closing on click-outside: WindowStyle=None means we don't get the
// standard "deactivated" behavior cleanly, but Deactivated still
@ -40,7 +40,7 @@ public partial class CommandPaletteWindow : Window
/// <summary>
/// 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>
protected override void OnPreviewKeyDown(System.Windows.Input.KeyEventArgs e)
{
@ -63,7 +63,7 @@ public partial class CommandPaletteWindow : Window
case Key.Enter:
e.Handled = true;
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
// operator wants to return to the main shell.
if (invoked) Close();

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