Compare commits
No commits in common. "main" and "V1.0.1" have entirely different histories.
|
|
@ -1,4 +1,4 @@
|
|||
name: CI
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -21,15 +21,15 @@ jobs:
|
|||
echo "$HOME/.dotnet" >> $GITHUB_PATH
|
||||
echo "DOTNET_ROOT=$HOME/.dotnet" >> $GITHUB_ENV
|
||||
|
||||
- name: Restore (Linux solution filter — excludes Windows-only WPF app)
|
||||
run: dotnet restore Dragon-ISO.Linux.slnf
|
||||
- name: Restore (Linux solution filter — excludes Windows-only WPF app)
|
||||
run: dotnet restore TeamsISO.Linux.slnf
|
||||
|
||||
- name: Build (Release, treat warnings as errors)
|
||||
run: dotnet build Dragon-ISO.Linux.slnf --configuration Release --no-restore
|
||||
run: dotnet build TeamsISO.Linux.slnf --configuration Release --no-restore
|
||||
|
||||
- name: Test (excluding requires=ndi)
|
||||
run: >
|
||||
dotnet test Dragon-ISO.Linux.slnf
|
||||
dotnet test TeamsISO.Linux.slnf
|
||||
--configuration Release
|
||||
--no-build
|
||||
--logger "trx;LogFileName=test-results.trx"
|
||||
|
|
@ -47,7 +47,7 @@ jobs:
|
|||
-reports:"**/coverage.cobertura.xml" \
|
||||
-targetdir:coverage-report \
|
||||
-reporttypes:"Cobertura;TextSummary" \
|
||||
-assemblyfilters:"+Dragon-ISO.Engine;-Dragon-ISO.Engine.NdiInterop"
|
||||
-assemblyfilters:"+TeamsISO.Engine;-TeamsISO.Engine.NdiInterop"
|
||||
|
||||
- name: Enforce coverage threshold (80%)
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
name: Release
|
||||
name: Release
|
||||
|
||||
# Triggered by pushing an annotated tag of the form v1.2.3 (or any v-prefixed
|
||||
# semver). The job runs on a Windows runner because building the WiX MSI
|
||||
|
|
@ -54,48 +54,48 @@ jobs:
|
|||
}
|
||||
|
||||
- name: Restore (Windows solution filter)
|
||||
run: dotnet restore Dragon-ISO.Windows.slnf
|
||||
run: dotnet restore TeamsISO.Windows.slnf
|
||||
|
||||
- name: Build (Release, treat warnings as errors)
|
||||
run: dotnet build Dragon-ISO.Windows.slnf --configuration Release --no-restore /p:Version=${{ steps.ver.outputs.version }}
|
||||
run: dotnet build TeamsISO.Windows.slnf --configuration Release --no-restore /p:Version=${{ steps.ver.outputs.version }}
|
||||
|
||||
- name: Run unit tests (excluding requires=ndi)
|
||||
run: >
|
||||
dotnet test Dragon-ISO.Windows.slnf
|
||||
dotnet test TeamsISO.Windows.slnf
|
||||
--configuration Release
|
||||
--no-build
|
||||
--filter "Category!=ndi&requires!=ndi"
|
||||
|
||||
- name: Publish Dragon-ISO.App (framework-dependent, win-x64)
|
||||
- name: Publish TeamsISO.App (framework-dependent, win-x64)
|
||||
run: >
|
||||
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj
|
||||
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj
|
||||
--configuration Release
|
||||
--runtime win-x64
|
||||
--self-contained false
|
||||
--output publish/Dragon-ISO
|
||||
--output publish/TeamsISO
|
||||
/p:Version=${{ steps.ver.outputs.version }}
|
||||
|
||||
- name: Publish Dragon-ISO.Console (framework-dependent, win-x64)
|
||||
- name: Publish TeamsISO.Console (framework-dependent, win-x64)
|
||||
run: >
|
||||
dotnet publish src/Dragon-ISO.Console/Dragon-ISO.Console.csproj
|
||||
dotnet publish src/TeamsISO.Console/TeamsISO.Console.csproj
|
||||
--configuration Release
|
||||
--runtime win-x64
|
||||
--self-contained false
|
||||
--output publish/Dragon-ISO-Console
|
||||
--output publish/TeamsISO-Console
|
||||
/p:Version=${{ steps.ver.outputs.version }}
|
||||
|
||||
# Code-sign the WPF .exe BEFORE the MSI is built, so the MSI's embedded
|
||||
# binaries are signed too. Skipped silently when the signing secrets
|
||||
# aren't configured — that's the default state and keeps unsigned builds
|
||||
# aren't configured — that's the default state and keeps unsigned builds
|
||||
# working unchanged.
|
||||
#
|
||||
# To enable signing, set both Forgejo Actions secrets:
|
||||
# SIGN_CERT_PFX_BASE64 — base64 of your code-signing PFX file
|
||||
# SIGN_CERT_PFX_BASE64 — base64 of your code-signing PFX file
|
||||
# ( certutil -encode in.pfx out.b64; strip BEGIN/END lines )
|
||||
# SIGN_CERT_PASSWORD — the PFX password
|
||||
# SIGN_CERT_PASSWORD — the PFX password
|
||||
# Optionally:
|
||||
# SIGN_TIMESTAMP_URL — RFC 3161 timestamp server (default: digicert)
|
||||
- name: Sign Dragon-ISO.exe (optional, skipped if no cert)
|
||||
# SIGN_TIMESTAMP_URL — RFC 3161 timestamp server (default: digicert)
|
||||
- name: Sign TeamsISO.exe (optional, skipped if no cert)
|
||||
if: ${{ steps.signcfg.outputs.enabled == 'true' }}
|
||||
env:
|
||||
SIGN_CERT_PFX_BASE64: ${{ secrets.SIGN_CERT_PFX_BASE64 }}
|
||||
|
|
@ -116,13 +116,13 @@ jobs:
|
|||
/fd SHA256 `
|
||||
/td SHA256 `
|
||||
/tr $tsUrl `
|
||||
'publish/Dragon-ISO/DragonISO.exe'
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool failed on Dragon-ISO.exe (exit $LASTEXITCODE)" }
|
||||
'publish/TeamsISO/TeamsISO.exe'
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool failed on TeamsISO.exe (exit $LASTEXITCODE)" }
|
||||
Remove-Item $pfxPath -Force
|
||||
|
||||
- name: Build MSI installer
|
||||
run: >
|
||||
dotnet build installer/Dragon-ISO.Installer.wixproj
|
||||
dotnet build installer/TeamsISO.Installer.wixproj
|
||||
--configuration Release
|
||||
/p:Version=${{ steps.ver.outputs.version }}
|
||||
|
||||
|
|
@ -136,7 +136,7 @@ jobs:
|
|||
"name=$($msi.Name)" >> $env:GITHUB_OUTPUT
|
||||
Write-Host "MSI: $($msi.FullName) ($($msi.Length) bytes)"
|
||||
|
||||
# Sign the produced MSI itself. Same gate as exe signing — runs only if
|
||||
# Sign the produced MSI itself. Same gate as exe signing — runs only if
|
||||
# the cert secret is set. Splitting the two stages means the inner exe
|
||||
# is signed before being embedded, AND the wrapping MSI carries its own
|
||||
# signature for SmartScreen.
|
||||
|
|
@ -155,7 +155,6 @@ jobs:
|
|||
$signtool = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter signtool.exe `
|
||||
| Where-Object { $_.FullName -match '\\x64\\' } `
|
||||
| Select-Object -First 1
|
||||
if (-not $signtool) { throw 'signtool.exe not found on runner' }
|
||||
& $signtool.FullName sign `
|
||||
/f $pfxPath `
|
||||
/p $env:SIGN_CERT_PASSWORD `
|
||||
|
|
@ -167,7 +166,7 @@ jobs:
|
|||
Remove-Item $pfxPath -Force
|
||||
|
||||
- name: Upload MSI as workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ steps.msi.outputs.name }}
|
||||
path: ${{ steps.msi.outputs.path }}
|
||||
|
|
@ -196,7 +195,7 @@ jobs:
|
|||
Write-Host "No release found for $env:TAG; creating one."
|
||||
$body = @{
|
||||
tag_name = $env:TAG
|
||||
name = "Dragon-ISO $env:TAG"
|
||||
name = "TeamsISO $env:TAG"
|
||||
body = "Automated build from tag $env:TAG."
|
||||
draft = $false
|
||||
prerelease = $env:TAG -match '-(alpha|beta|rc)'
|
||||
|
|
|
|||
6
.gitignore
vendored
|
|
@ -31,9 +31,3 @@ Thumbs.db
|
|||
|
||||
# Local Claude session metadata
|
||||
.claude/
|
||||
|
||||
# Build / test output logs
|
||||
*.log
|
||||
full-output.txt
|
||||
test-output.txt
|
||||
test-run.txt
|
||||
|
|
|
|||
40
CHANGELOG.md
|
|
@ -1,62 +1,62 @@
|
|||
# Changelog
|
||||
# Changelog
|
||||
|
||||
All notable changes to Dragon-ISO are documented here. The format follows
|
||||
All notable changes to TeamsISO are documented here. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
|
||||
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.0] — 2026-05-17
|
||||
## [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
|
||||
`Dragon-ISO-input` group while Dragon-ISO re-emits on `Public`.
|
||||
- **Self-healing finder** — if the NDI runtime stalls (zero discovered
|
||||
`teamsiso-input` group while TeamsISO 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
|
||||
`Dragon-ISO_{guid}` keeps the NDI sender uniquely identifiable while a
|
||||
`TEAMSISO_{guid}` keeps the NDI sender uniquely identifiable while a
|
||||
participant's display name resolves upstream.
|
||||
- Available tokens: `{name}`, `{guid}`, `{machine}`, `{timestamp}`.
|
||||
|
||||
### Operator presets
|
||||
|
||||
- Save current per-participant ISO assignments + custom output names to
|
||||
`%LOCALAPPDATA%\Dragon-ISO\presets.json`. Optional auto-apply on next
|
||||
`%LOCALAPPDATA%\TeamsISO\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%\Dragon-ISO\logs\`.
|
||||
- Diagnostic bundle export — zips logs + config + presets for bug reports.
|
||||
- Rolling daily Serilog logs under `%LOCALAPPDATA%\TeamsISO\logs\`.
|
||||
- Diagnostic bundle export — zips logs + config + presets for bug reports.
|
||||
- Forgejo-backed update check (manual or silent-on-launch, throttled to
|
||||
24h).
|
||||
- WiX MSI installer with proper Add/Remove Programs metadata, Start Menu
|
||||
+ Desktop shortcuts, and in-place upgrade.
|
||||
|
||||
[1.0.0]: https://forge.wilddragon.net/zgaetano/Dragon-ISO/releases/tag/v1.0.0
|
||||
[1.0.0]: https://forge.wilddragon.net/zgaetano/teamsiso/releases/tag/v1.0.0
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"solution": {
|
||||
"path": "Dragon-ISO.sln",
|
||||
"projects": [
|
||||
"src/Dragon-ISO.Engine/Dragon-ISO.Engine.csproj",
|
||||
"src/Dragon-ISO.Engine.NdiInterop/Dragon-ISO.Engine.NdiInterop.csproj",
|
||||
"src/Dragon-ISO.Console/Dragon-ISO.Console.csproj",
|
||||
"src/tests/Dragon-ISO.Engine.Tests/Dragon-ISO.Engine.Tests.csproj",
|
||||
"src/tests/Dragon-ISO.Engine.IntegrationTests/Dragon-ISO.Engine.IntegrationTests.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"solution": {
|
||||
"path": "Dragon-ISO.sln",
|
||||
"projects": [
|
||||
"src\\Dragon-ISO.Engine\\Dragon-ISO.Engine.csproj",
|
||||
"src\\Dragon-ISO.Engine.NdiInterop\\Dragon-ISO.Engine.NdiInterop.csproj",
|
||||
"src\\Dragon-ISO.Console\\Dragon-ISO.Console.csproj",
|
||||
"src\\Dragon-ISO.App\\Dragon-ISO.App.csproj",
|
||||
"src\\tests\\Dragon-ISO.Engine.Tests\\Dragon-ISO.Engine.Tests.csproj",
|
||||
"src\\tests\\Dragon-ISO.Engine.IntegrationTests\\Dragon-ISO.Engine.IntegrationTests.csproj",
|
||||
"src\\tests\\Dragon-ISO.App.Tests\\Dragon-ISO.App.Tests.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
12
TeamsISO.Linux.slnf
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"solution": {
|
||||
"path": "TeamsISO.sln",
|
||||
"projects": [
|
||||
"src/TeamsISO.Engine/TeamsISO.Engine.csproj",
|
||||
"src/TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj",
|
||||
"src/TeamsISO.Console/TeamsISO.Console.csproj",
|
||||
"src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj",
|
||||
"src/tests/TeamsISO.Engine.IntegrationTests/TeamsISO.Engine.IntegrationTests.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
14
TeamsISO.Windows.slnf
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"solution": {
|
||||
"path": "TeamsISO.sln",
|
||||
"projects": [
|
||||
"src\\TeamsISO.Engine\\TeamsISO.Engine.csproj",
|
||||
"src\\TeamsISO.Engine.NdiInterop\\TeamsISO.Engine.NdiInterop.csproj",
|
||||
"src\\TeamsISO.Console\\TeamsISO.Console.csproj",
|
||||
"src\\TeamsISO.App\\TeamsISO.App.csproj",
|
||||
"src\\tests\\TeamsISO.Engine.Tests\\TeamsISO.Engine.Tests.csproj",
|
||||
"src\\tests\\TeamsISO.Engine.IntegrationTests\\TeamsISO.Engine.IntegrationTests.csproj",
|
||||
"src\\tests\\TeamsISO.App.Tests\\TeamsISO.App.Tests.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +1,72 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{46E05E34-8A87-4986-87D3-FE0DE4E05F44}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine", "src\Dragon-ISO.Engine\Dragon-ISO.Engine.csproj", "{F0D24EAE-9225-4DC4-B3D2-6966077287A0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine.NdiInterop", "src\Dragon-ISO.Engine.NdiInterop\Dragon-ISO.Engine.NdiInterop.csproj", "{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DBDF4A1D-4215-42D5-B456-2CE7159DF848}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine.Tests", "src\tests\Dragon-ISO.Engine.Tests\Dragon-ISO.Engine.Tests.csproj", "{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.App", "src\Dragon-ISO.App\Dragon-ISO.App.csproj", "{80DCE039-3BBC-4D3F-B44B-51F324591C29}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine.IntegrationTests", "src\tests\Dragon-ISO.Engine.IntegrationTests\Dragon-ISO.Engine.IntegrationTests.csproj", "{A85E331D-026E-4BDE-B89C-0CC4C95001CE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Console", "src\Dragon-ISO.Console\Dragon-ISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.App.Tests", "src\tests\Dragon-ISO.App.Tests\Dragon-ISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
{DBDF4A1D-4215-42D5-B456-2CE7159DF848} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||
{80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{46E05E34-8A87-4986-87D3-FE0DE4E05F44}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine", "src\TeamsISO.Engine\TeamsISO.Engine.csproj", "{F0D24EAE-9225-4DC4-B3D2-6966077287A0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.NdiInterop", "src\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj", "{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DBDF4A1D-4215-42D5-B456-2CE7159DF848}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.Tests", "src\tests\TeamsISO.Engine.Tests\TeamsISO.Engine.Tests.csproj", "{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App", "src\TeamsISO.App\TeamsISO.App.csproj", "{80DCE039-3BBC-4D3F-B44B-51F324591C29}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.IntegrationTests", "src\tests\TeamsISO.Engine.IntegrationTests\TeamsISO.Engine.IntegrationTests.csproj", "{A85E331D-026E-4BDE-B89C-0CC4C95001CE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src\TeamsISO.Console\TeamsISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App.Tests", "src\tests\TeamsISO.App.Tests\TeamsISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
{DBDF4A1D-4215-42D5-B456-2CE7159DF848} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||
{80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
@ -1,29 +1,36 @@
|
|||
# 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 'Dragon-ISO.Windows.slnf')) {
|
||||
throw "Run from the Dragon-ISO repo root."
|
||||
if (-not (Test-Path 'TeamsISO.Windows.slnf')) {
|
||||
throw "Run from the TeamsISO repo root."
|
||||
}
|
||||
|
||||
$env:Path = "C:\Program Files\dotnet;$env:Path"
|
||||
|
||||
Write-Host "=== dotnet --version ===" -ForegroundColor Cyan
|
||||
dotnet --version
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Restore ===" -ForegroundColor Cyan
|
||||
dotnet restore Dragon-ISO.Windows.slnf
|
||||
dotnet restore TeamsISO.Windows.slnf
|
||||
if ($LASTEXITCODE -ne 0) { throw "Restore failed." }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Build (Release, TreatWarningsAsErrors=true) ===" -ForegroundColor Cyan
|
||||
dotnet build Dragon-ISO.Windows.slnf --configuration Release --no-restore --nologo
|
||||
dotnet build TeamsISO.Windows.slnf --configuration Release --no-restore --nologo
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Build failed. Most likely cause for this batch: System.Windows.Automation needs an explicit Reference. If so, add to src/Dragon-ISO.App/Dragon-ISO.App.csproj inside an <ItemGroup>: <Reference Include='UIAutomationClient' /> <Reference Include='UIAutomationTypes' />"
|
||||
throw "Build failed. Most likely cause for this batch: System.Windows.Automation needs an explicit Reference. If so, add to src/TeamsISO.App/TeamsISO.App.csproj inside an <ItemGroup>: <Reference Include='UIAutomationClient' /> <Reference Include='UIAutomationTypes' />"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Tests (excluding requires=ndi) ===" -ForegroundColor Cyan
|
||||
dotnet test Dragon-ISO.Windows.slnf `
|
||||
dotnet test TeamsISO.Windows.slnf `
|
||||
--configuration Release `
|
||||
--no-build `
|
||||
--nologo `
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
# Dragon-ISO Control Surface — REST API
|
||||
# TeamsISO Control Surface — REST API
|
||||
|
||||
Dragon-ISO can expose a localhost HTTP server so external controllers
|
||||
TeamsISO can expose a localhost HTTP server so external controllers
|
||||
(Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator, custom
|
||||
node-RED flows, command-line scripts) can drive it without a UI binding.
|
||||
|
||||
## Enabling
|
||||
|
||||
1. Open Dragon-ISO → Settings → DISPLAY tab.
|
||||
1. Open TeamsISO → Settings → DISPLAY tab.
|
||||
2. Tick "Control surface (Stream Deck / Companion)".
|
||||
3. Default port is **9755**; change it via the port textbox if needed.
|
||||
4. By default the server binds to `127.0.0.1` only — it is NOT reachable
|
||||
4. By default the server binds to `127.0.0.1` only — it is NOT reachable
|
||||
from the LAN.
|
||||
5. To allow other machines on the same network to drive Dragon-ISO (the
|
||||
5. To allow other machines on the same network to drive TeamsISO (the
|
||||
"headless host PC + thin client" scenario), tick the nested
|
||||
"LAN-reachable" checkbox underneath. The settings panel will display
|
||||
the LAN URL (e.g. `http://192.168.1.42:9755/ui`) with a Copy button.
|
||||
|
|
@ -31,20 +31,20 @@ netsh http add urlacl url=http://+:9755/ user=Everyone
|
|||
```
|
||||
|
||||
Also confirm the Windows Firewall is letting inbound traffic to that port
|
||||
through — `New-NetFirewallRule -DisplayName "Dragon-ISO Control Surface" -Direction Inbound -Protocol TCP -LocalPort 9755 -Action Allow`
|
||||
in an elevated PowerShell, or add it through Windows Defender Firewall →
|
||||
Advanced Settings → Inbound Rules.
|
||||
through — `New-NetFirewallRule -DisplayName "TeamsISO Control Surface" -Direction Inbound -Protocol TCP -LocalPort 9755 -Action Allow`
|
||||
in an elevated PowerShell, or add it through Windows Defender Firewall →
|
||||
Advanced Settings → Inbound Rules.
|
||||
|
||||
## Authentication
|
||||
|
||||
None — by design. In localhost-only mode, the loopback bind is the
|
||||
None — by design. In localhost-only mode, the loopback bind is the
|
||||
security model: any process on the operator's machine can hit these
|
||||
endpoints, the same threat model as a Stream Deck's USB connection.
|
||||
|
||||
In LAN-reachable mode, the assumption is a closed/trusted network (a
|
||||
production-control LAN, a dedicated show subnet, a private vlan). Any
|
||||
machine that can route to the host on the listener port can drive
|
||||
Dragon-ISO. **Do not enable LAN-reachable mode on an untrusted network.**
|
||||
TeamsISO. **Do not enable LAN-reachable mode on an untrusted network.**
|
||||
|
||||
## Response shape
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ specific fields. Errors return HTTP 4xx/5xx with `{"error": "..."}`.
|
|||
### `GET /ui`
|
||||
|
||||
Self-contained HTML control panel. Open this in a browser to drive
|
||||
Dragon-ISO from a phone, tablet, or second monitor. Lists participants live
|
||||
TeamsISO from a phone, tablet, or second monitor. Lists participants live
|
||||
via the same `/ws` WebSocket the rest of the doc describes, and posts to
|
||||
the REST endpoints when you click. Single page, no external dependencies,
|
||||
loads in <50KB.
|
||||
|
|
@ -70,7 +70,7 @@ alive?" probes.
|
|||
|
||||
```json
|
||||
{
|
||||
"product": "Dragon-ISO",
|
||||
"product": "TeamsISO",
|
||||
"version": "1.0.0.0",
|
||||
"endpoints": ["GET /participants", "POST /participants/{id}/iso", ...]
|
||||
}
|
||||
|
|
@ -89,7 +89,7 @@ Snapshot of the current participant list as the UI sees it.
|
|||
"isOnline": true,
|
||||
"isEnabled": false,
|
||||
"customName": null,
|
||||
"stateLabel": "—"
|
||||
"stateLabel": "—"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ Enable or disable an ISO by participant Id. Body or query string:
|
|||
{ "enabled": true, "customName": "Host" }
|
||||
```
|
||||
|
||||
`enabled` is optional — omitting it toggles the current state. `customName`
|
||||
`enabled` is optional — omitting it toggles the current state. `customName`
|
||||
is optional and overrides the auto-generated NDI output name.
|
||||
|
||||
```sh
|
||||
|
|
@ -174,7 +174,7 @@ Toggle per-output recording on or off. Body or query string:
|
|||
```
|
||||
|
||||
`directory` is optional when `enabled=false`. Already-running ISOs are not
|
||||
retroactively recorded — the operator should disable + re-enable a
|
||||
retroactively recorded — the operator should disable + re-enable a
|
||||
participant to start recording it.
|
||||
|
||||
### `POST /recording/marker`
|
||||
|
|
@ -191,8 +191,8 @@ curl -X POST 'http://127.0.0.1:9755/recording/marker?label=Guest+answer'
|
|||
### `POST /notes`
|
||||
|
||||
Append a timestamped line to today's show-notes file at
|
||||
`%LOCALAPPDATA%\Dragon-ISO\Notes\<YYYY-MM-DD>.md`. Body or query string carries
|
||||
`text`. Each line is prefixed with `**HH:mm:ss** —`; the file is markdown so
|
||||
`%LOCALAPPDATA%\TeamsISO\Notes\<YYYY-MM-DD>.md`. Body or query string carries
|
||||
`text`. Each line is prefixed with `**HH:mm:ss** —`; the file is markdown so
|
||||
it renders nicely in any editor.
|
||||
|
||||
```sh
|
||||
|
|
@ -204,7 +204,7 @@ curl -X POST 'http://127.0.0.1:9755/notes?text=guest+segment+starts'
|
|||
Roll every active recording into a new chunk. Each running pipeline is
|
||||
disabled (recorder finalizes its `manifest.json`), waits ~150ms, then re-
|
||||
enabled (recorder opens a fresh subdirectory keyed by display name +
|
||||
timestamp). Useful for chaptering between show segments — a Stream Deck
|
||||
timestamp). Useful for chaptering between show segments — a Stream Deck
|
||||
button mapped to this gives operators "next segment" without losing the
|
||||
already-recorded footage.
|
||||
|
||||
|
|
@ -217,7 +217,7 @@ Response:
|
|||
{ "ok": true, "action": "roll-recording", "rolled": 4 }
|
||||
```
|
||||
|
||||
## WebSocket — live state push
|
||||
## WebSocket — live state push
|
||||
|
||||
For controllers that want to light a button when an ISO goes LIVE without
|
||||
polling, connect to:
|
||||
|
|
@ -240,37 +240,37 @@ snapshot is pushed within 250ms. Format:
|
|||
}
|
||||
```
|
||||
|
||||
Client→server messages are ignored for v1 — all commands go through REST.
|
||||
Client→server messages are ignored for v1 — all commands go through REST.
|
||||
|
||||
## OSC over UDP
|
||||
|
||||
Same command surface, different transport. Enable the OSC bridge in the
|
||||
DISPLAY tab (default port **9000** — TouchOSC's default). Bound to
|
||||
DISPLAY tab (default port **9000** — TouchOSC's default). Bound to
|
||||
`127.0.0.1` by default; honors the same LAN-reachable toggle as the REST
|
||||
surface — when LAN mode is on, OSC binds to `0.0.0.0` so a TouchOSC tablet
|
||||
surface — when LAN mode is on, OSC binds to `0.0.0.0` so a TouchOSC tablet
|
||||
on the same network can talk to the host directly.
|
||||
|
||||
Address vocabulary:
|
||||
|
||||
```
|
||||
/Dragon-ISO/iso "DisplayName" {0|1} — toggle/set ISO by display name
|
||||
/Dragon-ISO/iso/by-id "guid" {0|1} — toggle/set by Id
|
||||
/Dragon-ISO/preset "Name" — apply preset
|
||||
/Dragon-ISO/teams/mute — UIA toggle mute
|
||||
/Dragon-ISO/teams/camera — UIA toggle camera
|
||||
/Dragon-ISO/teams/leave — UIA leave
|
||||
/Dragon-ISO/teams/share — UIA share tray
|
||||
/Dragon-ISO/teams/raise-hand — UIA raise hand
|
||||
/Dragon-ISO/refresh-discovery — rebuild NDI finder
|
||||
/Dragon-ISO/stop-all — disable every ISO
|
||||
/Dragon-ISO/recording {0|1} — recording on/off (default dir)
|
||||
/Dragon-ISO/recording/marker "Label" — drop a marker on every active recording
|
||||
/Dragon-ISO/recording/roll — roll every active recording into a new chunk
|
||||
/Dragon-ISO/notes "Free-form note" — append a timestamped line to today's notes
|
||||
/teamsiso/iso "DisplayName" {0|1} — toggle/set ISO by display name
|
||||
/teamsiso/iso/by-id "guid" {0|1} — toggle/set by Id
|
||||
/teamsiso/preset "Name" — apply preset
|
||||
/teamsiso/teams/mute — UIA toggle mute
|
||||
/teamsiso/teams/camera — UIA toggle camera
|
||||
/teamsiso/teams/leave — UIA leave
|
||||
/teamsiso/teams/share — UIA share tray
|
||||
/teamsiso/teams/raise-hand — UIA raise hand
|
||||
/teamsiso/refresh-discovery — rebuild NDI finder
|
||||
/teamsiso/stop-all — disable every ISO
|
||||
/teamsiso/recording {0|1} — recording on/off (default dir)
|
||||
/teamsiso/recording/marker "Label" — drop a marker on every active recording
|
||||
/teamsiso/recording/roll — roll every active recording into a new chunk
|
||||
/teamsiso/notes "Free-form note" — append a timestamped line to today's notes
|
||||
```
|
||||
|
||||
Companion → Surfaces → "Generic OSC" supports outbound OSC; bind a button
|
||||
press to e.g. `/Dragon-ISO/iso "Jane" 1`. TouchOSC layouts can use the same
|
||||
Companion → Surfaces → "Generic OSC" supports outbound OSC; bind a button
|
||||
press to e.g. `/teamsiso/iso "Jane" 1`. TouchOSC layouts can use the same
|
||||
addresses on the same UDP port.
|
||||
|
||||
## Bitfocus Companion recipe
|
||||
|
|
@ -292,7 +292,7 @@ on the appropriate endpoint above.
|
|||
|
||||
## Future work
|
||||
|
||||
- **HTTPS / token auth** — for deployments that don't have a closed
|
||||
- **HTTPS / token auth** — for deployments that don't have a closed
|
||||
network, layer TLS termination + a shared bearer token in front of the
|
||||
HttpListener. Out of scope for v1; the LAN-reachable mode is a
|
||||
trusted-network feature only.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Real-time H.264 recording
|
||||
# Real-time H.264 recording
|
||||
|
||||
The default recorder (`RawBgraRecorderSink`) writes uncompressed BGRA to disk
|
||||
and ships a `convert.cmd` for post-recording FFmpeg encoding. That's safe
|
||||
|
|
@ -7,32 +7,32 @@ and ships a `convert.cmd` for post-recording FFmpeg encoding. That's safe
|
|||
|
||||
For long shows or operators on slower disks, the engine ships a
|
||||
**`MediaFoundationRecorderSink`** that encodes to H.264 in real time using
|
||||
Windows Media Foundation. Inline encoding cuts disk pressure ~10× and
|
||||
Windows Media Foundation. Inline encoding cuts disk pressure ~10× and
|
||||
produces a finished `.mp4` without the convert step.
|
||||
|
||||
It's behind a build flag because activating it requires adding a NuGet
|
||||
dependency. The structural code is already in
|
||||
`src/Dragon-ISO.Engine/Pipeline/MediaFoundationRecorderSink.cs`.
|
||||
`src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs`.
|
||||
|
||||
## Status — May 2026
|
||||
## Status — May 2026
|
||||
|
||||
**Activation deferred.** The Vortice.MediaFoundation 3.6.2 NuGet package
|
||||
is referenced from `Dragon-ISO.Engine.csproj`, but the `MF_AVAILABLE` symbol
|
||||
is referenced from `TeamsISO.Engine.csproj`, but the `MF_AVAILABLE` symbol
|
||||
is *not* defined. The scaffold in
|
||||
`src/Dragon-ISO.Engine/Pipeline/MediaFoundationRecorderSink.cs` was written
|
||||
`src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs` was written
|
||||
against an older Vortice API and needs a port pass before activation:
|
||||
|
||||
- `MFVersion` → not on `MediaFactory` in 3.6.2; pass the SDK version
|
||||
- `MFVersion` → not on `MediaFactory` in 3.6.2; pass the SDK version
|
||||
directly to `MFStartup`.
|
||||
- `MediaFactory.MF_LOW_LATENCY` → relocated to a different attribute
|
||||
- `MediaFactory.MF_LOW_LATENCY` → relocated to a different attribute
|
||||
constants class.
|
||||
- `IMFAttributes.SetUINT32` → replaced with a generic `Set` overload.
|
||||
- `IMFAttributes.SetUINT32` → replaced with a generic `Set` overload.
|
||||
- `IMFMediaType.MajorType` / `.SubType` / `.AvgBitrate` properties
|
||||
→ now use `SetGUID(MFAttributeKeys.MajorType, ...)` etc.
|
||||
- `VideoFormatGuids.RGB32` → renamed (likely `Rgb32`).
|
||||
- `IMFMediaBuffer.Lock(out IntPtr, out int, out int)` → explicit out-param
|
||||
→ now use `SetGUID(MFAttributeKeys.MajorType, ...)` etc.
|
||||
- `VideoFormatGuids.RGB32` → renamed (likely `Rgb32`).
|
||||
- `IMFMediaBuffer.Lock(out IntPtr, out int, out int)` → explicit out-param
|
||||
signature, no longer returns a locked-buffer wrapper.
|
||||
- `IMFSinkWriter.Finalize_` → renamed (likely `Finalize`).
|
||||
- `IMFSinkWriter.Finalize_` → renamed (likely `Finalize`).
|
||||
|
||||
Until the port lands, the `RawBgraRecorderSink` is the only IRecorderSink
|
||||
production uses. The raw recorder is reliable and FFmpeg post-processing
|
||||
|
|
@ -45,7 +45,7 @@ disk pressure during the show.
|
|||
reference implementation lives in the Vortice samples repo under
|
||||
`samples/MediaFoundationSamples`.
|
||||
|
||||
2. **Define the `MF_AVAILABLE` build symbol** in `Dragon-ISO.Engine.csproj`:
|
||||
2. **Define the `MF_AVAILABLE` build symbol** in `TeamsISO.Engine.csproj`:
|
||||
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
|
|
@ -71,10 +71,10 @@ disk pressure during the show.
|
|||
## What the MF recorder produces
|
||||
|
||||
For each enabled ISO with recording on:
|
||||
- `<recordings>/<participant>/output.mp4` — H.264 video at the engine's
|
||||
- `<recordings>/<participant>/output.mp4` — H.264 video at the engine's
|
||||
configured resolution / framerate, target bitrate ~0.07 bits/pixel
|
||||
(~7 Mbps for 1080p30, ~3 Mbps for 720p30).
|
||||
- `<recordings>/<participant>/markers.txt` — tab-separated marker offsets
|
||||
- `<recordings>/<participant>/markers.txt` — tab-separated marker offsets
|
||||
from `IIsoController.AddRecordingMarker`. Manually chapter the .mp4 with
|
||||
`mp4chaps -c -i markers.txt output.mp4` (mp4chaps from the `mp4v2` tools).
|
||||
|
||||
|
|
@ -91,5 +91,5 @@ For each enabled ISO with recording on:
|
|||
| Reliable on legacy GPUs | Yes | Yes (MF falls back to software encoder if no hw H.264) |
|
||||
|
||||
If your target machines have NVIDIA NVENC / Intel QuickSync, MF will use
|
||||
the hardware encoder transparently — that's the path that gives you
|
||||
the hardware encoder transparently — that's the path that gives you
|
||||
multi-stream realtime H.264 with low CPU.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Releasing Dragon-ISO
|
||||
# Releasing TeamsISO
|
||||
|
||||
The release workflow at `.forgejo/workflows/release.yml` runs on **annotated tag pushes
|
||||
matching `v*.*.*`**. It builds, tests, publishes, packages an MSI, and uploads the
|
||||
|
|
@ -11,26 +11,26 @@ MSI as a release asset.
|
|||
separate Windows runner. Register one with `forgejo-runner register` against a
|
||||
Windows host that has the .NET 8 SDK + WiX SDK access (the WiX SDK pulls itself
|
||||
via NuGet at build time, so no separate install).
|
||||
- The repository's **Create release on tag push** setting on (default), or skip it —
|
||||
- The repository's **Create release on tag push** setting on (default), or skip it —
|
||||
the workflow will create the release if one doesn't exist.
|
||||
|
||||
## Cutting a release
|
||||
|
||||
```sh
|
||||
# Bump the version in Directory.Build.props if you haven't already.
|
||||
git tag -a v1.0.0 -m "Dragon-ISO 1.0.0"
|
||||
git tag -a v1.0.0 -m "TeamsISO 1.0.0"
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
The workflow will:
|
||||
|
||||
1. Restore + build `Dragon-ISO.Windows.slnf` in Release with the tag's version.
|
||||
2. Run unit tests (the `requires=ndi` integration tier is skipped — it needs a
|
||||
1. Restore + build `TeamsISO.Windows.slnf` in Release with the tag's version.
|
||||
2. Run unit tests (the `requires=ndi` integration tier is skipped — it needs a
|
||||
real NDI runtime which a CI runner won't have).
|
||||
3. Publish `Dragon-ISO.App` and `Dragon-ISO.Console` for `win-x64`,
|
||||
3. Publish `TeamsISO.App` and `TeamsISO.Console` for `win-x64`,
|
||||
framework-dependent (.NET 8 Desktop runtime is the user's responsibility).
|
||||
4. Build `installer/Dragon-ISO.Installer.wixproj`, producing
|
||||
`Dragon-ISO-Setup-<version>.msi`.
|
||||
4. Build `installer/TeamsISO.Installer.wixproj`, producing
|
||||
`TeamsISO-Setup-<version>.msi`.
|
||||
5. Upload the MSI as a workflow artifact (downloadable from the run page).
|
||||
6. Attach the MSI to the GitHub-style Release for the tag, creating the release
|
||||
first if it doesn't exist. Pre-release flag is set automatically when the
|
||||
|
|
@ -39,13 +39,13 @@ The workflow will:
|
|||
## Code signing
|
||||
|
||||
The release workflow has optional signtool integration. It runs only when the
|
||||
signing-cert secrets are configured on the repository — without them, builds
|
||||
signing-cert secrets are configured on the repository — without them, builds
|
||||
remain unsigned and produce a SmartScreen warning on first launch.
|
||||
|
||||
### Enabling signing
|
||||
|
||||
Set these secrets on `forge.wilddragon.net/zgaetano/Dragon-ISO`
|
||||
→ Settings → Actions → Secrets:
|
||||
Set these secrets on `forge.wilddragon.net/zgaetano/teamsiso`
|
||||
→ Settings → Actions → Secrets:
|
||||
|
||||
| Secret | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
|
|
@ -56,7 +56,7 @@ Set these secrets on `forge.wilddragon.net/zgaetano/Dragon-ISO`
|
|||
When all three are present, the workflow:
|
||||
|
||||
1. Decodes the PFX to a temp file on the runner before building.
|
||||
2. Signs `publish/Dragon-ISO/Dragon-ISO.exe` after publish, before MSI build, so the
|
||||
2. Signs `publish/TeamsISO/TeamsISO.exe` after publish, before MSI build, so the
|
||||
binary embedded in the MSI is signed too.
|
||||
3. Signs the produced MSI itself after WiX builds it.
|
||||
4. Wipes the temp PFX from disk.
|
||||
|
|
@ -70,7 +70,7 @@ which is what current Microsoft / SmartScreen guidance requires.
|
|||
per-publisher over time; brand-new OV certs still trip the warning until
|
||||
enough downloads accumulate.
|
||||
- **EV (Extended Validation, ~$300/yr, hardware token).** SmartScreen-trusted
|
||||
immediately. Token-based — to use one in CI you'll need to either (a) keep
|
||||
immediately. Token-based — to use one in CI you'll need to either (a) keep
|
||||
the runner on a host with the token plugged in, or (b) move to a cloud
|
||||
signing service like Azure Trusted Signing or DigiCert KeyLocker.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,520 +0,0 @@
|
|||
# Dragon-ISO Installer Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rebrand the WiX v5 MSI installer from TeamsISO to Dragon-ISO, producing `Dragon-ISO-Setup-1.0.0.0.msi` for end-user download.
|
||||
|
||||
**Architecture:** Rename the `.wixproj` file, rewrite `Package.wxs` with Dragon-ISO branding and a simple `WixUI_Minimal` UI (no directory picker), and fix a bug in `release.yml` where the signing step references the wrong executable filename.
|
||||
|
||||
**Tech Stack:** WiX Toolset v5, MSBuild, PowerShell; no unit tests (installer files are build-verified by `dotnet build`)
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `installer/TeamsISO.Installer.wixproj` | Rename + rewrite | MSBuild project — output name, publish dir, asset dir |
|
||||
| `installer/Dragon-ISO.Installer.wixproj` | Created by rename | Same as above, Dragon-ISO branded |
|
||||
| `installer/Package.wxs` | Rewrite | WiX source — all installer logic, shortcuts, metadata |
|
||||
| `.forgejo/workflows/release.yml` | Fix line 119 | Fix `Dragon-ISO.exe` → `DragonISO.exe` (exe filename matches AssemblyName) |
|
||||
|
||||
> **Important:** `Dragon-ISO.App.csproj` has `<AssemblyName>DragonISO</AssemblyName>` (no hyphen). The published executable is therefore `DragonISO.exe`, not `Dragon-ISO.exe`. All shortcut targets and signing steps must use `DragonISO.exe`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Rename the .wixproj file
|
||||
|
||||
**Files:**
|
||||
- Rename: `installer/TeamsISO.Installer.wixproj` → `installer/Dragon-ISO.Installer.wixproj`
|
||||
|
||||
- [ ] **Step 1: Rename the file using git mv**
|
||||
|
||||
```powershell
|
||||
cd C:\Users\zacga\source\repos\Dragon-ISO
|
||||
git mv installer/TeamsISO.Installer.wixproj installer/Dragon-ISO.Installer.wixproj
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the rename**
|
||||
|
||||
```powershell
|
||||
Get-ChildItem installer/
|
||||
```
|
||||
|
||||
Expected: `Dragon-ISO.Installer.wixproj` and `Package.wxs` (no `TeamsISO.Installer.wixproj`)
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Rewrite Dragon-ISO.Installer.wixproj
|
||||
|
||||
**Files:**
|
||||
- Modify: `installer/Dragon-ISO.Installer.wixproj`
|
||||
|
||||
- [ ] **Step 1: Replace the file content entirely**
|
||||
|
||||
Write the following to `installer/Dragon-ISO.Installer.wixproj`:
|
||||
|
||||
```xml
|
||||
<Project Sdk="WixToolset.Sdk/5.0.2">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Package</OutputType>
|
||||
<OutputName>Dragon-ISO-Setup-$(Version)</OutputName>
|
||||
|
||||
<!-- 64-bit MSI; suppresses ICE80 on components in Program Files (x64). -->
|
||||
<Platform>x64</Platform>
|
||||
<InstallerPlatform>x64</InstallerPlatform>
|
||||
|
||||
<!--
|
||||
Built artifact location. The installer expects a published build of
|
||||
Dragon-ISO.App rooted here. CI / local script:
|
||||
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj
|
||||
-c Release -r win-x64 --self-contained false
|
||||
-o $(SolutionDir)publish/Dragon-ISO
|
||||
-->
|
||||
<PublishDir>$(MSBuildThisFileDirectory)..\publish\Dragon-ISO\</PublishDir>
|
||||
|
||||
<!-- Pass MSBuild values into WiX preprocessor. -->
|
||||
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\Dragon-ISO.App\Assets\</DefineConstants>
|
||||
|
||||
<!-- Code-signing hook (set externally for release builds; left empty for dev). -->
|
||||
<SignOutput Condition=" '$(SignOutput)' == '' ">false</SignOutput>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
Reference the WiX UI extension so the MSI shows a friendly progress UI
|
||||
instead of the silent default.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="WixToolset.UI.wixext" Version="5.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the file reads back correctly**
|
||||
|
||||
```powershell
|
||||
Get-Content installer/Dragon-ISO.Installer.wixproj | Select-String "OutputName|PublishDir|AssetsDir"
|
||||
```
|
||||
|
||||
Expected output (3 lines):
|
||||
```
|
||||
<OutputName>Dragon-ISO-Setup-$(Version)</OutputName>
|
||||
<PublishDir>$(MSBuildThisFileDirectory)..\publish\Dragon-ISO\</PublishDir>
|
||||
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\Dragon-ISO.App\Assets\</DefineConstants>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Rewrite Package.wxs with Dragon-ISO branding
|
||||
|
||||
**Files:**
|
||||
- Modify: `installer/Package.wxs`
|
||||
|
||||
Changes from the original TeamsISO version:
|
||||
- Package Name: "TeamsISO" → "Dragon-ISO"
|
||||
- SummaryInformation description and keywords updated
|
||||
- MajorUpgrade error message updated
|
||||
- Feature Title: "TeamsISO" → "Dragon-ISO"
|
||||
- UI switched from `WixUI_InstallDir` (shows dir picker) → `WixUI_Minimal` (Welcome → Install → Finish)
|
||||
- `WIXUI_INSTALLDIR` property removed (not used by WixUI_Minimal)
|
||||
- ARPHELPLINK URL: teamsiso → dragon-iso
|
||||
- ARPCOMMENTS: "TeamsISO" → "Dragon-ISO"
|
||||
- Icon Id: "TeamsISOIcon" → "DragonISOIcon"
|
||||
- Icon SourceFile: `teamsiso.ico` → `Dragon-ISO.ico`
|
||||
- ARPPRODUCTICON value: "TeamsISOIcon" → "DragonISOIcon"
|
||||
- Added .NET 8 Desktop Runtime detection property
|
||||
- Install directory Name: "TeamsISO" → "Dragon-ISO"
|
||||
- Start Menu shortcut Id/Name/Target/Icon updated
|
||||
- Desktop shortcut Id/Name/Target/Icon updated
|
||||
- All registry keys: `Software\Wild Dragon\TeamsISO` → `Software\Wild Dragon\Dragon-ISO`
|
||||
- Shortcut targets: `TeamsISO.exe` → `DragonISO.exe` (matches AssemblyName, no hyphen)
|
||||
|
||||
- [ ] **Step 1: Replace Package.wxs entirely with Dragon-ISO branded content**
|
||||
|
||||
Write the following to `installer/Package.wxs`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Dragon-ISO — MSI installer (WiX v5)
|
||||
|
||||
Produces: Dragon-ISO-Setup-<Version>.msi (per-machine install).
|
||||
|
||||
Build:
|
||||
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj -c Release -r win-x64 --self-contained false -o publish/Dragon-ISO
|
||||
dotnet build installer/Dragon-ISO.Installer.wixproj -c Release
|
||||
|
||||
Runtime expectations:
|
||||
- .NET 8 Desktop runtime present on target (framework-dependent build)
|
||||
- NDI 6 Runtime present — checked in NdiRuntimeDirV6Search; absence WARNS
|
||||
but does not block install (operators can install NDI after the app)
|
||||
|
||||
Exe filename note:
|
||||
Dragon-ISO.App.csproj sets AssemblyName=DragonISO (no hyphen — CLR
|
||||
assembly names cannot contain hyphens). The published executable is
|
||||
therefore DragonISO.exe. Shortcut targets reference DragonISO.exe.
|
||||
-->
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
|
||||
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
||||
|
||||
<Package Name="Dragon-ISO"
|
||||
Manufacturer="Wild Dragon LLC"
|
||||
Version="1.0.0.0"
|
||||
UpgradeCode="9F4A8B2C-1D3E-4A5B-9C6D-8E7F0A1B2C3D"
|
||||
Scope="perMachine"
|
||||
Compressed="yes"
|
||||
InstallerVersion="500">
|
||||
|
||||
<!--
|
||||
SummaryInformation fields surface in File Explorer's "Details" tab and
|
||||
in the Windows Installer "About" dialog. Description and Keywords are
|
||||
what users see if they right-click the MSI before installing; Comments
|
||||
is the longer copy that appears alongside the version in some
|
||||
installer dialogs.
|
||||
-->
|
||||
<SummaryInformation
|
||||
Description="Dragon-ISO — per-participant NDI ISO controller for Microsoft Teams. Splits each Teams participant into a normalized NDI source for vMix / OBS / Ross / hardware switchers."
|
||||
Manufacturer="Wild Dragon LLC"
|
||||
Keywords="Dragon-ISO, NDI, Microsoft Teams, ISO recording, broadcast, live production, vMix, OBS, switcher, Wild Dragon" />
|
||||
|
||||
<!--
|
||||
MajorUpgrade: a newer install replaces an older one in-place. We
|
||||
disallow downgrades because the engine config schema only carries a
|
||||
forward-migration path; downgrading would leave operators with a
|
||||
config the older binary doesn't understand.
|
||||
-->
|
||||
<MajorUpgrade DowngradeErrorMessage="A newer version of Dragon-ISO is already installed. Uninstall it before installing this older version."
|
||||
Schedule="afterInstallInitialize" />
|
||||
|
||||
<!--
|
||||
Single MSI feature; users see only the install/uninstall screens.
|
||||
-->
|
||||
<Feature Id="Main" Title="Dragon-ISO" Level="1">
|
||||
<ComponentGroupRef Id="ApplicationFiles" />
|
||||
<ComponentGroupRef Id="Shortcuts" />
|
||||
<ComponentGroupRef Id="DesktopShortcut" />
|
||||
<ComponentGroupRef Id="ArpEntry" />
|
||||
</Feature>
|
||||
|
||||
<!--
|
||||
Minimal install UI: Welcome/License → Progress → Finish.
|
||||
No directory picker — installs to Program Files\Wild Dragon\Dragon-ISO.
|
||||
-->
|
||||
<ui:WixUI Id="WixUI_Minimal" />
|
||||
|
||||
<!--
|
||||
Add/Remove Programs metadata. ARPHELPLINK is the "Help" link; ARPURLINFOABOUT
|
||||
is the manufacturer/about link; ARPCONTACT is the support contact shown
|
||||
when the user clicks "Support information" from the ARP entry. ARPCOMMENTS
|
||||
is the long description displayed in some Settings → Apps surfaces.
|
||||
-->
|
||||
<Property Id="ARPHELPLINK" Value="https://forge.wilddragon.net/zgaetano/dragon-iso" />
|
||||
<Property Id="ARPURLINFOABOUT" Value="https://wilddragon.net" />
|
||||
<Property Id="ARPCONTACT" Value="Wild Dragon LLC — support@wilddragon.net" />
|
||||
<Property Id="ARPCOMMENTS" Value="Dragon-ISO turns Microsoft Teams' raw NDI broadcast into clean, normalized, per-participant NDI sources for ingestion by a live-production switcher (vMix, OBS, Ross, hardware capture). Each participant gets an individually-addressable source with configurable framerate, resolution, aspect mode, and audio routing." />
|
||||
<!-- ARPNOMODIFY is set by WixUI_Minimal; don't redeclare. -->
|
||||
<Property Id="ARPNOREPAIR" Value="1" />
|
||||
|
||||
<!--
|
||||
ARP icon — references the same .ico the WPF host uses. WiX requires the
|
||||
icon resource to live next to the wxs OR be reachable at build time;
|
||||
we point at the source copy under src/Dragon-ISO.App/Assets so the icon
|
||||
embedded in the MSI matches the icon in the running exe.
|
||||
-->
|
||||
<Icon Id="DragonISOIcon" SourceFile="$(var.AssetsDir)Dragon-ISO.ico" />
|
||||
<Property Id="ARPPRODUCTICON" Value="DragonISOIcon" />
|
||||
|
||||
<!--
|
||||
.NET 8 Desktop Runtime detection. The .NET apphost will surface a
|
||||
"framework not found" dialog naturally if the runtime is absent;
|
||||
this property is available for future conditional logic.
|
||||
VBScript-based install-time dialogs are deprecated in WiX v5 / Windows;
|
||||
rewriting in C++ is overkill for a soft warning on a soft dependency.
|
||||
-->
|
||||
<Property Id="DOTNET8DESKTOPRUNTIME" Value="0">
|
||||
<RegistrySearch Id="DotNet8DesktopRuntimeSearch"
|
||||
Root="HKLM"
|
||||
Key="SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App"
|
||||
Name="8.0.0"
|
||||
Type="raw" />
|
||||
</Property>
|
||||
|
||||
<!--
|
||||
NDI Runtime detection. We check for NDI_RUNTIME_DIR_V6 in the system
|
||||
environment block. Missing → warn during install, don't block. The
|
||||
engine surfaces a clear MessageBox with an install-NDI link at first
|
||||
launch if the runtime really isn't there.
|
||||
-->
|
||||
<Property Id="NDIRUNTIMEDIR" Value="0">
|
||||
<RegistrySearch Id="NdiRuntimeDirV6Search"
|
||||
Root="HKLM"
|
||||
Key="SYSTEM\CurrentControlSet\Control\Session Manager\Environment"
|
||||
Name="NDI_RUNTIME_DIR_V6"
|
||||
Type="raw" />
|
||||
</Property>
|
||||
|
||||
<!--
|
||||
NDI runtime detection is surfaced at first app launch (App.xaml.cs pops a
|
||||
MessageBox with an install link). We deliberately don't block install on
|
||||
a missing runtime so admins can stage the app before NDI is rolled out.
|
||||
VBScript-based install-time prompts are deprecated in WiX v5 / Windows
|
||||
and rewriting in C++ is overkill for a soft warning.
|
||||
-->
|
||||
|
||||
<!--
|
||||
Install layout under Program Files\Wild Dragon\Dragon-ISO.
|
||||
-->
|
||||
<StandardDirectory Id="ProgramFiles64Folder">
|
||||
<Directory Id="ManufacturerFolder" Name="Wild Dragon">
|
||||
<Directory Id="INSTALLFOLDER" Name="Dragon-ISO" />
|
||||
</Directory>
|
||||
</StandardDirectory>
|
||||
|
||||
<StandardDirectory Id="ProgramMenuFolder">
|
||||
<Directory Id="WildDragonStartMenuFolder" Name="Wild Dragon" />
|
||||
</StandardDirectory>
|
||||
|
||||
<!--
|
||||
Files: harvested from the publish output dir at build time.
|
||||
WiX v5 understands <Files Include="..."> with glob patterns and
|
||||
synthesizes one Component per file with stable GUIDs.
|
||||
-->
|
||||
<ComponentGroup Id="ApplicationFiles" Directory="INSTALLFOLDER">
|
||||
<Files Include="$(var.PublishDir)**" />
|
||||
</ComponentGroup>
|
||||
|
||||
<!--
|
||||
Start Menu and Desktop shortcuts — direct .exe targets.
|
||||
|
||||
Don't wrap the Target in runas.exe /trustlevel:0x20000 (or anything
|
||||
else that demotes the spawned process). The SAFER-restricted token
|
||||
breaks .NET 8 WPF apphost startup: the process appears alive with
|
||||
a window, but no managed code past BAML parse executes. Verified
|
||||
empirically 2026-05-16 — letting Dragon-ISO inherit the launching
|
||||
token (medium or high integrity, doesn't matter) is the correct
|
||||
behavior. NDI discovery works fine at either integrity level.
|
||||
|
||||
Exe filename: AssemblyName=DragonISO (no hyphen), so target is
|
||||
DragonISO.exe not Dragon-ISO.exe.
|
||||
-->
|
||||
<ComponentGroup Id="Shortcuts" Directory="WildDragonStartMenuFolder">
|
||||
<Component Id="StartMenuShortcut" Guid="*">
|
||||
<Shortcut Id="StartMenuDragonISO"
|
||||
Name="Dragon-ISO"
|
||||
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
|
||||
Target="[INSTALLFOLDER]DragonISO.exe"
|
||||
WorkingDirectory="INSTALLFOLDER"
|
||||
Icon="DragonISOIcon" />
|
||||
<!-- Required by ICE64: Start Menu folder must be cleaned on uninstall. -->
|
||||
<RemoveFolder Id="RemoveWildDragonStartMenuFolder"
|
||||
Directory="WildDragonStartMenuFolder"
|
||||
On="uninstall" />
|
||||
<RegistryValue Root="HKCU"
|
||||
Key="Software\Wild Dragon\Dragon-ISO"
|
||||
Name="StartMenuShortcut"
|
||||
Type="integer"
|
||||
Value="1"
|
||||
KeyPath="yes" />
|
||||
</Component>
|
||||
</ComponentGroup>
|
||||
|
||||
<StandardDirectory Id="DesktopFolder" />
|
||||
<ComponentGroup Id="DesktopShortcut" Directory="DesktopFolder">
|
||||
<Component Id="DesktopShortcutComponent" Guid="*">
|
||||
<Shortcut Id="DesktopDragonISO"
|
||||
Name="Dragon-ISO"
|
||||
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
|
||||
Target="[INSTALLFOLDER]DragonISO.exe"
|
||||
WorkingDirectory="INSTALLFOLDER"
|
||||
Icon="DragonISOIcon" />
|
||||
<RegistryValue Root="HKCU"
|
||||
Key="Software\Wild Dragon\Dragon-ISO"
|
||||
Name="DesktopShortcut"
|
||||
Type="integer"
|
||||
Value="1"
|
||||
KeyPath="yes" />
|
||||
</Component>
|
||||
</ComponentGroup>
|
||||
|
||||
<!--
|
||||
ARP icon registry entry. Optional — the MSI auto-fills most ARP
|
||||
fields from the Package element. We only need to store the install
|
||||
path for diagnostic / uninstall tooling.
|
||||
-->
|
||||
<ComponentGroup Id="ArpEntry" Directory="INSTALLFOLDER">
|
||||
<Component Id="ArpIconRegistry" Guid="*">
|
||||
<RegistryValue Root="HKLM"
|
||||
Key="Software\Wild Dragon\Dragon-ISO"
|
||||
Name="InstallPath"
|
||||
Type="string"
|
||||
Value="[INSTALLFOLDER]"
|
||||
KeyPath="yes" />
|
||||
</Component>
|
||||
</ComponentGroup>
|
||||
|
||||
</Package>
|
||||
</Wix>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify no "TeamsISO" strings remain in Package.wxs**
|
||||
|
||||
```powershell
|
||||
Select-String -Path installer/Package.wxs -Pattern "TeamsISO"
|
||||
```
|
||||
|
||||
Expected: no output (zero matches)
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Fix release.yml — wrong exe filename in signing step
|
||||
|
||||
**Files:**
|
||||
- Modify: `.forgejo/workflows/release.yml` line 119
|
||||
|
||||
The signing step references `publish/Dragon-ISO/Dragon-ISO.exe` but the app's `AssemblyName` is `DragonISO`, so the published exe is `DragonISO.exe`. Fix it.
|
||||
|
||||
- [ ] **Step 1: Edit release.yml to fix the exe path**
|
||||
|
||||
In `.forgejo/workflows/release.yml`, find and replace:
|
||||
|
||||
Old (line 119):
|
||||
```
|
||||
'publish/Dragon-ISO/Dragon-ISO.exe'
|
||||
```
|
||||
|
||||
New:
|
||||
```
|
||||
'publish/Dragon-ISO/DragonISO.exe'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the fix**
|
||||
|
||||
```powershell
|
||||
Select-String -Path .forgejo/workflows/release.yml -Pattern "Dragon-ISO\.exe|DragonISO\.exe"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
.forgejo/workflows/release.yml:119: 'publish/Dragon-ISO/DragonISO.exe'
|
||||
```
|
||||
|
||||
(One match, using `DragonISO.exe` with no hyphen)
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Check WiX workload and verify build
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Check if WiX workload is installed**
|
||||
|
||||
```powershell
|
||||
dotnet workload list
|
||||
```
|
||||
|
||||
Expected: output includes `wix` in the list. If not installed, run:
|
||||
```powershell
|
||||
dotnet workload install wix
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Publish the app to the expected location**
|
||||
|
||||
```powershell
|
||||
cd C:\Users\zacga\source\repos\Dragon-ISO
|
||||
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj `
|
||||
-c Release -r win-x64 --self-contained false `
|
||||
-o publish/Dragon-ISO
|
||||
```
|
||||
|
||||
Expected: ends with `Build succeeded.` and creates `publish/Dragon-ISO/DragonISO.exe`
|
||||
|
||||
- [ ] **Step 3: Verify the exe filename in the publish output**
|
||||
|
||||
```powershell
|
||||
Get-ChildItem publish/Dragon-ISO/ -Filter "*.exe"
|
||||
```
|
||||
|
||||
Expected: one file named `DragonISO.exe` (confirms shortcut targets are correct)
|
||||
|
||||
- [ ] **Step 4: Build the MSI**
|
||||
|
||||
```powershell
|
||||
dotnet build installer/Dragon-ISO.Installer.wixproj -c Release /p:Version=1.0.0.0
|
||||
```
|
||||
|
||||
Expected: ends with `Build succeeded.` — no errors, no warnings.
|
||||
|
||||
- [ ] **Step 5: Verify the MSI was produced with the correct name**
|
||||
|
||||
```powershell
|
||||
Get-ChildItem installer/bin -Recurse -Filter "*.msi"
|
||||
```
|
||||
|
||||
Expected: one file named `Dragon-ISO-Setup-1.0.0.0.msi`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Commit all changes
|
||||
|
||||
- [ ] **Step 1: Stage the changed files**
|
||||
|
||||
```powershell
|
||||
cd C:\Users\zacga\source\repos\Dragon-ISO
|
||||
git add installer/Dragon-ISO.Installer.wixproj
|
||||
git add installer/Package.wxs
|
||||
git add .forgejo/workflows/release.yml
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify nothing unexpected is staged**
|
||||
|
||||
```powershell
|
||||
git status
|
||||
```
|
||||
|
||||
Expected staged files:
|
||||
- `installer/Dragon-ISO.Installer.wixproj` (renamed from TeamsISO.Installer.wixproj)
|
||||
- `installer/Package.wxs` (modified)
|
||||
- `.forgejo/workflows/release.yml` (modified)
|
||||
|
||||
No other files should be staged.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```powershell
|
||||
git commit -m "$(cat <<'EOF'
|
||||
rebrand installer from TeamsISO to Dragon-ISO
|
||||
|
||||
- Rename TeamsISO.Installer.wixproj → Dragon-ISO.Installer.wixproj
|
||||
- Update Package.wxs: product name, shortcuts, registry keys, ARP
|
||||
metadata, install directory, and icon all updated to Dragon-ISO
|
||||
- Switch UI from WixUI_InstallDir to WixUI_Minimal (no dir picker)
|
||||
- Add .NET 8 Desktop Runtime detection property
|
||||
- Fix release.yml: signing step referenced Dragon-ISO.exe but
|
||||
AssemblyName=DragonISO so exe is DragonISO.exe (no hyphen)
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist (manual verification after install)
|
||||
|
||||
Once the MSI is built, install it on a test machine and verify:
|
||||
|
||||
- [ ] `Dragon-ISO-Setup-1.0.0.0.msi` installs without errors
|
||||
- [ ] App installs to `C:\Program Files\Wild Dragon\Dragon-ISO\`
|
||||
- [ ] `DragonISO.exe` is present in the install folder
|
||||
- [ ] Start Menu shows `Wild Dragon → Dragon-ISO` shortcut with correct icon
|
||||
- [ ] Desktop shows `Dragon-ISO` shortcut with correct icon
|
||||
- [ ] Both shortcuts launch the app successfully
|
||||
- [ ] Add/Remove Programs shows:
|
||||
- Name: Dragon-ISO
|
||||
- Publisher: Wild Dragon LLC
|
||||
- Version: 1.0.0.0
|
||||
- Help link: `https://forge.wilddragon.net/zgaetano/dragon-iso`
|
||||
- [ ] Uninstall removes all files, shortcuts, and registry entries
|
||||
- [ ] `%APPDATA%\Dragon-ISO\` (user config) is NOT removed on uninstall
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
# Dragon-ISO Installer Design
|
||||
|
||||
**Date:** 2026-05-31
|
||||
**Status:** Design Complete
|
||||
**Audience:** End users downloading and installing Dragon-ISO
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Update the existing WiX Toolset v5 MSI installer from TeamsISO branding to Dragon-ISO. The installer provides a simple, professional Windows installation experience for end users with minimal choices (Next → Install).
|
||||
|
||||
**Key characteristics:**
|
||||
- Per-machine installation to `Program Files\Wild Dragon\Dragon-ISO\`
|
||||
- Simple UI (no customization dialogs)
|
||||
- Start Menu and Desktop shortcuts
|
||||
- Professional Add/Remove Programs (ARP) metadata
|
||||
- Prerequisite detection for .NET 8 Desktop Runtime and NDI 6 Runtime (warn, don't block)
|
||||
- Version: 1.0.0.0
|
||||
|
||||
---
|
||||
|
||||
## File and Naming Structure
|
||||
|
||||
### Files to Rename
|
||||
- `installer/TeamsISO.Installer.wixproj` → `installer/Dragon-ISO.Installer.wixproj`
|
||||
- `installer/Package.wxs` → remains (generic name, no change needed)
|
||||
|
||||
### Build Output
|
||||
- **Current (TeamsISO):** `TeamsISO-Setup-1.0.0.0.msi`
|
||||
- **Updated (Dragon-ISO):** `Dragon-ISO-Setup-1.0.0.0.msi`
|
||||
|
||||
### Asset References
|
||||
- Icon: References `Dragon-ISO.ico` (already exists in `src/Dragon-ISO.App/Assets/`)
|
||||
- App directory: `src/Dragon-ISO.App/` (updated from `src/TeamsISO.App/`)
|
||||
- Publish output: `publish/Dragon-ISO/` (updated from `publish/TeamsISO/`)
|
||||
|
||||
---
|
||||
|
||||
## Branding Updates (in Package.wxs)
|
||||
|
||||
### Product Metadata
|
||||
- **Package Name:** `"Dragon-ISO"` (from `"TeamsISO"`)
|
||||
- **Manufacturer:** `"Wild Dragon LLC"` (unchanged)
|
||||
- **UpgradeCode:** Keep existing GUID (allows upgrades from TeamsISO to Dragon-ISO)
|
||||
- **Version:** `1.0.0.0`
|
||||
|
||||
### Summary Information
|
||||
- **Description:** "Dragon-ISO — per-participant NDI ISO controller for Microsoft Teams. Splits each Teams participant into a normalized NDI source for vMix / OBS / Ross / hardware switchers."
|
||||
- **Keywords:** "Dragon-ISO, NDI, Microsoft Teams, ISO recording, broadcast, live production, vMix, OBS, switcher, Wild Dragon"
|
||||
|
||||
### Add/Remove Programs (ARP) Metadata
|
||||
- **Help Link:** `https://forge.wilddragon.net/zgaetano/dragon-iso`
|
||||
- **About Link:** `https://wilddragon.net`
|
||||
- **Support Contact:** `Wild Dragon LLC — support@wilddragon.net`
|
||||
- **Comments:** "Dragon-ISO turns Microsoft Teams' raw NDI broadcast into clean, normalized, per-participant NDI sources for ingestion by a live-production switcher (vMix, OBS, Ross, hardware capture). Each participant gets an individually-addressable source with configurable framerate, resolution, aspect mode, and audio routing."
|
||||
|
||||
### Registry Keys
|
||||
- **Location:** `Software\Wild Dragon\Dragon-ISO` (from `Software\Wild Dragon\TeamsISO`)
|
||||
- Used for: Start Menu shortcut tracking, Desktop shortcut tracking, Install path storage
|
||||
|
||||
### Shortcuts
|
||||
- **Start Menu:** `Wild Dragon → Dragon-ISO`
|
||||
- **Desktop:** `Dragon-ISO` shortcut
|
||||
- **Target:** `[INSTALLFOLDER]Dragon-ISO.exe`
|
||||
- **Icon:** Dragon-ISO icon from assets
|
||||
- **Description:** "Per-Participant NDI ISO Controller for Microsoft Teams"
|
||||
|
||||
---
|
||||
|
||||
## Installation Layout
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
Program Files\Wild Dragon\Dragon-ISO\
|
||||
├── Dragon-ISO.exe (main executable)
|
||||
├── DragonISO.dll (core assembly)
|
||||
├── Assets\ (icons, fonts, resources)
|
||||
├── Themes\ (XAML theme files)
|
||||
├── [.NET dependencies] (runtime assets, supporting DLLs)
|
||||
└── [all other published files]
|
||||
```
|
||||
|
||||
### Shortcuts Created
|
||||
- **Start Menu:** `%ProgramMenu%\Wild Dragon\Dragon-ISO.lnk`
|
||||
- **Desktop:** `%UserProfile%\Desktop\Dragon-ISO.lnk`
|
||||
- Both use stable GUIDs for reliable uninstall tracking
|
||||
|
||||
### Uninstall Behavior
|
||||
- Standard Windows uninstall via Add/Remove Programs
|
||||
- Removes all application files and shortcuts
|
||||
- Removes registry entries under `Software\Wild Dragon\Dragon-ISO`
|
||||
- **Preserves:** User config files in `%APPDATA%\Dragon-ISO\` (for future upgrades)
|
||||
|
||||
### Add/Remove Programs Entry
|
||||
Users see:
|
||||
- **Name:** Dragon-ISO
|
||||
- **Version:** 1.0.0.0
|
||||
- **Publisher:** Wild Dragon LLC
|
||||
- **Help:** Link to Forge repository
|
||||
- **About:** Link to wilddragon.net
|
||||
- **Contact:** support@wilddragon.net
|
||||
- **Icon:** Dragon-ISO app icon
|
||||
|
||||
---
|
||||
|
||||
## Build Process
|
||||
|
||||
### Prerequisites
|
||||
- .NET 8 SDK
|
||||
- WiX Toolset v5 (`dotnet workload install wix`)
|
||||
|
||||
### Build Steps
|
||||
|
||||
1. **Publish the application:**
|
||||
```powershell
|
||||
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj `
|
||||
-c Release -r win-x64 `
|
||||
-o publish/Dragon-ISO
|
||||
```
|
||||
|
||||
2. **Build the MSI:**
|
||||
```powershell
|
||||
dotnet build installer/Dragon-ISO.Installer.wixproj -c Release
|
||||
```
|
||||
|
||||
3. **Output:**
|
||||
- Location: `installer/bin/Release/Dragon-ISO-Setup-1.0.0.0.msi`
|
||||
- Ready for end-user distribution
|
||||
|
||||
### Version Management
|
||||
- MSI version is driven by the app's `.csproj` `<Version>` tag
|
||||
- Update version once, builds auto-propagate to the MSI filename
|
||||
- UpgradeCode remains constant across versions (enables upgrade detection)
|
||||
|
||||
### CI/CD Integration
|
||||
- Update existing `.forgejo/workflows/` to use new project paths
|
||||
- Scripts already handle publish → build → sign → release
|
||||
- Just update path references and output filename
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites & Runtime Detection
|
||||
|
||||
### .NET 8 Desktop Runtime
|
||||
- **Check:** Registry lookup for installed .NET 8 Desktop Runtime
|
||||
- **If missing:** Display warning dialog with link to download
|
||||
- **Install behavior:** Continue installation anyway (user can install .NET 8 later)
|
||||
- **At app launch:** App checks again; shows MessageBox with install link if still missing
|
||||
|
||||
### NDI 6 Runtime
|
||||
- **Check:** Environment variable `NDI_RUNTIME_DIR_V6`
|
||||
- **If missing:** Display warning dialog
|
||||
- **Install behavior:** Continue installation anyway
|
||||
- **At app launch:** App checks again; shows MessageBox with install link if still missing
|
||||
|
||||
**Rationale:** Allows staged deployment where IT can install Dragon-ISO first, then NDI/runtime later. End users get clear guidance at both install-time and app-launch time.
|
||||
|
||||
---
|
||||
|
||||
## Major Upgrade Behavior
|
||||
|
||||
The installer detects when a newer version is already installed and:
|
||||
1. **Prevents downgrade:** Blocks installation of older versions with a clear error message
|
||||
2. **In-place upgrade:** Newer installs replace older ones seamlessly
|
||||
3. **Config preservation:** User configuration in `%APPDATA%\Dragon-ISO\` is preserved
|
||||
4. **UpgradeCode:** Constant GUID ensures upgrade detection works (TeamsISO → Dragon-ISO, and future versions)
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Installation Failures
|
||||
- WiX standard rollback behavior: if installation fails, all changes are undone
|
||||
- Clear error messages for common issues (insufficient permissions, disk space, etc.)
|
||||
|
||||
### Shortcut Creation
|
||||
- If shortcut creation fails, installation continues (non-blocking)
|
||||
- User can manually create shortcuts from `Program Files\Wild Dragon\Dragon-ISO\Dragon-ISO.exe`
|
||||
|
||||
### Registry Operations
|
||||
- If registry write fails, installation continues (non-blocking)
|
||||
- ARP entry may be incomplete but app is still functional
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] MSI builds successfully with new branding
|
||||
- [ ] Install to clean system works end-to-end
|
||||
- [ ] Shortcuts appear in Start Menu and on Desktop
|
||||
- [ ] ARP entry shows correct metadata
|
||||
- [ ] Uninstall removes all files and shortcuts
|
||||
- [ ] Upgrade from TeamsISO to Dragon-ISO works
|
||||
- [ ] .NET 8 runtime detection shows warning if missing
|
||||
- [ ] NDI runtime detection shows warning if missing
|
||||
- [ ] App launches after installation
|
||||
- [ ] Help/About links in ARP entry work
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Out of Scope)
|
||||
|
||||
- Code signing for published MSI
|
||||
- Automatic update checks via Squirrel.Windows
|
||||
- Per-user installation option
|
||||
- Silent/unattended install mode (for enterprise deployment)
|
||||
|
|
@ -1,27 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Dragon-ISO — MSI installer (WiX v5)
|
||||
TeamsISO — MSI installer (WiX v5)
|
||||
|
||||
Produces: Dragon-ISO-Setup-<Version>.msi (per-machine install).
|
||||
Produces: TeamsISO-Setup-<Version>.msi (per-machine install).
|
||||
|
||||
Build:
|
||||
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj -c Release -r win-x64 -p:SelfContained=false -o publish/Dragon-ISO
|
||||
dotnet build installer/Dragon-ISO.Installer.wixproj -c Release
|
||||
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj -c Release -r win-x64 (no self contained) -o publish/TeamsISO
|
||||
dotnet build installer/TeamsISO.Installer.wixproj -c Release
|
||||
|
||||
Runtime expectations:
|
||||
- .NET 8 Desktop runtime present on target (framework-dependent build)
|
||||
- NDI 6 Runtime present — checked in NdiRuntimeDirV6Search; absence WARNS
|
||||
- NDI 6 Runtime present — checked in CheckNdiRuntime; absence WARNS
|
||||
but does not block install (operators can install NDI after the app)
|
||||
|
||||
Exe filename note:
|
||||
Dragon-ISO.App.csproj sets AssemblyName=DragonISO (no hyphen — CLR
|
||||
assembly names cannot contain hyphens). The published executable is
|
||||
therefore DragonISO.exe. Shortcut targets reference DragonISO.exe.
|
||||
-->
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
|
||||
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
||||
|
||||
<Package Name="Dragon-ISO"
|
||||
<Package Name="TeamsISO"
|
||||
Manufacturer="Wild Dragon LLC"
|
||||
Version="1.0.0.0"
|
||||
UpgradeCode="9F4A8B2C-1D3E-4A5B-9C6D-8E7F0A1B2C3D"
|
||||
|
|
@ -37,9 +32,9 @@
|
|||
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."
|
||||
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."
|
||||
Manufacturer="Wild Dragon LLC"
|
||||
Keywords="Dragon-ISO, NDI, Microsoft Teams, ISO recording, broadcast, live production, vMix, OBS, switcher, Wild Dragon" />
|
||||
Keywords="NDI, Microsoft Teams, ISO recording, broadcast, live production, vMix, OBS, switcher, Wild Dragon" />
|
||||
|
||||
<!--
|
||||
MajorUpgrade: a newer install replaces an older one in-place. We
|
||||
|
|
@ -47,13 +42,13 @@
|
|||
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."
|
||||
<MajorUpgrade DowngradeErrorMessage="A newer version of TeamsISO is already installed. Uninstall it before installing this older version."
|
||||
Schedule="afterInstallInitialize" />
|
||||
|
||||
<!--
|
||||
Single MSI feature; users see only the install/uninstall screens.
|
||||
-->
|
||||
<Feature Id="Main" Title="Dragon-ISO" Level="1">
|
||||
<Feature Id="Main" Title="TeamsISO" Level="1">
|
||||
<ComponentGroupRef Id="ApplicationFiles" />
|
||||
<ComponentGroupRef Id="Shortcuts" />
|
||||
<ComponentGroupRef Id="DesktopShortcut" />
|
||||
|
|
@ -61,51 +56,37 @@
|
|||
</Feature>
|
||||
|
||||
<!--
|
||||
Minimal install UI: Welcome/License -> Progress -> Finish.
|
||||
No directory picker; installs to Program Files\Wild Dragon\Dragon-ISO.
|
||||
Friendly install UI. WixToolset.UI.wixext provides several flavors;
|
||||
WixUI_InstallDir lets the user pick the directory.
|
||||
-->
|
||||
<ui:WixUI Id="WixUI_Minimal" />
|
||||
<ui:WixUI Id="WixUI_InstallDir" />
|
||||
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
|
||||
|
||||
<!--
|
||||
Add/Remove Programs metadata. ARPHELPLINK is the "Help" link; ARPURLINFOABOUT
|
||||
is the manufacturer/about link; ARPCONTACT is the support contact shown
|
||||
when the user clicks "Support information" from the ARP entry. ARPCOMMENTS
|
||||
is the long description displayed in some Settings -> Apps surfaces.
|
||||
is the long description displayed in some Settings → Apps surfaces.
|
||||
-->
|
||||
<Property Id="ARPHELPLINK" Value="https://forge.wilddragon.net/zgaetano/dragon-iso" />
|
||||
<Property Id="ARPHELPLINK" Value="https://forge.wilddragon.net/zgaetano/teamsiso" />
|
||||
<Property Id="ARPURLINFOABOUT" Value="https://wilddragon.net" />
|
||||
<Property Id="ARPCONTACT" Value="Wild Dragon LLC — support@wilddragon.net" />
|
||||
<Property Id="ARPCOMMENTS" Value="Dragon-ISO turns Microsoft Teams' raw NDI broadcast into clean, normalized, per-participant NDI sources for ingestion by a live-production switcher (vMix, OBS, Ross, hardware capture). Each participant gets an individually-addressable source with configurable framerate, resolution, aspect mode, and audio routing." />
|
||||
<!-- ARPNOMODIFY is set by WixUI_Minimal. Do not redeclare. -->
|
||||
<Property Id="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="ARPNOREPAIR" Value="1" />
|
||||
|
||||
<!--
|
||||
ARP icon: references the same .ico the WPF host uses. WiX requires the
|
||||
ARP icon — references the same .ico the WPF host uses. WiX requires the
|
||||
icon resource to live next to the wxs OR be reachable at build time;
|
||||
we point at the source copy under src/Dragon-ISO.App/Assets so the icon
|
||||
we point at the published copy under src/TeamsISO.App/Assets so the icon
|
||||
embedded in the MSI matches the icon in the running exe.
|
||||
-->
|
||||
<Icon Id="DragonISOIcon" SourceFile="$(var.AssetsDir)Dragon-ISO.ico" />
|
||||
<Property Id="ARPPRODUCTICON" Value="DragonISOIcon" />
|
||||
|
||||
<!--
|
||||
.NET 8 Desktop Runtime detection. The .NET apphost will surface a
|
||||
"framework not found" dialog naturally if the runtime is absent;
|
||||
this property is available for future conditional logic.
|
||||
VBScript-based install-time dialogs are deprecated in WiX v5 / Windows;
|
||||
rewriting in C++ is overkill for a soft warning on a soft dependency.
|
||||
-->
|
||||
<Property Id="DOTNET8DESKTOPRUNTIME" Value="0">
|
||||
<RegistrySearch Id="DotNet8DesktopRuntimeSearch"
|
||||
Root="HKLM"
|
||||
Key="SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App\8.0.0"
|
||||
Name="Version"
|
||||
Type="raw" />
|
||||
</Property>
|
||||
<Icon Id="TeamsISOIcon" SourceFile="$(var.AssetsDir)teamsiso.ico" />
|
||||
<Property Id="ARPPRODUCTICON" Value="TeamsISOIcon" />
|
||||
|
||||
<!--
|
||||
NDI Runtime detection. We check for NDI_RUNTIME_DIR_V6 in the system
|
||||
environment block. Missing -> warn during install, don't block. The
|
||||
environment block. Missing → warn during install, don't block. The
|
||||
engine surfaces a clear MessageBox with an install-NDI link at first
|
||||
launch if the runtime really isn't there.
|
||||
-->
|
||||
|
|
@ -126,11 +107,11 @@
|
|||
-->
|
||||
|
||||
<!--
|
||||
Install layout under Program Files\Wild Dragon\Dragon-ISO.
|
||||
Install layout under Program Files\Wild Dragon\TeamsISO.
|
||||
-->
|
||||
<StandardDirectory Id="ProgramFiles64Folder">
|
||||
<Directory Id="ManufacturerFolder" Name="Wild Dragon">
|
||||
<Directory Id="INSTALLFOLDER" Name="Dragon-ISO" />
|
||||
<Directory Id="INSTALLFOLDER" Name="TeamsISO" />
|
||||
</Directory>
|
||||
</StandardDirectory>
|
||||
|
||||
|
|
@ -148,33 +129,30 @@
|
|||
</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 Dragon-ISO inherit the launching
|
||||
empirically 2026-05-16 — letting TeamsISO 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"
|
||||
<Shortcut Id="StartMenuTeamsISO"
|
||||
Name="TeamsISO"
|
||||
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
|
||||
Target="[INSTALLFOLDER]DragonISO.exe"
|
||||
Target="[INSTALLFOLDER]TeamsISO.exe"
|
||||
WorkingDirectory="INSTALLFOLDER"
|
||||
Icon="DragonISOIcon" />
|
||||
Icon="TeamsISOIcon" />
|
||||
<!-- Required by ICE64: Start Menu folder must be cleaned on uninstall. -->
|
||||
<RemoveFolder Id="RemoveWildDragonStartMenuFolder"
|
||||
Directory="WildDragonStartMenuFolder"
|
||||
On="uninstall" />
|
||||
<RegistryValue Root="HKCU"
|
||||
Key="Software\Wild Dragon\Dragon-ISO"
|
||||
Key="Software\Wild Dragon\TeamsISO"
|
||||
Name="StartMenuShortcut"
|
||||
Type="integer"
|
||||
Value="1"
|
||||
|
|
@ -185,14 +163,14 @@
|
|||
<StandardDirectory Id="DesktopFolder" />
|
||||
<ComponentGroup Id="DesktopShortcut" Directory="DesktopFolder">
|
||||
<Component Id="DesktopShortcutComponent" Guid="*">
|
||||
<Shortcut Id="DesktopDragonISO"
|
||||
Name="Dragon-ISO"
|
||||
<Shortcut Id="DesktopTeamsISO"
|
||||
Name="TeamsISO"
|
||||
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
|
||||
Target="[INSTALLFOLDER]DragonISO.exe"
|
||||
Target="[INSTALLFOLDER]TeamsISO.exe"
|
||||
WorkingDirectory="INSTALLFOLDER"
|
||||
Icon="DragonISOIcon" />
|
||||
Icon="TeamsISOIcon" />
|
||||
<RegistryValue Root="HKCU"
|
||||
Key="Software\Wild Dragon\Dragon-ISO"
|
||||
Key="Software\Wild Dragon\TeamsISO"
|
||||
Name="DesktopShortcut"
|
||||
Type="integer"
|
||||
Value="1"
|
||||
|
|
@ -201,14 +179,14 @@
|
|||
</ComponentGroup>
|
||||
|
||||
<!--
|
||||
ARP icon registry entry. Optional: the MSI auto-fills most ARP
|
||||
fields from the Package element. We only need to store the install
|
||||
path for diagnostic / uninstall tooling.
|
||||
ARP icon registry entry. Optional — the MSI auto-fills most ARP
|
||||
fields from the Package element. We only need to point at the
|
||||
executable for the ARP icon.
|
||||
-->
|
||||
<ComponentGroup Id="ArpEntry" Directory="INSTALLFOLDER">
|
||||
<Component Id="ArpIconRegistry" Guid="*">
|
||||
<RegistryValue Root="HKLM"
|
||||
Key="Software\Wild Dragon\Dragon-ISO"
|
||||
Key="Software\Wild Dragon\TeamsISO"
|
||||
Name="InstallPath"
|
||||
Type="string"
|
||||
Value="[INSTALLFOLDER]"
|
||||
|
|
@ -217,4 +195,4 @@
|
|||
</ComponentGroup>
|
||||
|
||||
</Package>
|
||||
</Wix>
|
||||
</Wix>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<PropertyGroup>
|
||||
<OutputType>Package</OutputType>
|
||||
<OutputName>Dragon-ISO-Setup-$(Version)</OutputName>
|
||||
<OutputName>TeamsISO-Setup-$(Version)</OutputName>
|
||||
|
||||
<!-- 64-bit MSI; suppresses ICE80 on components in Program Files (x64). -->
|
||||
<Platform>x64</Platform>
|
||||
|
|
@ -10,15 +10,15 @@
|
|||
|
||||
<!--
|
||||
Built artifact location. The installer expects a published build of
|
||||
Dragon-ISO.App rooted here. CI / local script:
|
||||
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj
|
||||
-c Release -r win-x64 -self-contained false
|
||||
-o $(SolutionDir)publish/Dragon-ISO
|
||||
TeamsISO.App rooted here. CI / local script:
|
||||
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj
|
||||
-c Release -r win-x64 (with self contained false)
|
||||
-o $(SolutionDir)publish/TeamsISO
|
||||
-->
|
||||
<PublishDir>$(MSBuildThisFileDirectory)..\publish\Dragon-ISO\</PublishDir>
|
||||
<PublishDir>$(MSBuildThisFileDirectory)..\publish\TeamsISO\</PublishDir>
|
||||
|
||||
<!-- Pass MSBuild values into WiX preprocessor. -->
|
||||
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\Dragon-ISO.App\Assets\</DefineConstants>
|
||||
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\TeamsISO.App\Assets\</DefineConstants>
|
||||
|
||||
<!-- Code-signing hook (set externally for release builds; left empty for dev). -->
|
||||
<SignOutput Condition=" '$(SignOutput)' == '' ">false</SignOutput>
|
||||
|
|
@ -32,4 +32,4 @@
|
|||
<PackageReference Include="WixToolset.UI.wixext" Version="5.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
namespace DragonISO.App.Services;
|
||||
|
||||
// /teams/* route handlers — UIAutomation-driven in-call controls.
|
||||
//
|
||||
// POST /teams/mute → InvokeTeams(ToggleMute, "mute")
|
||||
// POST /teams/camera → InvokeTeams(ToggleCamera, "camera")
|
||||
// POST /teams/leave → InvokeTeams(LeaveCall, "leave")
|
||||
// POST /teams/share → InvokeTeams(OpenShareTray, "share")
|
||||
// POST /teams/raise-hand → InvokeTeams(ToggleRaiseHand, "raise-hand")
|
||||
public sealed partial class ControlSurfaceServer
|
||||
{
|
||||
private object InvokeTeams(Func<TeamsControlBridge.InvokeResult> invoke, string action)
|
||||
{
|
||||
var result = invoke();
|
||||
return new
|
||||
{
|
||||
ok = result == TeamsControlBridge.InvokeResult.Invoked,
|
||||
action,
|
||||
result = result.ToString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<Window x:Class="DragonISO.App.AboutWindow"
|
||||
<Window x:Class="TeamsISO.App.AboutWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
Title="About DragonISO"
|
||||
Icon="/Assets/DragonISO.ico"
|
||||
Title="About TeamsISO"
|
||||
Icon="/Assets/teamsiso.ico"
|
||||
Width="460" Height="500"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
WindowStyle="None"
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="About DragonISO"
|
||||
<TextBlock Text="About TeamsISO"
|
||||
Style="{StaticResource Wd.Text.Caption}"
|
||||
Margin="20,12,0,0"
|
||||
VerticalAlignment="Center"/>
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
Margin="0,0,0,16"
|
||||
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||
|
||||
<TextBlock Text="DragonISO"
|
||||
<TextBlock Text="TeamsISO"
|
||||
Style="{StaticResource Wd.Text.Title}"
|
||||
FontSize="28"
|
||||
HorizontalAlignment="Center"/>
|
||||
|
|
@ -146,12 +146,12 @@
|
|||
Click="OnOpenLogs"
|
||||
Padding="14,6"
|
||||
Margin="0,0,8,0"
|
||||
ToolTip="Open %LOCALAPPDATA%\DragonISO\Logs in Explorer"/>
|
||||
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Logs in Explorer"/>
|
||||
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||
Content="Notes"
|
||||
Click="OnOpenNotes"
|
||||
Padding="14,6"
|
||||
ToolTip="Open %LOCALAPPDATA%\DragonISO\Notes in Explorer"/>
|
||||
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Notes in Explorer"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Windows;
|
||||
using System.Windows.Navigation;
|
||||
using DragonISO.App.Services;
|
||||
using DragonISO.Engine.NdiInterop;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.Engine.NdiInterop;
|
||||
|
||||
namespace DragonISO.App;
|
||||
namespace TeamsISO.App;
|
||||
|
||||
/// <summary>
|
||||
/// Modal "About" dialog. Surfaces enough info that a user filing a support ticket
|
||||
|
|
@ -65,7 +65,7 @@ public partial class AboutWindow : Window
|
|||
/// <summary>
|
||||
/// Quick-jump: open a path in Explorer. Creates the directory if missing
|
||||
/// (operator might click "Recordings" before any have been made). Best-
|
||||
/// effort — Explorer launch failures don't surface a dialog.
|
||||
/// effort — Explorer launch failures don't surface a dialog.
|
||||
/// </summary>
|
||||
private static void OpenInExplorer(string path)
|
||||
{
|
||||
|
|
@ -87,18 +87,18 @@ public partial class AboutWindow : Window
|
|||
private void OnOpenLogs(object sender, RoutedEventArgs e) =>
|
||||
OpenInExplorer(Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Dragon-ISO", "Logs"));
|
||||
"TeamsISO", "Logs"));
|
||||
|
||||
// OnOpenRecordings removed — recording feature axed.
|
||||
// OnOpenRecordings removed — recording feature axed.
|
||||
|
||||
private void OnOpenNotes(object sender, RoutedEventArgs e) =>
|
||||
OpenInExplorer(Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Dragon-ISO", "Notes"));
|
||||
"TeamsISO", "Notes"));
|
||||
|
||||
/// <summary>
|
||||
/// Build the diagnostic bundle and tell the operator where it landed. The
|
||||
/// bundle is just zipped logs / config / presets — no screenshots, no
|
||||
/// bundle is just zipped logs / config / presets — no screenshots, no
|
||||
/// memory dumps. Intended to be attached to a bug report.
|
||||
/// </summary>
|
||||
private void OnExportDiagnostics(object sender, RoutedEventArgs e)
|
||||
|
|
@ -109,7 +109,7 @@ public partial class AboutWindow : Window
|
|||
var open = MessageBox.Show(
|
||||
this,
|
||||
$"Diagnostic bundle written to:\n\n{path}\n\nOpen the containing folder?",
|
||||
"Dragon-ISO — Diagnostics exported",
|
||||
"TeamsISO — Diagnostics exported",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Information);
|
||||
if (open == MessageBoxResult.Yes)
|
||||
|
|
@ -131,7 +131,7 @@ public partial class AboutWindow : Window
|
|||
MessageBox.Show(
|
||||
this,
|
||||
$"Diagnostic export failed.\n\n{ex.Message}",
|
||||
"Dragon-ISO — Diagnostic export",
|
||||
"TeamsISO — Diagnostic export",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
|
|
@ -158,7 +158,7 @@ public partial class AboutWindow : Window
|
|||
$"{result.Message}\n\n" +
|
||||
$"You're on {result.CurrentVersion}; latest is {result.LatestTag}.\n\n" +
|
||||
"Open the releases page to download the new MSI?",
|
||||
"Dragon-ISO — Update available",
|
||||
"TeamsISO — Update available",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Information);
|
||||
if (open == MessageBoxResult.Yes)
|
||||
|
|
@ -169,7 +169,7 @@ public partial class AboutWindow : Window
|
|||
MessageBox.Show(
|
||||
this,
|
||||
result.Message ?? "You're on the latest release.",
|
||||
"Dragon-ISO — Up to date",
|
||||
"TeamsISO — Up to date",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
break;
|
||||
|
|
@ -179,7 +179,7 @@ public partial class AboutWindow : Window
|
|||
MessageBox.Show(
|
||||
this,
|
||||
$"Couldn't check for updates.\n\n{result.Message}",
|
||||
"Dragon-ISO — Update check failed",
|
||||
"TeamsISO — Update check failed",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
break;
|
||||
|
|
@ -193,7 +193,7 @@ public partial class AboutWindow : Window
|
|||
|
||||
/// <summary>
|
||||
/// Open the company site in the default browser. We intentionally use the
|
||||
/// shell's URL handler rather than a tab inside the app — this is a
|
||||
/// shell's URL handler rather than a tab inside the app — this is a
|
||||
/// "tell me more" link, not a workflow.
|
||||
/// </summary>
|
||||
private void OnWebsiteClick(object sender, RoutedEventArgs e)
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using DragonISO.App.Services;
|
||||
using DragonISO.App.ViewModels;
|
||||
using DragonISO.Engine.Controller;
|
||||
using DragonISO.Engine.Interop;
|
||||
using DragonISO.Engine.NdiInterop;
|
||||
using DragonISO.Engine.Persistence;
|
||||
using DragonISO.Engine.Pipeline;
|
||||
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;
|
||||
|
||||
namespace DragonISO.App;
|
||||
namespace TeamsISO.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 DragonISO.App;
|
|||
public partial class App
|
||||
{
|
||||
/// <summary>
|
||||
/// Acquire the per-user named mutex that gates a single Dragon-ISO
|
||||
/// instance per Windows user. Two Dragon-ISOs on the same machine for
|
||||
/// Acquire the per-user named mutex that gates a single TeamsISO
|
||||
/// instance per Windows user. Two TeamsISOs on the same machine for
|
||||
/// the same user race over the NDI finder, the NDI senders, and
|
||||
/// %APPDATA%\Dragon-ISO\config.json — none of those are safe to share.
|
||||
/// %APPDATA%\TeamsISO\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.DragonISO.BringToFront");
|
||||
var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.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.DragonISO.BringToFront");
|
||||
var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.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(
|
||||
"Dragon-ISO could not initialize the NDI runtime.\n\n" +
|
||||
"TeamsISO could not initialize the NDI runtime.\n\n" +
|
||||
"Install the NDI Runtime from https://ndi.video/tools/ and try again.\n\n" +
|
||||
"Details: " + ex.Message,
|
||||
"Dragon-ISO — NDI runtime missing",
|
||||
"TeamsISO — 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),
|
||||
"Dragon-ISO", "config.json");
|
||||
"TeamsISO", "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
|
||||
/// Dragon-ISO" experience. Fire-and-forget — a slow Teams launch must
|
||||
/// not delay Dragon-ISO's own window from appearing.
|
||||
/// TeamsISO" experience. Fire-and-forget — a slow Teams launch must
|
||||
/// not delay TeamsISO'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 Dragon-ISO" rule
|
||||
// on, hide it now so the operator's "I only see TeamsISO" rule
|
||||
// applies even when Teams was launched externally.
|
||||
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
|
||||
}
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DragonISO.App;
|
||||
namespace TeamsISO.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%\Dragon-ISO\Logs) and then shows the user a
|
||||
// daily file at %LOCALAPPDATA%\TeamsISO\Logs) and then shows the user a
|
||||
// dialog with the log path so they can attach it to a bug report.
|
||||
//
|
||||
// We deliberately don't catch StackOverflowException or
|
||||
// ExecutionEngineException — both are uncatchable in modern .NET; if one
|
||||
// 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),
|
||||
"Dragon-ISO", "Logs");
|
||||
"TeamsISO", "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
|
||||
? "Dragon-ISO encountered an unrecoverable error and will exit."
|
||||
: "Dragon-ISO encountered an error.";
|
||||
? "TeamsISO encountered an unrecoverable error and will exit."
|
||||
: "TeamsISO encountered an error.";
|
||||
var details = ex?.GetType().Name + ": " + (ex?.Message ?? "(no details)");
|
||||
var body =
|
||||
heading + "\n\n" +
|
||||
details + "\n\n" +
|
||||
$"A full diagnostic log has been written to:\n{LogDirectory}\n\n" +
|
||||
"Attach the most recent file from that directory to your bug report.";
|
||||
MessageBox.Show(body, "Dragon-ISO — Error",
|
||||
MessageBox.Show(body, "TeamsISO — Error",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
catch
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using DragonISO.App.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace DragonISO.App;
|
||||
namespace TeamsISO.App;
|
||||
|
||||
// Background update check, throttled to once per 24h. Fire-and-forget
|
||||
// so a slow / offline update server never delays startup. Surfaces a
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<Application x:Class="DragonISO.App.App"
|
||||
<Application x:Class="TeamsISO.App.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Application.Resources>
|
||||
|
|
@ -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 DragonISO.App.ViewModels;
|
||||
using DragonISO.Engine.Controller;
|
||||
using DragonISO.Engine.Logging;
|
||||
using DragonISO.Engine.NdiInterop;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Logging;
|
||||
using TeamsISO.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 DragonISO.App;
|
||||
namespace TeamsISO.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 Dragon-ISO on the same machine, while one
|
||||
/// different Windows users can each run TeamsISO on the same machine, while one
|
||||
/// user can't spawn duplicate instances that would contend over the NDI runtime
|
||||
/// and the shared %APPDATA%\Dragon-ISO\config.json.
|
||||
/// and the shared %APPDATA%\TeamsISO\config.json.
|
||||
///
|
||||
/// The "Global\" prefix puts the named object in the system-wide namespace
|
||||
/// (not session-local or integrity-isolated). This matters because when an
|
||||
/// admin user has UAC effectively disabled, launches from different parents
|
||||
/// (elevated File Explorer, non-elevated shell, etc.) can land in slightly
|
||||
/// different security contexts. A "Local\" mutex was being created in
|
||||
/// different views per integrity level on some boxes, letting two Dragon-ISO
|
||||
/// instances run concurrently — the second's REST surface couldn't bind port
|
||||
/// different views per integrity level on some boxes, letting two TeamsISO
|
||||
/// instances run concurrently — the second's REST surface couldn't bind port
|
||||
/// 9755 (already held) and its Serilog file sink couldn't open the daily log
|
||||
/// (already held with shared=false), producing a window that looked like
|
||||
/// the app but had no engine attached. Global\ closes that gap.
|
||||
/// </summary>
|
||||
private static readonly string SingleInstanceMutexName =
|
||||
$"Global\\WildDragon.DragonISO.SingleInstance.{Environment.UserName}";
|
||||
$"Global\\WildDragon.TeamsISO.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 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;
|
||||
private TeamsISO.App.Services.ControlSurfaceServer? _controlSurface;
|
||||
private TeamsISO.App.Services.OscBridge? _oscBridge;
|
||||
// _diskSpaceWatcher removed — only existed to auto-disable recording at low free space.
|
||||
private TeamsISO.App.Services.TrayIconHost? _trayIcon;
|
||||
|
||||
/// <summary>
|
||||
/// REST control surface lifetime. Lives on App so the settings VM can flip
|
||||
/// it on/off without us plumbing yet another DI dependency through MainViewModel.
|
||||
/// Null between process startup and the OnStartup wire-up, and after OnExit.
|
||||
/// </summary>
|
||||
internal DragonISO.App.Services.ControlSurfaceServer? ControlSurface => _controlSurface;
|
||||
internal TeamsISO.App.Services.ControlSurfaceServer? ControlSurface => _controlSurface;
|
||||
|
||||
/// <summary>OSC bridge (UDP) lifetime — same lifecycle pattern as the REST surface.</summary>
|
||||
internal DragonISO.App.Services.OscBridge? OscBridge => _oscBridge;
|
||||
/// <summary>OSC bridge (UDP) lifetime — same lifecycle pattern as the REST surface.</summary>
|
||||
internal TeamsISO.App.Services.OscBridge? OscBridge => _oscBridge;
|
||||
|
||||
/// <summary>Tray-icon host. Exposed so the settings VM can flip the minimize-to-tray toggle.</summary>
|
||||
internal DragonISO.App.Services.TrayIconHost? TrayIcon => _trayIcon;
|
||||
internal TeamsISO.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%\Dragon-ISO\startup-trace.log.
|
||||
// %LOCALAPPDATA%\TeamsISO\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 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
|
||||
// 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
|
||||
// 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 { DragonISO.App.Services.ThemeManager.Current.Apply(); StartupTrace.Write("ThemeManager.Apply OK"); }
|
||||
try { TeamsISO.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(
|
||||
"DragonISO.App starting up. Build: {Version}. Process: {Pid}.",
|
||||
"TeamsISO.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(
|
||||
"Dragon-ISO failed to start.\n\nDetails: " + ex,
|
||||
"Dragon-ISO — startup error",
|
||||
"TeamsISO failed to start.\n\nDetails: " + ex,
|
||||
"TeamsISO — startup error",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Error);
|
||||
Shutdown(1);
|
||||
|
|
@ -204,7 +204,7 @@ public partial class App : Application
|
|||
}
|
||||
|
||||
// De-elevation helpers (ShouldDeElevate, TryDeElevateAndExit, the
|
||||
// Dragon-ISO_RELAUNCHED env var) were removed 2026-05-16. The whole
|
||||
// TEAMSISO_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 Dragon-ISO and clicking Presets → select →
|
||||
/// <c>--apply-preset NAME</c> — apply the named preset once participants
|
||||
/// populate. Equivalent to running TeamsISO and clicking Presets → select →
|
||||
/// Apply, but driven from a desktop shortcut.
|
||||
/// Unrecognized flags are silently ignored — operators using shortcut.lnk
|
||||
/// Unrecognized flags are silently ignored — operators using shortcut.lnk
|
||||
/// files don't need to fight argument parsers.
|
||||
/// </summary>
|
||||
private void ApplyCommandLineArgs(string[] args)
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
|
@ -1,8 +1,8 @@
|
|||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace DragonISO.App.Converters;
|
||||
namespace TeamsISO.App.Converters;
|
||||
|
||||
[ValueConversion(typeof(bool), typeof(Visibility))]
|
||||
public sealed class BoolToVisibilityConverter : IValueConverter
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
using System.Collections;
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace DragonISO.App.Converters;
|
||||
namespace TeamsISO.App.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a collection (or its count) to <see cref="Visibility"/>. Pass
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using DragonISO.Engine.Domain;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace DragonISO.App.Converters;
|
||||
namespace TeamsISO.App.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Renders engine enum values into operator-friendly strings.
|
||||
|
|
@ -39,7 +39,7 @@ public sealed class EnumDescriptionConverter : IValueConverter
|
|||
},
|
||||
AudioMode m => m switch
|
||||
{
|
||||
AudioMode.Auto => "Auto (isolated → mixed fallback)",
|
||||
AudioMode.Auto => "Auto (isolated → mixed fallback)",
|
||||
AudioMode.Isolated => "Isolated",
|
||||
AudioMode.Mixed => "Mixed",
|
||||
_ => m.ToString()
|
||||
|
|
@ -1,26 +1,26 @@
|
|||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace DragonISO.App.Converters;
|
||||
namespace TeamsISO.App.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a display name to up to two uppercase initials for an avatar bubble.
|
||||
/// "Brendon Power" → "BP". "(Local)" → "L". Falls back to "·" for empty inputs.
|
||||
/// "Brendon Power" → "BP". "(Local)" → "L". Falls back to "·" for empty inputs.
|
||||
/// </summary>
|
||||
public sealed class InitialsConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
var s = value as string;
|
||||
if (string.IsNullOrWhiteSpace(s)) return "·";
|
||||
if (string.IsNullOrWhiteSpace(s)) return "·";
|
||||
|
||||
// Strip surrounding parens / punctuation that would otherwise become
|
||||
// useless initials (e.g. "(Local)" should yield "L", not "(").
|
||||
var cleaned = new string(s.Where(c => char.IsLetterOrDigit(c) || char.IsWhiteSpace(c)).ToArray()).Trim();
|
||||
if (cleaned.Length == 0) return "·";
|
||||
if (cleaned.Length == 0) return "·";
|
||||
|
||||
var parts = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0) return "·";
|
||||
if (parts.Length == 0) return "·";
|
||||
if (parts.Length == 1) return char.ToUpperInvariant(parts[0][0]).ToString();
|
||||
return $"{char.ToUpperInvariant(parts[0][0])}{char.ToUpperInvariant(parts[^1][0])}";
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace DragonISO.App.Converters;
|
||||
namespace TeamsISO.App.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Maps an audio level (0.0–1.0) to an opacity for a single audio-meter
|
||||
/// Maps an audio level (0.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)
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace DragonISO.App.Converters;
|
||||
namespace TeamsISO.App.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Tiny utility converter — null or empty string ⇒ Collapsed, otherwise
|
||||
/// Tiny utility converter — null or empty string ⇒ Collapsed, otherwise
|
||||
/// Visible. Used by the v2 command palette's optional shortcut chip
|
||||
/// (e.g. "Ctrl+R") so rows without a bound shortcut don't render the
|
||||
/// empty pill outline.
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<Window x:Class="DragonISO.App.HelpWindow"
|
||||
<Window x:Class="TeamsISO.App.HelpWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
Title="Help"
|
||||
Icon="/Assets/DragonISO.ico"
|
||||
Icon="/Assets/teamsiso.ico"
|
||||
Width="540" Height="560"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
WindowStyle="None"
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
|
||||
<!-- Header -->
|
||||
<StackPanel Grid.Row="1" Margin="0,16,0,16">
|
||||
<TextBlock Text="DragonISO cheat sheet"
|
||||
<TextBlock Text="TeamsISO cheat sheet"
|
||||
Style="{StaticResource Wd.Text.Title}"/>
|
||||
<TextBlock Text="Keyboard shortcuts, file locations, and quick links."
|
||||
Style="{StaticResource Wd.Text.Subtle}"
|
||||
|
|
@ -148,22 +148,22 @@
|
|||
LineHeight="20">
|
||||
<Run Text="Engine config" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
|
||||
<LineBreak/>
|
||||
<Run Text="%APPDATA%\DragonISO\config.json"/>
|
||||
<Run Text="%APPDATA%\TeamsISO\config.json"/>
|
||||
<LineBreak/>
|
||||
<LineBreak/>
|
||||
<Run Text="Operator presets" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
|
||||
<LineBreak/>
|
||||
<Run Text="%LOCALAPPDATA%\DragonISO\presets.json"/>
|
||||
<Run Text="%LOCALAPPDATA%\TeamsISO\presets.json"/>
|
||||
<LineBreak/>
|
||||
<LineBreak/>
|
||||
<Run Text="Diagnostic logs (rolling daily)" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
|
||||
<LineBreak/>
|
||||
<Run Text="%LOCALAPPDATA%\DragonISO\Logs\"/>
|
||||
<Run Text="%LOCALAPPDATA%\TeamsISO\Logs\"/>
|
||||
<LineBreak/>
|
||||
<LineBreak/>
|
||||
<Run Text="Recordings (default)" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
|
||||
<LineBreak/>
|
||||
<Run Text="%USERPROFILE%\Videos\DragonISO\<date>\"/>
|
||||
<Run Text="%USERPROFILE%\Videos\TeamsISO\<date>\"/>
|
||||
<LineBreak/>
|
||||
<LineBreak/>
|
||||
<Run Text="NDI Access Manager config" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
|
||||
|
|
@ -197,7 +197,7 @@
|
|||
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||
TextDecorations="None"
|
||||
Click="OnDocsClick">
|
||||
forge.wilddragon.net/zgaetano/DragonISO
|
||||
forge.wilddragon.net/zgaetano/teamsiso
|
||||
</Hyperlink>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
|
||||
namespace DragonISO.App;
|
||||
namespace TeamsISO.App;
|
||||
|
||||
/// <summary>
|
||||
/// F1 cheat-sheet dialog. Lists keyboard shortcuts, file locations, and a
|
||||
|
|
@ -20,7 +20,7 @@ public partial class HelpWindow : Window
|
|||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "https://forge.wilddragon.net/zgaetano/Dragon-ISO",
|
||||
FileName = "https://forge.wilddragon.net/zgaetano/teamsiso",
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
<Window x:Class="DragonISO.App.MainWindow"
|
||||
<Window x:Class="TeamsISO.App.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:DragonISO.App.ViewModels"
|
||||
xmlns:conv="clr-namespace:DragonISO.App.Converters"
|
||||
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels"
|
||||
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
|
||||
mc:Ignorable="d"
|
||||
Title="DragonISO"
|
||||
Title="TeamsISO"
|
||||
Width="1280" Height="780"
|
||||
MinWidth="980" MinHeight="640"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
|
|
@ -19,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-DragonISO-v2-studio-terminal.md)
|
||||
(Approved 2026-05-13. Shape brief at docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md)
|
||||
|
||||
Default Windows title bar (no chromeless WindowChrome). The 32px header
|
||||
below it carries the brand mark, wordmark, and two icon buttons:
|
||||
|
|
@ -105,7 +105,7 @@
|
|||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Cursor="Hand"
|
||||
ToolTip="About DragonISO">
|
||||
ToolTip="About TeamsISO">
|
||||
<!-- 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="DragonISO"
|
||||
<TextBlock Text="TeamsISO"
|
||||
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 DragonISO broadcasts as.
|
||||
name TeamsISO 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 DragonISO will broadcast this participant as. Defaults
|
||||
name TeamsISO 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 'DragonISO-input' group so they don't pollute the Public network. Restart Teams after applying."
|
||||
<TextBlock Text="Pins Teams' raw NDI broadcasts to a private 'teamsiso-input' group so they don't pollute the Public network. Restart Teams after applying."
|
||||
Style="{StaticResource Wd.Text.Body}"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||
|
|
@ -1055,7 +1055,7 @@
|
|||
Height="1"
|
||||
Background="{DynamicResource Wd.Border}"/>
|
||||
|
||||
<CheckBox Content="Launch Microsoft Teams on DragonISO startup"
|
||||
<CheckBox Content="Launch Microsoft Teams on TeamsISO startup"
|
||||
IsChecked="{Binding Settings.LaunchTeamsOnStartup}"/>
|
||||
<CheckBox Content="Auto-hide Teams windows when launched"
|
||||
IsChecked="{Binding Settings.AutoHideTeamsWindows}"
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using DragonISO.App.Services;
|
||||
using DragonISO.App.ViewModels;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.App.ViewModels;
|
||||
|
||||
namespace DragonISO.App;
|
||||
namespace TeamsISO.App;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
|
|
@ -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 Dragon-ISO's own toggle history.
|
||||
/// re-opens them and we only care about TeamsISO'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
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<Window x:Class="DragonISO.App.NotesWindow"
|
||||
<Window x:Class="TeamsISO.App.NotesWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
Title="Show notes"
|
||||
Icon="/Assets/DragonISO.ico"
|
||||
Icon="/Assets/teamsiso.ico"
|
||||
Width="540" Height="560"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
WindowStyle="None"
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using DragonISO.App.Services;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace DragonISO.App;
|
||||
namespace TeamsISO.App;
|
||||
|
||||
/// <summary>
|
||||
/// Inline viewer for the daily show-notes file. Reads
|
||||
|
|
@ -12,7 +12,7 @@ namespace DragonISO.App;
|
|||
/// shown so REST/OSC-driven note appends surface live without the operator
|
||||
/// having to click Refresh.
|
||||
///
|
||||
/// We don't allow editing here — the file is intentionally a one-way log
|
||||
/// We don't allow editing here — the file is intentionally a one-way log
|
||||
/// (operator stamps, post-show review). If someone wants to edit, they
|
||||
/// click "Open in editor" and use Notepad.
|
||||
/// </summary>
|
||||
|
|
@ -32,7 +32,7 @@ public partial class NotesWindow : Window
|
|||
_refreshTimer.Tick += (_, _) => RefreshIfChanged();
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
DateLine.Text = $"{DateTimeOffset.Now:dddd, MMMM d, yyyy} · {NotesService.TodayPath}";
|
||||
DateLine.Text = $"{DateTimeOffset.Now:dddd, MMMM d, yyyy} · {NotesService.TodayPath}";
|
||||
ReloadFromDisk();
|
||||
_refreshTimer.Start();
|
||||
};
|
||||
|
|
@ -44,7 +44,7 @@ public partial class NotesWindow : Window
|
|||
private void OnRefresh(object sender, RoutedEventArgs e) => ReloadFromDisk();
|
||||
|
||||
/// <summary>
|
||||
/// Cheap mtime/size check — only re-reads the file when something changed.
|
||||
/// Cheap mtime/size check — only re-reads the file when something changed.
|
||||
/// Saves the textbox a flicker on every 2s tick when no notes are being
|
||||
/// added. Falls through to a full reload if the file got smaller (operator
|
||||
/// might have edited externally).
|
||||
|
|
@ -79,7 +79,7 @@ public partial class NotesWindow : Window
|
|||
_lastFileSize = info.Length;
|
||||
_lastFileWrite = info.LastWriteTimeUtc;
|
||||
NotesText.Text = File.ReadAllText(path);
|
||||
// Scroll to bottom so the latest stamp is visible — operators are
|
||||
// Scroll to bottom so the latest stamp is visible — operators are
|
||||
// typically reading "what just happened" not "what happened first."
|
||||
Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<Window x:Class="DragonISO.App.OnboardingWindow"
|
||||
<Window x:Class="TeamsISO.App.OnboardingWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
Title="Welcome to DragonISO"
|
||||
Icon="/Assets/DragonISO.ico"
|
||||
Title="Welcome to TeamsISO"
|
||||
Icon="/Assets/teamsiso.ico"
|
||||
Width="560" Height="600"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
WindowStyle="None"
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
HorizontalAlignment="Left"
|
||||
Margin="0,0,0,12"
|
||||
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||
<TextBlock Text="DragonISO routes Microsoft Teams participants as isolated NDI feeds."
|
||||
<TextBlock Text="TeamsISO routes Microsoft Teams participants as isolated NDI feeds."
|
||||
Style="{StaticResource Wd.Text.Title}"
|
||||
TextWrapping="Wrap"/>
|
||||
<TextBlock Text="A few one-time setup notes before you start."
|
||||
|
|
@ -96,7 +96,7 @@
|
|||
Style="{StaticResource Wd.Text.Body}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||
Text="DragonISO depends on NDI 6 from NewTek/Vizrt. Get it at ndi.video/tools — pick the Tools bundle for free, or use the Runtime-only installer if you have an NDI subscription."/>
|
||||
Text="TeamsISO depends on NDI 6 from NewTek/Vizrt. Get it at ndi.video/tools — pick the Tools bundle for free, or use the Runtime-only installer if you have an NDI subscription."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
|
@ -152,7 +152,7 @@
|
|||
Style="{StaticResource Wd.Text.Body}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||
Text="Settings → NETWORK → Apply transcoder topology. This pins Teams' raw NDI broadcasts to a private 'DragonISO-input' group so they don't pollute the Public network. Restart Teams once after applying."/>
|
||||
Text="Settings → NETWORK → Apply transcoder topology. This pins Teams' raw NDI broadcasts to a private 'teamsiso-input' group so they don't pollute the Public network. Restart Teams once after applying."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
|
@ -180,11 +180,11 @@
|
|||
Style="{StaticResource Wd.Text.Body}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||
Text="Once you've toggled the right ISOs, click Presets in the participants header to save them by display name. Turn on 'Auto-apply last preset on launch' under DISPLAY settings and DragonISO will restore that routing on every subsequent launch."/>
|
||||
Text="Once you've toggled the right ISOs, click Presets in the participants header to save them by display name. Turn on 'Auto-apply last preset on launch' under DISPLAY settings and TeamsISO will restore that routing on every subsequent launch."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Step 5 — Headless Teams ("I only see DragonISO") -->
|
||||
<!-- Step 5 — Headless Teams ("I only see TeamsISO") -->
|
||||
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||
|
|
@ -208,7 +208,7 @@
|
|||
Style="{StaticResource Wd.Text.Body}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||
Text="To use DragonISO as your only window: tick both 'Launch Microsoft Teams on DragonISO startup' and 'Auto-hide Teams windows when launched' under Settings → DISPLAY. Teams runs in the background; the IN-CALL bar shows the meeting state (READY / IN CALL · meeting title), the Mute/Camera/Share/Leave buttons drive Teams via UIAutomation, and the URL paste-box joins meetings directly. Use the eye-icon button in the left rail to manually restore Teams' windows when you need them."/>
|
||||
Text="To use TeamsISO as your only window: tick both 'Launch Microsoft Teams on TeamsISO startup' and 'Auto-hide Teams windows when launched' under Settings → DISPLAY. Teams runs in the background; the IN-CALL bar shows the meeting state (READY / IN CALL · meeting title), the Mute/Camera/Share/Leave buttons drive Teams via UIAutomation, and the URL paste-box joins meetings directly. Use the eye-icon button in the left rail to manually restore Teams' windows when you need them."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
|
@ -236,7 +236,7 @@
|
|||
Style="{StaticResource Wd.Text.Body}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||
Text="For headless host PC + thin-client setups: tick 'Control surface' then 'LAN-reachable' under DISPLAY. DragonISO listens on http://<your-lan-ip>:9755/ui — open that URL from any browser on the LAN. First-time use needs a one-shot 'netsh http add urlacl url=http://+:9755/ user=Everyone' in an Administrator PowerShell."/>
|
||||
Text="For headless host PC + thin-client setups: tick 'Control surface' then 'LAN-reachable' under DISPLAY. TeamsISO listens on http://<your-lan-ip>:9755/ui — open that URL from any browser on the LAN. First-time use needs a one-shot 'netsh http add urlacl url=http://+:9755/ user=Everyone' in an Administrator PowerShell."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
|
@ -264,7 +264,7 @@
|
|||
Style="{StaticResource Wd.Text.Body}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||
Text="Diagnostic logs roll daily under %LOCALAPPDATA%\DragonISO\Logs. Settings live at %APPDATA%\DragonISO\config.json; presets at %LOCALAPPDATA%\DragonISO\presets.json. Attach the most recent log when filing an issue at forge.wilddragon.net/zgaetano/DragonISO."/>
|
||||
Text="Diagnostic logs roll daily under %LOCALAPPDATA%\TeamsISO\Logs. Settings live at %APPDATA%\TeamsISO\config.json; presets at %LOCALAPPDATA%\TeamsISO\presets.json. Attach the most recent log when filing an issue at forge.wilddragon.net/zgaetano/teamsiso."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
|
||||
namespace DragonISO.App;
|
||||
namespace TeamsISO.App;
|
||||
|
||||
/// <summary>
|
||||
/// First-launch welcome dialog. Walks the user through the once-per-machine
|
||||
|
|
@ -10,8 +10,8 @@ namespace DragonISO.App;
|
|||
/// presets live for later self-service.
|
||||
///
|
||||
/// Suppression is governed by a marker file at
|
||||
/// <c>%LOCALAPPDATA%\Dragon-ISO\onboarding.flag</c>. The presence of the file —
|
||||
/// regardless of contents — means "don't show again." The user can restore
|
||||
/// <c>%LOCALAPPDATA%\TeamsISO\onboarding.flag</c>. The presence of the file —
|
||||
/// regardless of contents — means "don't show again." The user can restore
|
||||
/// the dialog by deleting that file.
|
||||
/// </summary>
|
||||
public partial class OnboardingWindow : Window
|
||||
|
|
@ -19,7 +19,7 @@ public partial class OnboardingWindow : Window
|
|||
private static string FlagPath =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Dragon-ISO", "onboarding.flag");
|
||||
"TeamsISO", "onboarding.flag");
|
||||
|
||||
public OnboardingWindow() => InitializeComponent();
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ public partial class OnboardingWindow : Window
|
|||
public static bool ShouldShow()
|
||||
{
|
||||
try { return !File.Exists(FlagPath); }
|
||||
catch { return false; } // permission errors → assume already shown
|
||||
catch { return false; } // permission errors → assume already shown
|
||||
}
|
||||
|
||||
private void OnDismiss(object sender, RoutedEventArgs e)
|
||||
|
|
@ -47,7 +47,7 @@ public partial class OnboardingWindow : Window
|
|||
}
|
||||
catch
|
||||
{
|
||||
// Disk full / permission denied — show the dialog again next launch
|
||||
// Disk full / permission denied — show the dialog again next launch
|
||||
// rather than fail noisily.
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<Window x:Class="DragonISO.App.PresetsDialog"
|
||||
<Window x:Class="TeamsISO.App.PresetsDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
Title="Operator presets"
|
||||
Icon="/Assets/DragonISO.ico"
|
||||
Icon="/Assets/teamsiso.ico"
|
||||
Width="460" Height="520"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
WindowStyle="None"
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using DragonISO.App.Services;
|
||||
using DragonISO.App.ViewModels;
|
||||
using DragonISO.Engine.Controller;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Controller;
|
||||
|
||||
namespace DragonISO.App;
|
||||
namespace TeamsISO.App;
|
||||
|
||||
/// <summary>
|
||||
/// Modal dialog for saving and loading operator presets. Owned by
|
||||
|
|
@ -101,7 +101,7 @@ public partial class PresetsDialog : Window
|
|||
var confirm = MessageBox.Show(
|
||||
this,
|
||||
$"A preset named \"{name}\" already exists. Overwrite it?",
|
||||
"Dragon-ISO — Overwrite preset",
|
||||
"TeamsISO — Overwrite preset",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question,
|
||||
MessageBoxResult.No);
|
||||
|
|
@ -122,7 +122,7 @@ public partial class PresetsDialog : Window
|
|||
MessageBox.Show(
|
||||
this,
|
||||
$"Could not save preset.\n\n{ex.Message}",
|
||||
"Dragon-ISO — Save preset",
|
||||
"TeamsISO — Save preset",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
|
|
@ -130,7 +130,7 @@ public partial class PresetsDialog : Window
|
|||
|
||||
/// <summary>
|
||||
/// Apply the selected preset: walks the current participants list, matching
|
||||
/// by display name (the only stable join key across meetings — Ids are
|
||||
/// by display name (the only stable join key across meetings — Ids are
|
||||
/// regenerated each meeting). For each match, set the custom output name and
|
||||
/// reconcile its enabled state with the preset by calling EnableIsoAsync /
|
||||
/// DisableIsoAsync as needed. Participants in the preset who aren't in the
|
||||
|
|
@ -143,15 +143,15 @@ public partial class PresetsDialog : Window
|
|||
ApplyButton.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
// PresetApplier owns the apply loop — same code path the REST control
|
||||
// PresetApplier owns the apply loop — same code path the REST control
|
||||
// surface and auto-apply-on-launch use. Dialog passes null dispatcher
|
||||
// since OnApply already runs on the UI thread.
|
||||
var result = await PresetApplier.ApplyAsync(
|
||||
row.Preset, _participants, _controller, dispatcher: null);
|
||||
|
||||
var summary = result.Skipped > 0
|
||||
? $"Applied \"{row.Name}\" — {result.Changed} change(s); {result.Skipped} not in meeting"
|
||||
: $"Applied \"{row.Name}\" — {result.Changed} change(s)";
|
||||
? $"Applied \"{row.Name}\" — {result.Changed} change(s); {result.Skipped} not in meeting"
|
||||
: $"Applied \"{row.Name}\" — {result.Changed} change(s)";
|
||||
_toast?.Show(summary);
|
||||
DialogResult = true;
|
||||
Close();
|
||||
|
|
@ -184,14 +184,14 @@ public partial class PresetsDialog : Window
|
|||
var confirm = MessageBox.Show(
|
||||
this,
|
||||
$"A preset named \"{newName}\" already exists. Overwrite it?",
|
||||
"Dragon-ISO — Duplicate preset",
|
||||
"TeamsISO — Duplicate preset",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question,
|
||||
MessageBoxResult.No);
|
||||
if (confirm != MessageBoxResult.Yes) return;
|
||||
}
|
||||
|
||||
// Re-using Save() with a fresh SavedAt timestamp — Save's overwrite
|
||||
// Re-using Save() with a fresh SavedAt timestamp — Save's overwrite
|
||||
// semantics handle the name-collision case cleanly.
|
||||
OperatorPresetStore.Save(new OperatorPresetStore.Preset(
|
||||
Name: newName,
|
||||
|
|
@ -204,14 +204,14 @@ public partial class PresetsDialog : Window
|
|||
{
|
||||
MessageBox.Show(this,
|
||||
$"Could not duplicate preset.\n\n{ex.Message}",
|
||||
"Dragon-ISO — Duplicate preset",
|
||||
"TeamsISO — Duplicate preset",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Suggest a name for a copy: "Foo" → "Foo (copy)", "Foo (copy)" → "Foo (copy 2)".
|
||||
/// Suggest a name for a copy: "Foo" → "Foo (copy)", "Foo (copy)" → "Foo (copy 2)".
|
||||
/// Bumps the digit if the operator iterates from a copy.
|
||||
/// </summary>
|
||||
private static string SuggestCopyName(string original)
|
||||
|
|
@ -275,7 +275,7 @@ public partial class PresetsDialog : Window
|
|||
var confirm = MessageBox.Show(
|
||||
this,
|
||||
$"Delete preset \"{row.Name}\"? This cannot be undone.",
|
||||
"Dragon-ISO — Delete preset",
|
||||
"TeamsISO — Delete preset",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Warning,
|
||||
MessageBoxResult.No);
|
||||
|
|
@ -292,7 +292,7 @@ public partial class PresetsDialog : Window
|
|||
MessageBox.Show(
|
||||
this,
|
||||
$"Could not delete preset.\n\n{ex.Message}",
|
||||
"Dragon-ISO — Delete preset",
|
||||
"TeamsISO — Delete preset",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
|
|
@ -313,9 +313,9 @@ public partial class PresetsDialog : Window
|
|||
{
|
||||
var dlg = new Microsoft.Win32.SaveFileDialog
|
||||
{
|
||||
Title = "Export Dragon-ISO presets",
|
||||
FileName = $"Dragon-ISO-presets-{DateTimeOffset.Now:yyyy-MM-dd}.json",
|
||||
Filter = "Dragon-ISO preset bundle (*.json)|*.json",
|
||||
Title = "Export TeamsISO presets",
|
||||
FileName = $"teamsiso-presets-{DateTimeOffset.Now:yyyy-MM-dd}.json",
|
||||
Filter = "TeamsISO preset bundle (*.json)|*.json",
|
||||
DefaultExt = "json",
|
||||
};
|
||||
if (dlg.ShowDialog(this) != true) return;
|
||||
|
|
@ -330,7 +330,7 @@ public partial class PresetsDialog : Window
|
|||
{
|
||||
MessageBox.Show(this,
|
||||
$"Could not export presets.\n\n{ex.Message}",
|
||||
"Dragon-ISO — Export presets",
|
||||
"TeamsISO — Export presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
|
|
@ -338,7 +338,7 @@ public partial class PresetsDialog : Window
|
|||
|
||||
/// <summary>
|
||||
/// Load a bundle from a path the user picks. On name collision we ask once
|
||||
/// (covering all collisions) whether to overwrite — a per-preset prompt would
|
||||
/// (covering all collisions) whether to overwrite — a per-preset prompt would
|
||||
/// be exhausting for a 20-preset bundle. The Y/N here drives the overwrite
|
||||
/// flag passed to <see cref="OperatorPresetStore.ImportBundle"/>.
|
||||
/// </summary>
|
||||
|
|
@ -346,8 +346,8 @@ public partial class PresetsDialog : Window
|
|||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Title = "Import Dragon-ISO presets",
|
||||
Filter = "Dragon-ISO preset bundle (*.json)|*.json|All files (*.*)|*.*",
|
||||
Title = "Import TeamsISO presets",
|
||||
Filter = "TeamsISO preset bundle (*.json)|*.json|All files (*.*)|*.*",
|
||||
};
|
||||
if (dlg.ShowDialog(this) != true) return;
|
||||
|
||||
|
|
@ -357,7 +357,7 @@ public partial class PresetsDialog : Window
|
|||
{
|
||||
MessageBox.Show(this,
|
||||
$"Could not read the file.\n\n{ex.Message}",
|
||||
"Dragon-ISO — Import presets",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
return;
|
||||
|
|
@ -369,8 +369,8 @@ public partial class PresetsDialog : Window
|
|||
catch
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
"That file isn't a valid Dragon-ISO preset bundle.",
|
||||
"Dragon-ISO — Import presets",
|
||||
"That file isn't a valid TeamsISO preset bundle.",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
return;
|
||||
|
|
@ -379,7 +379,7 @@ public partial class PresetsDialog : Window
|
|||
{
|
||||
MessageBox.Show(this,
|
||||
"The bundle is empty.",
|
||||
"Dragon-ISO — Import presets",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
return;
|
||||
|
|
@ -398,7 +398,7 @@ public partial class PresetsDialog : Window
|
|||
$"{collisions} preset name(s) in this bundle already exist on this machine.\n\n" +
|
||||
"Yes = overwrite local copies with the bundle's versions.\n" +
|
||||
"No = keep local copies; only import new presets.",
|
||||
"Dragon-ISO — Import presets",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.YesNoCancel,
|
||||
MessageBoxImage.Question,
|
||||
MessageBoxResult.No);
|
||||
|
|
@ -411,13 +411,13 @@ public partial class PresetsDialog : Window
|
|||
{
|
||||
MessageBox.Show(this,
|
||||
$"Import failed.\n\n{result.Error}",
|
||||
"Dragon-ISO — Import presets",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var summary = $"Imported — {result.Added} new";
|
||||
var summary = $"Imported — {result.Added} new";
|
||||
if (result.Overwritten > 0) summary += $", {result.Overwritten} overwritten";
|
||||
if (result.Skipped > 0) summary += $", {result.Skipped} skipped";
|
||||
_toast?.Show(summary);
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<Window x:Class="DragonISO.App.PreviewWindow"
|
||||
<Window x:Class="TeamsISO.App.PreviewWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
Title="Preview"
|
||||
Icon="/Assets/DragonISO.ico"
|
||||
Icon="/Assets/teamsiso.ico"
|
||||
Width="640" Height="400"
|
||||
MinWidth="320" MinHeight="200"
|
||||
Background="Black"
|
||||
|
|
@ -1,21 +1,21 @@
|
|||
using System.Windows;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using DragonISO.Engine.Controller;
|
||||
using DragonISO.Engine.Pipeline;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Pipeline;
|
||||
|
||||
namespace DragonISO.App;
|
||||
namespace TeamsISO.App;
|
||||
|
||||
/// <summary>
|
||||
/// Non-modal floating preview window for a single participant. Shows the
|
||||
/// engine's most recent <see cref="ProcessedFrame"/> at a higher refresh
|
||||
/// rate (~20Hz) than the participants DataGrid thumbnails (1Hz). Multi-
|
||||
/// monitor friendly: operator drags it to a second display, leaves the
|
||||
/// main Dragon-ISO window on the primary.
|
||||
/// main TeamsISO window on the primary.
|
||||
///
|
||||
/// Uses <see cref="WriteableBitmap"/> with <see cref="WriteableBitmap.WritePixels(Int32Rect, IntPtr, int, int)"/>
|
||||
/// — the engine produces full-resolution BGRA frames so we can write them
|
||||
/// — the engine produces full-resolution BGRA frames so we can write them
|
||||
/// straight into the bitmap without scaling. WPF's Image control with
|
||||
/// Stretch=Uniform handles aspect-correct fit to the window size.
|
||||
/// </summary>
|
||||
|
|
@ -71,12 +71,12 @@ public partial class PreviewWindow : Window
|
|||
PreviewImage.Source = _bitmap;
|
||||
_lastWidth = frame.Width;
|
||||
_lastHeight = frame.Height;
|
||||
ResolutionText.Text = $"{frame.Width}×{frame.Height}";
|
||||
ResolutionText.Text = $"{frame.Width}×{frame.Height}";
|
||||
}
|
||||
|
||||
// WritePixels takes a buffer + stride + rect. Stride = width * 4 for
|
||||
// BGRA. We slice the ProcessedFrame's ReadOnlyMemory<byte> via .Span
|
||||
// and use the IntPtr overload via MemoryMarshal — but the
|
||||
// and use the IntPtr overload via MemoryMarshal — but the
|
||||
// byte-array overload is simpler and the compiler picks the right
|
||||
// ToArray-free path because the engine already allocates a fresh
|
||||
// array per frame.
|
||||
|
|
@ -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 DragonISO.App.Properties;
|
||||
namespace TeamsISO.App.Properties;
|
||||
|
||||
internal static class Strings
|
||||
{
|
||||
private static readonly ResourceManager ResourceManager = new(
|
||||
baseName: "DragonISO.App.Properties.Strings",
|
||||
baseName: "TeamsISO.App.Properties.Strings",
|
||||
assembly: typeof(Strings).Assembly);
|
||||
|
||||
public static CultureInfo? Culture { get; set; }
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The HTML / CSS / JS for the embedded control panel served at
|
||||
/// <c>GET /ui</c>. Single self-contained string — no external CDN deps, no
|
||||
/// <c>GET /ui</c>. Single self-contained string — no external CDN deps, no
|
||||
/// build step, no React. Phone-friendly remote that connects via WebSocket
|
||||
/// to <c>/ws</c> and posts to the existing REST endpoints.
|
||||
///
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
/// - Live preview tiles per participant via GET /participants/{id}/thumbnail.jpg
|
||||
/// (engine encodes the latest ProcessedFrame as a 192-wide JPEG; refreshed
|
||||
/// ~1Hz alongside the WebSocket state push).
|
||||
/// - Topology toggle card — shows whether raw Teams NDI sources are
|
||||
/// - Topology toggle card — shows whether raw Teams NDI sources are
|
||||
/// hidden from the LAN, with Apply / Restore buttons that hit the
|
||||
/// /topology/apply + /topology/restore REST endpoints. Operator still
|
||||
/// has to restart Teams afterward, surfaced in a banner on apply.
|
||||
|
|
@ -22,7 +22,7 @@ internal static class ControlPanelHtml
|
|||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
||||
<title>Dragon-ISO Control</title>
|
||||
<title>TeamsISO Control</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
|
|
@ -142,11 +142,11 @@ internal static class ControlPanelHtml
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Dragon-ISO control surface</h1>
|
||||
<h1>TeamsISO control surface</h1>
|
||||
|
||||
<div class='card'>
|
||||
<div class='status'>
|
||||
<span><span id='conn' class='dot gray'></span> <span id='conn-text'>connecting…</span></span>
|
||||
<span><span id='conn' class='dot gray'></span> <span id='conn-text'>connecting…</span></span>
|
||||
<span id='count' class='sub'></span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -156,7 +156,7 @@ internal static class ControlPanelHtml
|
|||
<span id='topo-dot' class='dot gray'></span>
|
||||
<div>
|
||||
<div class='label-caps'>Network topology</div>
|
||||
<strong id='topo-label'>—</strong>
|
||||
<strong id='topo-label'>—</strong>
|
||||
<div id='topo-detail' class='sub' style='margin-top: 2px;'></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -173,7 +173,7 @@ internal static class ControlPanelHtml
|
|||
<button onclick='post(""/teams/camera"")'>Camera</button>
|
||||
<button onclick='post(""/teams/share"")'>Share</button>
|
||||
<button onclick='post(""/teams/leave"")'>Leave</button>
|
||||
<button onclick='dropNote()'>Note…</button>
|
||||
<button onclick='dropNote()'>Note…</button>
|
||||
<button onclick='post(""/presets/refresh-discovery"")'>Refresh</button>
|
||||
<button class='danger' onclick='post(""/presets/stop-all"")'>Stop all ISOs</button>
|
||||
</div>
|
||||
|
|
@ -226,20 +226,20 @@ function paintTopology(t) {
|
|||
topoLabel.textContent = 'Teams hidden from LAN';
|
||||
} else if (t.mode === 'public') {
|
||||
topoDot.className = 'dot amber';
|
||||
topoLabel.textContent = 'Public — raw Teams visible';
|
||||
topoLabel.textContent = 'Public — raw Teams visible';
|
||||
} else {
|
||||
topoDot.className = 'dot gray';
|
||||
topoLabel.textContent = 'Unknown';
|
||||
}
|
||||
const sends = (t.senders || []).join(', ') || '—';
|
||||
const recvs = (t.receivers || []).join(', ') || '—';
|
||||
topoDetail.textContent = 'send: ' + sends + ' · recv: ' + recvs;
|
||||
const sends = (t.senders || []).join(', ') || '—';
|
||||
const recvs = (t.receivers || []).join(', ') || '—';
|
||||
topoDetail.textContent = 'send: ' + sends + ' · recv: ' + recvs;
|
||||
}
|
||||
|
||||
async function applyTopology() {
|
||||
const r = await post('/topology/apply');
|
||||
if (r && r.ok) {
|
||||
topoBanner.textContent = '✓ ' + (r.note || 'Applied. Restart Microsoft Teams for it to take effect.');
|
||||
topoBanner.textContent = '✓ ' + (r.note || 'Applied. Restart Microsoft Teams for it to take effect.');
|
||||
topoBanner.classList.add('show');
|
||||
setTimeout(() => topoBanner.classList.remove('show'), 8000);
|
||||
}
|
||||
|
|
@ -250,7 +250,7 @@ async function restoreTopology() {
|
|||
if (!confirm('Restore default NDI groups? Teams will broadcast on public again after you restart it.')) return;
|
||||
const r = await post('/topology/restore');
|
||||
if (r && r.ok) {
|
||||
topoBanner.textContent = '✓ Defaults restored. Restart Microsoft Teams for it to take effect.';
|
||||
topoBanner.textContent = '✓ Defaults restored. Restart Microsoft Teams for it to take effect.';
|
||||
topoBanner.classList.add('show');
|
||||
setTimeout(() => topoBanner.classList.remove('show'), 8000);
|
||||
}
|
||||
|
|
@ -282,15 +282,15 @@ const openPanels = new Set();
|
|||
|
||||
function shortFps(v) {
|
||||
for (const [k, label] of FRAMERATE_OPTS) if (k === v) return label;
|
||||
return v || '—';
|
||||
return v || '—';
|
||||
}
|
||||
function shortRes(v) {
|
||||
for (const [k, label] of RESOLUTION_OPTS) if (k === v) return label;
|
||||
return v || '—';
|
||||
return v || '—';
|
||||
}
|
||||
function shortAudio(v) {
|
||||
for (const [k, label] of AUDIO_OPTS) if (k === v) return label;
|
||||
return v || '—';
|
||||
return v || '—';
|
||||
}
|
||||
|
||||
function buildSelect(opts, current) {
|
||||
|
|
@ -323,21 +323,21 @@ function render(participants) {
|
|||
const row = document.createElement('div');
|
||||
row.className = 'participant-row';
|
||||
const stateColor = p.isEnabled ? 'cyan' : (p.isOnline ? 'gray' : 'coral');
|
||||
// Live preview tile — cache-bust with a 1s-bucket query param so the
|
||||
// Live preview tile — cache-bust with a 1s-bucket query param so the
|
||||
// browser refreshes the image without flickering on every WS message.
|
||||
const bust = Math.floor(Date.now() / 1000);
|
||||
const previewUrl = '/participants/' + p.id + '/thumbnail.jpg?t=' + bust;
|
||||
row.innerHTML =
|
||||
""<span class='dot "" + stateColor + ""'></span>"" +
|
||||
""<img class='preview' alt='' onerror=\""this.style.display='none'; this.nextElementSibling.style.display='flex';\"" >"" +
|
||||
""<div class='preview empty' style='display:none;'>—</div>"" +
|
||||
""<div class='preview empty' style='display:none;'>—</div>"" +
|
||||
""<div class='grow'>"" +
|
||||
""<div class='name'></div>"" +
|
||||
""<div class='sub'></div>"" +
|
||||
""</div>"" +
|
||||
""<div class='row-right'>"" +
|
||||
""<span class='cfg-caption'></span>"" +
|
||||
""<button class='gear-btn' title='Output settings'>âš™</button>"" +
|
||||
""<button class='gear-btn' title='Output settings'>⚙</button>"" +
|
||||
""<button class='enable-btn'></button>"" +
|
||||
""</div>"";
|
||||
const img = row.querySelector('img.preview');
|
||||
|
|
@ -346,7 +346,7 @@ function render(participants) {
|
|||
const subEl = row.querySelector('.sub');
|
||||
subEl.textContent =
|
||||
(p.stateLabel || (p.isOnline ? 'online' : 'offline')) +
|
||||
(p.customName ? ' · ' + p.customName : '');
|
||||
(p.customName ? ' · ' + p.customName : '');
|
||||
if (isOverride) {
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'ovr-pill';
|
||||
|
|
@ -354,10 +354,10 @@ function render(participants) {
|
|||
subEl.appendChild(pill);
|
||||
}
|
||||
row.querySelector('.cfg-caption').textContent =
|
||||
shortFps(eff.framerate) + ' · ' + shortRes(eff.resolution) + ' · ' + shortAudio(eff.audio);
|
||||
shortFps(eff.framerate) + ' · ' + shortRes(eff.resolution) + ' · ' + shortAudio(eff.audio);
|
||||
const enableBtn = row.querySelector('.enable-btn');
|
||||
enableBtn.className = 'enable-btn ' + (p.isEnabled ? 'live' : '');
|
||||
enableBtn.textContent = p.isEnabled ? 'â— LIVE' : 'Enable';
|
||||
enableBtn.textContent = p.isEnabled ? '● LIVE' : 'Enable';
|
||||
enableBtn.onclick = () => post('/participants/iso', {
|
||||
displayName: p.displayName,
|
||||
enabled: !p.isEnabled,
|
||||
|
|
@ -419,7 +419,7 @@ function render(participants) {
|
|||
}
|
||||
|
||||
function connect() {
|
||||
setConn('gray', 'connecting…');
|
||||
setConn('gray', 'connecting…');
|
||||
const ws = new WebSocket(
|
||||
(location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
|
||||
ws.onopen = () => { setConn('green', 'live'); fetchTopology(); };
|
||||
|
|
@ -430,7 +430,7 @@ function connect() {
|
|||
} catch (e) { console.warn(e); }
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setConn('coral', 'disconnected — retry in 3s');
|
||||
setConn('coral', 'disconnected — retry in 3s');
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
ws.onerror = () => setConn('coral', 'error');
|
||||
|
|
@ -438,7 +438,7 @@ function connect() {
|
|||
|
||||
connect();
|
||||
// Re-poll topology every 30s in case the operator changes the machine NDI
|
||||
// config externally (NDI Access Manager, manual edit). Cheap — one HTTP GET.
|
||||
// config externally (NDI Access Manager, manual edit). Cheap — one HTTP GET.
|
||||
setInterval(fetchTopology, 30000);
|
||||
</script>
|
||||
</body>
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.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 = "Dragon-ISO",
|
||||
product = "TeamsISO",
|
||||
version = typeof(ControlSurfaceServer).Assembly.GetName().Version?.ToString() ?? "unknown",
|
||||
engine = new
|
||||
{
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
using System.Collections.Specialized;
|
||||
using System.Collections.Specialized;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.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)
|
||||
|
|
@ -1,24 +1,24 @@
|
|||
using System.Collections.Specialized;
|
||||
using System.Collections.Specialized;
|
||||
using System.Text.Json;
|
||||
using DragonISO.Engine.Domain;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.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(() =>
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.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.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
namespace TeamsISO.App.Services;
|
||||
|
||||
// /teams/* route handlers — UIAutomation-driven in-call controls.
|
||||
//
|
||||
// POST /teams/mute → InvokeTeams(ToggleMute, "mute")
|
||||
// POST /teams/camera → InvokeTeams(ToggleCamera, "camera")
|
||||
// POST /teams/leave → InvokeTeams(LeaveCall, "leave")
|
||||
// POST /teams/share → InvokeTeams(OpenShareTray, "share")
|
||||
// POST /teams/raise-hand → InvokeTeams(ToggleRaiseHand, "raise-hand")
|
||||
public sealed partial class ControlSurfaceServer
|
||||
{
|
||||
private object InvokeTeams(Func<TeamsControlBridge.InvokeResult> invoke, string action)
|
||||
{
|
||||
var result = invoke();
|
||||
return new
|
||||
{
|
||||
ok = result == TeamsControlBridge.InvokeResult.Invoked,
|
||||
action,
|
||||
result = result.ToString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.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.
|
||||
//
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.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>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
|
||||
/// 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
|
||||
/// 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 DragonISO.Engine.Domain.NdiGroupSettings(
|
||||
var ourGroups = new TeamsISO.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 DragonISO.Engine.Domain.NdiGroupSettings(
|
||||
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings(
|
||||
DiscoveryGroups: null,
|
||||
OutputGroups: null);
|
||||
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
using System.Net.WebSockets;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.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>
|
||||
|
|
@ -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 DragonISO.App.ViewModels;
|
||||
using DragonISO.Engine.Controller;
|
||||
using DragonISO.Engine.Domain;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Localhost-only HTTP control surface. Lets external controllers (Bitfocus
|
||||
/// Companion, Stream Deck plugins, Bome MIDI Translator, custom node-RED flows,
|
||||
/// etc.) drive Dragon-ISO without needing to embed a UI binding.
|
||||
/// etc.) drive TeamsISO without needing to embed a UI binding.
|
||||
///
|
||||
/// Bound to 127.0.0.1 by default — exposing this to LAN would require auth, and
|
||||
/// the typical operator workflow is "Stream Deck on the same machine as Dragon-ISO".
|
||||
/// 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".
|
||||
/// 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&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 Dragon-ISO as Administrator OR a
|
||||
/// LAN binding requires either running TeamsISO 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)
|
||||
{
|
||||
|
|
@ -1,30 +1,30 @@
|
|||
using System.IO;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Gathers logs + config + presets + version metadata into a single .zip the
|
||||
/// operator can attach to a bug report. Surfaced via the "Export diagnostics"
|
||||
/// button in About.
|
||||
///
|
||||
/// We deliberately do NOT include screenshots or any process/memory dumps —
|
||||
/// We deliberately do NOT include screenshots or any process/memory dumps —
|
||||
/// that's outside the scope of a v1 support bundle and would raise privacy
|
||||
/// flags. The bundle has only files the user already wrote with their Dragon-ISO
|
||||
/// flags. The bundle has only files the user already wrote with their TeamsISO
|
||||
/// usage; nothing here is hidden state.
|
||||
/// </summary>
|
||||
public static class DiagnosticsBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Build the bundle and return the path it was written to.
|
||||
/// Throws on disk failure — the caller toasts/dialogs.
|
||||
/// Throws on disk failure — the caller toasts/dialogs.
|
||||
/// </summary>
|
||||
public static string Export()
|
||||
{
|
||||
var ts = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
|
||||
var fileName = $"Dragon-ISO-diagnostics-{ts}.zip";
|
||||
var fileName = $"teamsiso-diagnostics-{ts}.zip";
|
||||
var outDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var downloads = Path.Combine(outDir, "Downloads");
|
||||
if (!Directory.Exists(downloads)) downloads = outDir; // fall back to ~/
|
||||
|
|
@ -51,9 +51,9 @@ public static class DiagnosticsBundle
|
|||
?? asm.GetName().Version?.ToString()
|
||||
?? "unknown";
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Dragon-ISO diagnostic bundle");
|
||||
sb.AppendLine("TeamsISO diagnostic bundle");
|
||||
sb.AppendLine($"Generated: {DateTimeOffset.Now:o}");
|
||||
sb.AppendLine($"Dragon-ISO version: {version}");
|
||||
sb.AppendLine($"TeamsISO version: {version}");
|
||||
sb.AppendLine($".NET runtime: {Environment.Version}");
|
||||
sb.AppendLine($"OS: {Environment.OSVersion}");
|
||||
sb.AppendLine($"Machine: {Environment.MachineName}");
|
||||
|
|
@ -115,17 +115,17 @@ public static class DiagnosticsBundle
|
|||
private static string LogsDirectory =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Dragon-ISO", "Logs");
|
||||
"TeamsISO", "Logs");
|
||||
|
||||
private static string LocalAppDataPath(string fileName) =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Dragon-ISO", fileName);
|
||||
"TeamsISO", fileName);
|
||||
|
||||
private static string AppDataPath(string fileName) =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"Dragon-ISO", fileName);
|
||||
"TeamsISO", fileName);
|
||||
|
||||
private static string NdiConfigPath() =>
|
||||
Path.Combine(
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes NDI Access Manager's per-user config at
|
||||
/// <c>%APPDATA%\NDI\ndi-config.v1.json</c>. This file controls global NDI behavior for
|
||||
/// every NDI application on the machine — sender groups, receiver groups, RUDP/TCP
|
||||
/// every NDI application on the machine — sender groups, receiver groups, RUDP/TCP
|
||||
/// transport toggles, allowed adapters, etc. NDI applications read it on startup, so
|
||||
/// changes here only take effect after restarting the affected app (Teams, OBS, etc.).
|
||||
///
|
||||
/// We use it to implement the "transcoder topology" requested by the user: pin Teams'
|
||||
/// raw at-source-resolution NDI broadcasts to a private group (<c>Dragon-ISO-input</c>) so
|
||||
/// they don't pollute the production network, while Dragon-ISO's own clean normalized ISO
|
||||
/// raw at-source-resolution NDI broadcasts to a private group (<c>teamsiso-input</c>) so
|
||||
/// they don't pollute the production network, while TeamsISO's own clean normalized ISO
|
||||
/// outputs continue to broadcast on the standard <c>Public</c> group that downstream
|
||||
/// switchers and recorders default to.
|
||||
///
|
||||
|
|
@ -36,7 +36,7 @@ public static class NdiAccessManagerConfig
|
|||
/// Default name of the private group used for the transcoder topology.
|
||||
/// Matches the convention referenced in the NDI Network settings UI.
|
||||
/// </summary>
|
||||
public const string TranscoderInputGroup = "Dragon-ISO-input";
|
||||
public const string TranscoderInputGroup = "teamsiso-input";
|
||||
|
||||
/// <summary>
|
||||
/// Result of an apply attempt. <see cref="Success"/> indicates the file was
|
||||
|
|
@ -54,12 +54,12 @@ public static class NdiAccessManagerConfig
|
|||
/// Configures the machine-wide NDI groups so:
|
||||
/// <list type="bullet">
|
||||
/// <item>All local senders (Teams, anything else) broadcast on
|
||||
/// <paramref name="senderGroup"/> only — i.e. the private input group.</item>
|
||||
/// <paramref name="senderGroup"/> only — i.e. the private input group.</item>
|
||||
/// <item>All local receivers see both <paramref name="senderGroup"/> and
|
||||
/// <c>public</c> so Dragon-ISO can discover Teams' sources AND any
|
||||
/// <c>public</c> so TeamsISO can discover Teams' sources AND any
|
||||
/// standard public sources from elsewhere on the network.</item>
|
||||
/// </list>
|
||||
/// Dragon-ISO's own per-pipeline <c>OutputGroups</c> still overrides the per-machine
|
||||
/// TeamsISO's own per-pipeline <c>OutputGroups</c> still overrides the per-machine
|
||||
/// default at the sender level, so its normalized ISO outputs go on Public.
|
||||
/// </summary>
|
||||
/// <param name="senderGroup">Private group name for Teams' raw broadcasts.</param>
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Append-only show-notes log. Each call writes a timestamped line to a daily
|
||||
/// markdown file at <c>%LOCALAPPDATA%\Dragon-ISO\Notes\<YYYY-MM-DD>.md</c>.
|
||||
/// markdown file at <c>%LOCALAPPDATA%\TeamsISO\Notes\<YYYY-MM-DD>.md</c>.
|
||||
/// Operators stamp notes via the REST <c>POST /notes</c> endpoint or the OSC
|
||||
/// <c>/Dragon-ISO/notes "..."</c> address — typically wired to a Stream Deck
|
||||
/// <c>/teamsiso/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%\Dragon-ISO\Notes path. Lets tests write to a
|
||||
/// Test-only seam — when set, overrides the default
|
||||
/// %LOCALAPPDATA%\TeamsISO\Notes path. Lets tests write to a
|
||||
/// tempdir without polluting the dev's real notes folder.
|
||||
/// InternalsVisibleTo grants DragonISO.App.Tests access.
|
||||
/// InternalsVisibleTo grants TeamsISO.App.Tests access.
|
||||
/// </summary>
|
||||
internal static string? DirectoryOverride { get; set; }
|
||||
|
||||
private static string NotesDirectory =>
|
||||
DirectoryOverride ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Dragon-ISO", "Notes");
|
||||
"TeamsISO", "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 = $"# Dragon-ISO show notes — {DateTimeOffset.Now:yyyy-MM-dd}{Environment.NewLine}{Environment.NewLine}";
|
||||
var header = $"# TeamsISO show notes — {DateTimeOffset.Now:yyyy-MM-dd}{Environment.NewLine}{Environment.NewLine}";
|
||||
File.WriteAllText(path, header, Encoding.UTF8);
|
||||
}
|
||||
File.AppendAllText(path, line, Encoding.UTF8);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persistent named snapshots of which participants should have ISOs enabled and
|
||||
|
|
@ -10,7 +10,7 @@ namespace DragonISO.App.Services;
|
|||
/// meeting load the same preset and auto-enable everyone whose display name
|
||||
/// matches.
|
||||
///
|
||||
/// Persisted as JSON at <c>%LOCALAPPDATA%\Dragon-ISO\presets.json</c>. We key by
|
||||
/// Persisted as JSON at <c>%LOCALAPPDATA%\TeamsISO\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),
|
||||
"Dragon-ISO",
|
||||
"TeamsISO",
|
||||
"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 = "Dragon-ISO-presets-bundle/v1";
|
||||
public const string CurrentSchema = "teamsiso-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)
|
||||
{
|
||||
|
|
@ -1,39 +1,39 @@
|
|||
using System.Net;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using DragonISO.App.ViewModels;
|
||||
using DragonISO.Engine.Controller;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Controller;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.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 ("/Dragon-ISO/*") we
|
||||
/// — 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
|
||||
/// ignore it. Operators get a clear log line in either case.
|
||||
///
|
||||
/// Routes:
|
||||
/// /Dragon-ISO/iso "DisplayName" {0|1} — toggle/set ISO by display name
|
||||
/// /Dragon-ISO/iso/by-id "guid" {0|1} — toggle/set by Id
|
||||
/// /Dragon-ISO/preset "Name" — apply preset
|
||||
/// /Dragon-ISO/teams/mute — UIA toggle mute
|
||||
/// /Dragon-ISO/teams/camera — UIA toggle camera
|
||||
/// /Dragon-ISO/teams/leave — UIA leave
|
||||
/// /Dragon-ISO/teams/share — UIA share tray
|
||||
/// /Dragon-ISO/teams/raise-hand — UIA raise hand
|
||||
/// /Dragon-ISO/refresh-discovery — rebuild NDI finder
|
||||
/// /Dragon-ISO/stop-all — disable every ISO
|
||||
/// /Dragon-ISO/recording {0|1} — recording on/off (default dir)
|
||||
/// /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)
|
||||
/// </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 "/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;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// User-editable template for the NDI source name a participant's ISO is
|
||||
/// published as. Default <c>"{name}"</c> renders the speaker's display name
|
||||
/// directly, which is what downstream switchers want when they key on
|
||||
/// readable identifiers. Operators can override globally to
|
||||
/// <c>"Dragon-ISO_{guid}"</c> for the legacy stable-id behavior, or
|
||||
/// <c>"Dragon-ISO_{machine}_{name}"</c> when multiple Dragon-ISO machines feed
|
||||
/// <c>"TEAMSISO_{guid}"</c> for the legacy stable-id behavior, or
|
||||
/// <c>"TEAMSISO_{machine}_{name}"</c> when multiple TeamsISO 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 DragonISO.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>Dragon-ISO_{guid}</c> so
|
||||
/// name yet), <see cref="Render"/> falls back to <c>TEAMSISO_{guid}</c> so
|
||||
/// the NDI sender always has a usable, unique identifier.
|
||||
///
|
||||
/// Persisted to <c>%LOCALAPPDATA%\Dragon-ISO\output-name-template.txt</c>.
|
||||
/// Persisted to <c>%LOCALAPPDATA%\TeamsISO\output-name-template.txt</c>.
|
||||
/// </summary>
|
||||
public static class OutputNameTemplate
|
||||
{
|
||||
/// <summary>
|
||||
/// Default template — renders just the speaker's display name. Was
|
||||
/// <c>"Dragon-ISO_{guid}"</c> in pre-v1 builds; switched 2026-05-16 so
|
||||
/// Default template — renders just the speaker's display name. Was
|
||||
/// <c>"TEAMSISO_{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 = "Dragon-ISO_{guid}";
|
||||
private const string EmptyNameFallback = "TEAMSISO_{guid}";
|
||||
|
||||
private static string TemplatePath =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Dragon-ISO", "output-name-template.txt");
|
||||
"TeamsISO", "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
|
||||
// Dragon-ISO_{guid}. The Any(char.IsLetterOrDigit) check covers both
|
||||
// cases — anything without at least one alphanumeric is unusable.
|
||||
// TEAMSISO_{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
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
using System.Windows.Threading;
|
||||
using DragonISO.App.ViewModels;
|
||||
using DragonISO.Engine.Controller;
|
||||
using System.Windows.Threading;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Controller;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.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,
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using System.Windows.Automation;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.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 DragonISO.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 Dragon-ISO" workflow viable.
|
||||
/// drive it from TeamsISO" 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.
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.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 Dragon-ISO-owned host
|
||||
/// Reparents Teams' main top-level window into a TeamsISO-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 DragonISO.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 Dragon-ISO host.</summary>
|
||||
/// <summary>True when a Teams window is currently parented inside a TeamsISO 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;
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Small Win32 wrapper that launches the Microsoft Teams desktop client as a
|
||||
/// subprocess of DragonISO. First step toward Phase E.1 of the embedded-Teams
|
||||
/// subprocess of TeamsISO. First step toward Phase E.1 of the embedded-Teams
|
||||
/// roadmap (see docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md):
|
||||
/// the operator can launch Teams from within Dragon-ISO so they don't have to
|
||||
/// the operator can launch Teams from within TeamsISO so they don't have to
|
||||
/// switch apps to start a meeting.
|
||||
///
|
||||
/// The launcher tries (in order):
|
||||
|
|
@ -17,7 +17,7 @@ namespace DragonISO.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 Dragon-ISO's quick-join field instead of opening Teams,
|
||||
/// into TeamsISO's quick-join field instead of opening Teams,
|
||||
/// hunting down the calendar entry, and clicking Join. With auto-hide
|
||||
/// on, the Teams window flashes briefly then disappears; the operator
|
||||
/// is now in the meeting, driving routing from DragonISO.
|
||||
/// is now in the meeting, driving routing from TeamsISO.
|
||||
///
|
||||
/// 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 DragonISO. We do this by enumerating top-level windows,
|
||||
// operator only sees TeamsISO. 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 Dragon-ISO's UI without burning a UIA traversal.
|
||||
/// meeting context to TeamsISO'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.
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.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 DragonISO.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 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";
|
||||
// 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";
|
||||
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%\Dragon-ISO\ui-prefs.json. Returns null on any read
|
||||
/// %LOCALAPPDATA%\TeamsISO\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));
|
||||
}
|
||||
|
|
@ -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 DragonISO.App.Services;
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a WinForms <see cref="WinForms.NotifyIcon"/> so the WPF host can
|
||||
/// minimize-to-tray during long shows. Operators with a Stream Deck setup
|
||||
/// often want Dragon-ISO running but invisible — the tray icon keeps the
|
||||
/// often want TeamsISO 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 = "Dragon-ISO",
|
||||
Text = "TeamsISO",
|
||||
Icon = LoadEmbeddedIcon(),
|
||||
Visible = false,
|
||||
};
|
||||
|
|
@ -76,7 +76,7 @@ public sealed class TrayIconHost : IDisposable
|
|||
_notifyIcon.Visible = true;
|
||||
_notifyIcon.ShowBalloonTip(
|
||||
timeout: 1500,
|
||||
tipTitle: "Dragon-ISO is still running",
|
||||
tipTitle: "TeamsISO 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 Dragon-ISO", null, (_, _) => RestoreFromTray());
|
||||
menu.Items.Add("Show TeamsISO", 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 Dragon-ISO", null, (_, _) => System.Windows.Application.Current.Shutdown());
|
||||
menu.Items.Add("Exit TeamsISO", null, (_, _) => System.Windows.Application.Current.Shutdown());
|
||||
return menu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load the bundled DragonISO.ico from this assembly's resources. We use
|
||||
/// Load the bundled teamsiso.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/DragonISO.ico");
|
||||
var uri = new Uri("pack://application:,,,/Assets/teamsiso.ico");
|
||||
using var stream = System.Windows.Application.GetResourceStream(uri)?.Stream;
|
||||
if (stream is not null) return new Icon(stream);
|
||||
}
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persistent UI-side toggles that don't belong in <see cref="DragonISO.Engine.Persistence.ConfigStore"/>
|
||||
/// (which is the engine's domain model — framerate, NDI groups, ISO assignments).
|
||||
/// 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).
|
||||
///
|
||||
/// Each toggle is a property on a single record persisted as JSON at
|
||||
/// <c>%LOCALAPPDATA%\Dragon-ISO\ui-prefs.json</c>. Defaults match the original
|
||||
/// <c>%LOCALAPPDATA%\TeamsISO\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),
|
||||
"Dragon-ISO", "ui-prefs.json");
|
||||
"TeamsISO", "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
|
||||
// Dragon-ISO and never sees the Teams UI — Teams auto-starts in the
|
||||
// TeamsISO and never sees the Teams UI — Teams auto-starts in the
|
||||
// background and its windows are auto-hidden as soon as they materialize.
|
||||
// All control happens via the IN-CALL bar + participants DataGrid.
|
||||
bool LaunchTeamsOnStartup = false,
|
||||
bool AutoHideTeamsWindows = false,
|
||||
// Experimental Phase E.4. SetParent-reparents Teams' main window
|
||||
// into a Dragon-ISO-owned host. WebView2 in modern Teams can render
|
||||
// into a TeamsISO-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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 DragonISO.App.Services;
|
||||
namespace TeamsISO.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/Dragon-ISO/releases?limit=1
|
||||
/// GET /api/v1/repos/zgaetano/teamsiso/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 DragonISO.App.Services;
|
|||
public static class UpdateChecker
|
||||
{
|
||||
private const string ReleasesApi =
|
||||
"https://forge.wilddragon.net/api/v1/repos/zgaetano/Dragon-ISO/releases?limit=1";
|
||||
"https://forge.wilddragon.net/api/v1/repos/zgaetano/teamsiso/releases?limit=1";
|
||||
|
||||
private const string ReleasesPage =
|
||||
"https://forge.wilddragon.net/zgaetano/Dragon-ISO/releases";
|
||||
"https://forge.wilddragon.net/zgaetano/teamsiso/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", "Dragon-ISO/" + current);
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "TeamsISO/" + 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%\Dragon-ISO\last-update-check.txt</c> as an ISO 8601
|
||||
/// in <c>%LOCALAPPDATA%\TeamsISO\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%\Dragon-ISO path that holds the cooldown stamp +
|
||||
/// Test-only seam — when set, overrides the default
|
||||
/// %LOCALAPPDATA%\TeamsISO 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),
|
||||
"Dragon-ISO");
|
||||
"TeamsISO");
|
||||
|
||||
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>
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
|
||||
namespace DragonISO.App.Services;
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Saves / restores the main window's size, position, and state across launches.
|
||||
/// Stored as JSON at <c>%LOCALAPPDATA%\Dragon-ISO\window.json</c>. Multi-monitor
|
||||
/// Stored as JSON at <c>%LOCALAPPDATA%\TeamsISO\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 DragonISO.App.Services;
|
|||
public static class WindowStateStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Test-only seam — when set, overrides the default
|
||||
/// %LOCALAPPDATA%\Dragon-ISO\window.json path. Lets tests verify
|
||||
/// Test-only seam — when set, overrides the default
|
||||
/// %LOCALAPPDATA%\TeamsISO\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),
|
||||
"Dragon-ISO",
|
||||
"TeamsISO",
|
||||
"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;
|
||||
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
using System.IO;
|
||||
using System.IO;
|
||||
|
||||
namespace DragonISO.App;
|
||||
namespace TeamsISO.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%\Dragon-ISO\startup-trace.log</c>. Grows
|
||||
/// File lives at <c>%LOCALAPPDATA%\TeamsISO\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),
|
||||
"Dragon-ISO");
|
||||
"TeamsISO");
|
||||
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}";
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<Window x:Class="DragonISO.App.TeamsEmbedWindow"
|
||||
<Window x:Class="TeamsISO.App.TeamsEmbedWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
Title="Teams (embedded)"
|
||||
Icon="/Assets/DragonISO.ico"
|
||||
Icon="/Assets/teamsiso.ico"
|
||||
Width="1280" Height="720"
|
||||
MinWidth="640" MinHeight="360"
|
||||
Background="Black"
|
||||
|
|
@ -1,22 +1,22 @@
|
|||
using System.Windows;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using DragonISO.App.Services;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace DragonISO.App;
|
||||
namespace TeamsISO.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.",
|
||||
"Dragon-ISO — embed",
|
||||
"TeamsISO — 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.",
|
||||
"Dragon-ISO — embed",
|
||||
"TeamsISO — 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();
|
||||
|
|
@ -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>DragonISO.App</RootNamespace>
|
||||
<AssemblyName>DragonISO</AssemblyName>
|
||||
<RootNamespace>TeamsISO.App</RootNamespace>
|
||||
<AssemblyName>TeamsISO</AssemblyName>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<ApplicationIcon>Assets\Dragon-ISO.ico</ApplicationIcon>
|
||||
<ApplicationIcon>Assets\teamsiso.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="..\Dragon-ISO.Engine\Dragon-ISO.Engine.csproj" />
|
||||
<ProjectReference Include="..\Dragon-ISO.Engine.NdiInterop\Dragon-ISO.Engine.NdiInterop.csproj" />
|
||||
<ProjectReference Include="..\TeamsISO.Engine\TeamsISO.Engine.csproj" />
|
||||
<ProjectReference Include="..\TeamsISO.Engine.NdiInterop\TeamsISO.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>Dragon-ISO.App.Tests</_Parameter1>
|
||||
<_Parameter1>TeamsISO.App.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Strings.resx — user-facing English MessageBox copy. Embedded as a
|
||||
.NET resource so ResourceManager can resolve Dragon-ISO.App.Properties.Strings
|
||||
Strings.resx — user-facing English MessageBox copy. Embedded as a
|
||||
.NET resource so ResourceManager can resolve TeamsISO.App.Properties.Strings
|
||||
by basename. Strings.Designer.cs is hand-written (see file comment).
|
||||
-->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Properties\Strings.resx">
|
||||
<LogicalName>Dragon-ISO.App.Properties.Strings.resources</LogicalName>
|
||||
<LogicalName>TeamsISO.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\Dragon-ISO.ico" />
|
||||
<Resource Include="Assets\teamsiso.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.
|
||||
|
|
@ -58,6 +58,6 @@
|
|||
of the resource dictionary.
|
||||
-->
|
||||
<BitmapImage x:Key="Wd.BrandMark.Image"
|
||||
UriSource="pack://application:,,,/DragonISO;component/Assets/dragon-mark-white.png"
|
||||
UriSource="pack://application:,,,/TeamsISO;component/Assets/dragon-mark-white.png"
|
||||
CacheOption="OnLoad"/>
|
||||
</ResourceDictionary>
|
||||
|
|
@ -55,6 +55,6 @@
|
|||
See Theme.Dark.xaml's comment for the CacheOption=OnLoad rationale.
|
||||
-->
|
||||
<BitmapImage x:Key="Wd.BrandMark.Image"
|
||||
UriSource="pack://application:,,,/DragonISO;component/Assets/dragon-mark-black.png"
|
||||
UriSource="pack://application:,,,/TeamsISO;component/Assets/dragon-mark-black.png"
|
||||
CacheOption="OnLoad"/>
|
||||
</ResourceDictionary>
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
xmlns:sys="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!--
|
||||
DragonISO design system — Wild Dragon brand × Microsoft Teams layout.
|
||||
TeamsISO design system — Wild Dragon brand × Microsoft Teams layout.
|
||||
|
||||
Brand reference: wilddragon.net
|
||||
Primary canvas: #0a0a0a
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using DragonISO.Engine.Domain;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace DragonISO.App.ViewModels;
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
||||
public sealed class AlertBannerViewModel : ObservableObject
|
||||
{
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Windows.Threading;
|
||||
using DragonISO.App.Services;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace DragonISO.App.ViewModels;
|
||||
namespace TeamsISO.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 Dragon-ISO-input private", null,
|
||||
// ─── NETWORK ───
|
||||
new("Network", "Apply transcoder topology", "ndi groups isolate teamsiso-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(
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using DragonISO.App.Services;
|
||||
using DragonISO.Engine.Controller;
|
||||
using DragonISO.Engine.Domain;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace DragonISO.App.ViewModels;
|
||||
namespace TeamsISO.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 = DragonISO.App.Services.OutputNameTemplate.Get();
|
||||
private string _outputNameTemplate = TeamsISO.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%\Dragon-ISO\ui-prefs.json — defaults match the original
|
||||
// %LOCALAPPDATA%\TeamsISO\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 Dragon-ISO defaults?\n\n" +
|
||||
"Reset framerate, resolution, aspect and audio to TeamsISO defaults?\n\n" +
|
||||
"This won't touch your NDI group configuration or display toggles.",
|
||||
"Dragon-ISO — Reset output defaults",
|
||||
"TeamsISO — 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 Dragon-ISO
|
||||
/// Useful for long unattended shows where the operator wants TeamsISO
|
||||
/// 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 Dragon-ISO starts.
|
||||
/// Auto-launch the Microsoft Teams desktop client when TeamsISO starts.
|
||||
/// Paired with <see cref="AutoHideTeamsWindows"/> gives the operator a
|
||||
/// "Dragon-ISO is the only window I see" experience — Teams runs in the
|
||||
/// "TeamsISO 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 Dragon-ISO-
|
||||
/// EXPERIMENTAL: SetParent-reparents Teams' main window into a TeamsISO-
|
||||
/// 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
|
||||
/// DragonISO. The OSC bridge follows suit if it's running.
|
||||
/// TeamsISO. 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>"Dragon-ISO_{guid}"</c> in 0.9.0-rc19, since downstream switchers
|
||||
/// <c>"TEAMSISO_{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))
|
||||
{
|
||||
DragonISO.App.Services.OutputNameTemplate.Set(value ?? string.Empty);
|
||||
TeamsISO.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 Dragon-ISO restores the same routing on every
|
||||
/// once, and from that point on TeamsISO 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 ("Dragon-ISO-input") while local
|
||||
/// 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
|
||||
/// 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}",
|
||||
"Dragon-ISO — Apply transcoder topology",
|
||||
"TeamsISO — 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 '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" +
|
||||
"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" +
|
||||
"RESTART Microsoft Teams for the new NDI config to take effect there.\n\n" +
|
||||
backupNote,
|
||||
"Dragon-ISO — Apply transcoder topology",
|
||||
"TeamsISO — 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DragonISO.Engine.Controller;
|
||||
using DragonISO.Engine.Domain;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace DragonISO.App.ViewModels;
|
||||
namespace TeamsISO.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;
|
||||
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
using DragonISO.App.Services;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace DragonISO.App.ViewModels;
|
||||
namespace TeamsISO.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.",
|
||||
"Dragon-ISO — Stop all ISOs",
|
||||
"TeamsISO — 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\Dragon-ISO\. One folder per Snapshot All click
|
||||
/// %USERPROFILE%\Pictures\TeamsISO\. 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),
|
||||
"Dragon-ISO",
|
||||
"TeamsISO",
|
||||
$"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\\Dragon-ISO\\{System.IO.Path.GetFileName(rootDir)}"
|
||||
: $"Saved {saved} snapshot(s) to Pictures\\Dragon-ISO\\{System.IO.Path.GetFileName(rootDir)}");
|
||||
? $"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)}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,23 @@
|
|||
using System.Windows.Threading;
|
||||
using DragonISO.App.Services;
|
||||
using System.Windows.Threading;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace DragonISO.App.ViewModels;
|
||||
namespace TeamsISO.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 5–10s 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)"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
using System.Windows.Threading;
|
||||
using DragonISO.App.Services;
|
||||
using System.Windows.Threading;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace DragonISO.App.ViewModels;
|
||||
namespace TeamsISO.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 50–200ms 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 */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 DragonISO.App.Services;
|
||||
using DragonISO.Engine.Controller;
|
||||
using DragonISO.Engine.Domain;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace DragonISO.App.ViewModels;
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level view model for the main window. Owns the live collection of <see cref="ParticipantViewModel"/>,
|
||||
|
|
@ -16,10 +16,10 @@ namespace DragonISO.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 Dragon-ISO doesn't
|
||||
// Showing a Window from a VM violates strict MVVM, but TeamsISO 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 Dragon-ISO's UI but is broken downstream.
|
||||
// looks fine in TeamsISO'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)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using System.ComponentModel;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace DragonISO.App.ViewModels;
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal MVVM base class implementing <see cref="INotifyPropertyChanged"/>.
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
using System.Windows;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using DragonISO.Engine.Controller;
|
||||
using DragonISO.Engine.Domain;
|
||||
using DragonISO.Engine.Pipeline;
|
||||
using IsoHealthStats = DragonISO.Engine.Domain.IsoHealthStats;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Domain;
|
||||
using TeamsISO.Engine.Pipeline;
|
||||
using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats;
|
||||
|
||||
namespace DragonISO.App.ViewModels;
|
||||
namespace TeamsISO.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\Dragon-ISO\</c>. Used
|
||||
/// by the participants' context menu for grabbing a stillframe — useful
|
||||
/// as a PNG and write it to <c>%USERPROFILE%\Pictures\TeamsISO\</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),
|
||||
"Dragon-ISO");
|
||||
"TeamsISO");
|
||||
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 Dragon-ISO will broadcast this participant as. Prefers
|
||||
/// The NDI source name TeamsISO will broadcast this participant as. Prefers
|
||||
/// the operator's <see cref="CustomName"/> when set; otherwise renders the
|
||||
/// active template (default <c>"{name}"</c>, falling back to
|
||||
/// <c>Dragon-ISO_{guid}</c> when the participant has no display name yet).
|
||||
/// <c>TEAMSISO_{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
|
||||
// Dragon-ISO_{guid} inside Render). Passing the rendered name
|
||||
// TEAMSISO_{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}");
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using System.Windows.Input;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace DragonISO.App.ViewModels;
|
||||
namespace TeamsISO.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>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using System.Windows.Input;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace DragonISO.App.ViewModels;
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight transient-notification view-model. The main view holds a single
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using DragonISO.App.Services;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace DragonISO.App.ViewModels;
|
||||
namespace TeamsISO.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; }
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
<Window x:Class="DragonISO.App.Views.CommandPaletteWindow"
|
||||
<Window x:Class="TeamsISO.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:DragonISO.App.ViewModels"
|
||||
xmlns:conv="clr-namespace:DragonISO.App.Converters"
|
||||
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels"
|
||||
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
|
||||
Width="560" Height="360"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
WindowStyle="None"
|
||||