chore: sweep orphaned files (UpdateChecker, UpdateBanner, TeamsControlBridge, helper scripts)
Some checks failed
CI / build-and-test (push) Failing after 25s
Some checks failed
CI / build-and-test (push) Failing after 25s
This commit is contained in:
parent
dd7827de82
commit
63bd93d0c2
9 changed files with 1393 additions and 0 deletions
41
build-and-test.ps1
Normal file
41
build-and-test.ps1
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Quick build + test verification before commit-and-push.ps1.
|
||||||
|
#
|
||||||
|
# Run from the repo root:
|
||||||
|
# pwsh -ExecutionPolicy Bypass -File .\build-and-test.ps1
|
||||||
|
#
|
||||||
|
# Builds TeamsISO.Windows.slnf in Release with TreatWarningsAsErrors=true
|
||||||
|
# (the Directory.Build.props default), then runs unit tests excluding the
|
||||||
|
# requires=ndi tier (those need a live NDI runtime).
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if (-not (Test-Path 'TeamsISO.Windows.slnf')) {
|
||||||
|
throw "Run from the TeamsISO repo root."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== dotnet --version ===" -ForegroundColor Cyan
|
||||||
|
dotnet --version
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Restore ===" -ForegroundColor Cyan
|
||||||
|
dotnet restore TeamsISO.Windows.slnf
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Restore failed." }
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Build (Release, TreatWarningsAsErrors=true) ===" -ForegroundColor Cyan
|
||||||
|
dotnet build TeamsISO.Windows.slnf --configuration Release --no-restore --nologo
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Build failed. Most likely cause for this batch: System.Windows.Automation needs an explicit Reference. If so, add to src/TeamsISO.App/TeamsISO.App.csproj inside an <ItemGroup>: <Reference Include='UIAutomationClient' /> <Reference Include='UIAutomationTypes' />"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Tests (excluding requires=ndi) ===" -ForegroundColor Cyan
|
||||||
|
dotnet test TeamsISO.Windows.slnf `
|
||||||
|
--configuration Release `
|
||||||
|
--no-build `
|
||||||
|
--nologo `
|
||||||
|
--filter "Category!=ndi&requires!=ndi"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Tests failed." }
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Build + tests green. Now run .\commit-and-push.ps1 to ship." -ForegroundColor Green
|
||||||
443
commit-and-push.ps1
Normal file
443
commit-and-push.ps1
Normal file
|
|
@ -0,0 +1,443 @@
|
||||||
|
# Commit + push the May 2026 polish batch to forge.wilddragon.net.
|
||||||
|
#
|
||||||
|
# Run from the repo root:
|
||||||
|
# pwsh -ExecutionPolicy Bypass -File .\commit-and-push.ps1
|
||||||
|
#
|
||||||
|
# Splits into 8 atomic commits (#59, #61, #64-#69), then pushes origin/main.
|
||||||
|
# Stops on first error so you can resolve and re-run.
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# Ensure we're at repo root.
|
||||||
|
if (-not (Test-Path '.git') -or -not (Test-Path 'TeamsISO.sln')) {
|
||||||
|
throw "Run from the TeamsISO repo root."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tidy up the diagnostic artifact I left while probing the sandbox.
|
||||||
|
if (Test-Path '.claude-bash-test.txt') {
|
||||||
|
Remove-Item '.claude-bash-test.txt' -Force
|
||||||
|
Write-Host "Removed sandbox diagnostic file." -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── helper ─────────────────────────────────────────────────────────────
|
||||||
|
function Stage-AndCommit($message, [string[]]$paths) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "──── $message ────" -ForegroundColor Cyan
|
||||||
|
foreach ($p in $paths) {
|
||||||
|
if (Test-Path $p) {
|
||||||
|
git add -- $p
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git add failed for $p" }
|
||||||
|
} else {
|
||||||
|
Write-Warning "Path not found, skipping: $p"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Anything actually staged?
|
||||||
|
git diff --cached --quiet
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host " (no changes to commit; skipping)" -ForegroundColor DarkGray
|
||||||
|
return
|
||||||
|
}
|
||||||
|
git commit -m $message
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $message" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── #59 Auto-disable on participant departure ─────────────────────────
|
||||||
|
# View-model gained AutoDisableOnDeparture; MainViewModel hooks departure;
|
||||||
|
# DISPLAY settings shows the toggle.
|
||||||
|
# (These three files also carry later changes — staging them here means the
|
||||||
|
# first commit captures only the auto-disable additions IF you've checked
|
||||||
|
# the diff is clean. If `git diff --cached` after the add looks bigger than
|
||||||
|
# the auto-disable feature alone, abort, edit the message, and let the
|
||||||
|
# combined commit cover #59 as part of the broader UI batch.)
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat(ui): auto-disable ISOs when participants leave the meeting" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #61 Operator presets ──────────────────────────────────────────────
|
||||||
|
# Only the new files; the wiring into MainWindow header / MainViewModel
|
||||||
|
# was already staged above as part of #59 (because all three commits touch
|
||||||
|
# MainWindow.xaml / MainViewModel.cs, the cleanest atomic split would
|
||||||
|
# require git add -p; for batch-push we accept that the boundary is
|
||||||
|
# approximate and the headline message reflects the dominant change).
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat(ui): operator presets — save/load named ISO assignment snapshots" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/Services/OperatorPresetStore.cs",
|
||||||
|
"src/TeamsISO.App/PresetsDialog.xaml",
|
||||||
|
"src/TeamsISO.App/PresetsDialog.xaml.cs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #64 Optional MSI / exe code-signing in release.yml ────────────────
|
||||||
|
Stage-AndCommit `
|
||||||
|
"ci: optional MSI + exe code-signing in release.yml" `
|
||||||
|
@(
|
||||||
|
".forgejo/workflows/release.yml",
|
||||||
|
"docs/RELEASING.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #65 Refresh discovery affordance ──────────────────────────────────
|
||||||
|
# Includes engine-side RefreshDiscovery + idempotent re-Add + regression test.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat(engine): refresh discovery affordance + idempotent re-Add handling" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs",
|
||||||
|
"src/TeamsISO.Engine/Discovery/ParticipantTracker.cs",
|
||||||
|
"src/TeamsISO.Engine/Controller/IIsoController.cs",
|
||||||
|
"src/TeamsISO.Engine/Controller/IsoController.cs",
|
||||||
|
"src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #66 / #67 / #68 / #69 UI batch ────────────────────────────────────
|
||||||
|
# These four features all touch MainViewModel.cs / MainWindow.xaml / theme
|
||||||
|
# files together, so a per-feature split is impractical without git add -p.
|
||||||
|
# We commit as one batch with a descriptive message.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat(ui): May 2026 batch — auto-apply preset, settings tabs, Phase E.2/E.3" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/Services/TeamsLauncher.cs",
|
||||||
|
"src/TeamsISO.App/Services/TeamsControlBridge.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml.cs",
|
||||||
|
"src/TeamsISO.App/Themes/WildDragonTheme.xaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #70 / #71 / #73 Hardening + onboarding ────────────────────────────
|
||||||
|
# Crash diagnostics, first-launch welcome dialog, Reset-to-defaults button.
|
||||||
|
# Touches App.xaml.cs, AboutWindow (re-open onboarding link), and adds the
|
||||||
|
# new OnboardingWindow files.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat(ui): crash diagnostics, first-launch welcome, reset-to-defaults" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/App.xaml.cs",
|
||||||
|
"src/TeamsISO.App/OnboardingWindow.xaml",
|
||||||
|
"src/TeamsISO.App/OnboardingWindow.xaml.cs",
|
||||||
|
"src/TeamsISO.App/AboutWindow.xaml",
|
||||||
|
"src/TeamsISO.App/AboutWindow.xaml.cs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #77 Per-output recording ──────────────────────────────────────────
|
||||||
|
# IRecorderSink + RawBgraRecorderSink + IsoPipelineConfig.Recorder wiring +
|
||||||
|
# IsoController.SetRecording + UI checkbox in DISPLAY tab.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat: per-output recording — raw BGRA stream + ffmpeg convert.cmd" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.Engine/Pipeline/IRecorderSink.cs",
|
||||||
|
"src/TeamsISO.Engine/Pipeline/RawBgraRecorderSink.cs",
|
||||||
|
"src/TeamsISO.Engine/Pipeline/IsoPipelineConfig.cs",
|
||||||
|
"src/TeamsISO.Engine/Pipeline/IsoPipeline.cs",
|
||||||
|
"src/TeamsISO.Engine/Controller/IIsoController.cs",
|
||||||
|
"src/TeamsISO.Engine/Controller/IsoController.cs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #78 / #79 REST control surface + preset apply lift ───────────────
|
||||||
|
# ControlSurfaceServer + PresetApplier (lifted from PresetsDialog) +
|
||||||
|
# REST endpoints + DISPLAY tab toggle + CONTROL-SURFACE.md docs.
|
||||||
|
# PresetsDialog and MainViewModel.TryAutoApplyPendingPreset both delegate
|
||||||
|
# to PresetApplier so apply has a single implementation across the dialog,
|
||||||
|
# auto-apply-on-launch, and the REST surface.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat: REST control surface + lift preset-apply into PresetApplier" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
|
||||||
|
"src/TeamsISO.App/Services/PresetApplier.cs",
|
||||||
|
"src/TeamsISO.App/PresetsDialog.xaml.cs",
|
||||||
|
"docs/CONTROL-SURFACE.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #80 In-app preview thumbnails ─────────────────────────────────────
|
||||||
|
# Engine: IsoPipeline.LatestProcessedFrame + IsoController.GetLatestProcessedFrame.
|
||||||
|
# UI: ParticipantViewModel.Thumbnail (WriteableBitmap, BGRA, 160x90 nearest-neighbor),
|
||||||
|
# DataGrid Preview column, .csproj AllowUnsafeBlocks.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat: in-app preview thumbnails per participant" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.Engine/Pipeline/IsoPipeline.cs",
|
||||||
|
"src/TeamsISO.Engine/Controller/IIsoController.cs",
|
||||||
|
"src/TeamsISO.Engine/Controller/IsoController.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml",
|
||||||
|
"src/TeamsISO.App/TeamsISO.App.csproj"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #81 / #82 WebSocket push + OSC bridge ─────────────────────────────
|
||||||
|
# /ws on the existing HTTP listener for live state push; OscBridge as a
|
||||||
|
# parallel UDP listener using the same command vocabulary.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat: WebSocket live-state push + OSC bridge" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
|
||||||
|
"src/TeamsISO.App/Services/OscBridge.cs",
|
||||||
|
"src/TeamsISO.App/App.xaml.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml",
|
||||||
|
"docs/CONTROL-SURFACE.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #83 / #85 Update check (manual + auto-on-launch) ─────────────────
|
||||||
|
# Manual "Check for updates" in About + silent throttled launch-time check
|
||||||
|
# with banner above the participants area.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat: update check — manual in About + auto-on-launch with banner" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/Services/UpdateChecker.cs",
|
||||||
|
"src/TeamsISO.App/AboutWindow.xaml",
|
||||||
|
"src/TeamsISO.App/AboutWindow.xaml.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/UpdateBannerViewModel.cs",
|
||||||
|
"src/TeamsISO.App/App.xaml.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #86 Preset import / export ────────────────────────────────────────
|
||||||
|
# OperatorPresetStore.ExportAllAsJson + ImportBundle + Export/Import buttons
|
||||||
|
# in the Presets dialog footer.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat: preset import / export bundles" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/Services/OperatorPresetStore.cs",
|
||||||
|
"src/TeamsISO.App/PresetsDialog.xaml",
|
||||||
|
"src/TeamsISO.App/PresetsDialog.xaml.cs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #87 Recording markers ─────────────────────────────────────────────
|
||||||
|
# IRecorderSink.AddMarker fan-out via IIsoController.AddRecordingMarker;
|
||||||
|
# UI button in IN-CALL bar; REST + OSC endpoints; manifest.json gets
|
||||||
|
# markers[] array.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat: recording markers (UI button + REST + OSC + manifest array)" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.Engine/Pipeline/IRecorderSink.cs",
|
||||||
|
"src/TeamsISO.Engine/Pipeline/RawBgraRecorderSink.cs",
|
||||||
|
"src/TeamsISO.Engine/Controller/IIsoController.cs",
|
||||||
|
"src/TeamsISO.Engine/Controller/IsoController.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml",
|
||||||
|
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
|
||||||
|
"src/TeamsISO.App/Services/OscBridge.cs",
|
||||||
|
"docs/CONTROL-SURFACE.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #88 / #89 NDI name template + enriched footer ─────────────────────
|
||||||
|
# OutputNameTemplate static helper + ParticipantViewModel uses it on Toggle;
|
||||||
|
# footer gains REC badge + Control-Surface badge.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat: custom NDI output name template + enriched status bar" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/Services/OutputNameTemplate.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #90 / #91 Disk space watcher + diagnostics bundle ─────────────────
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat: disk space watcher + diagnostic bundle export" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/Services/DiskSpaceWatcher.cs",
|
||||||
|
"src/TeamsISO.App/Services/DiagnosticsBundle.cs",
|
||||||
|
"src/TeamsISO.App/App.xaml.cs",
|
||||||
|
"src/TeamsISO.App/AboutWindow.xaml",
|
||||||
|
"src/TeamsISO.App/AboutWindow.xaml.cs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #92 Per-participant recording opt-out ─────────────────────────────
|
||||||
|
# IsoController.EnableIsoAsync overload taking record-override; UI checkbox
|
||||||
|
# in DataGrid bound to ParticipantViewModel.RecordToDisk.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat: per-participant recording opt-out (Rec column in DataGrid)" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.Engine/Controller/IIsoController.cs",
|
||||||
|
"src/TeamsISO.Engine/Controller/IsoController.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #93 / #94 Keyboard shortcuts + help cheat sheet ───────────────────
|
||||||
|
# F1 / Ctrl+M / Ctrl+Shift+S / Ctrl+R InputBindings + HelpWindow dialog.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat: window-scoped keyboard shortcuts + help cheat sheet (F1)" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/HelpWindow.xaml",
|
||||||
|
"src/TeamsISO.App/HelpWindow.xaml.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #95 / #96 / #97 Bulk enable + filter + context menu ───────────────
|
||||||
|
# EnableAllOnlineCommand, ParticipantsView with live filter, right-click
|
||||||
|
# context menu on DataGrid rows.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat: bulk enable + participant filter + right-click context menu" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #98 / #99 / #100 / #101 / #102 Operator polish batch ─────────────
|
||||||
|
# --apply-preset CLI, dynamic status with live counts, embedded HTML panel
|
||||||
|
# at /ui, session timer in footer, NotesService + REST/OSC notes endpoint.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat: CLI flags, dynamic status, HTML panel, session timer, notes" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/App.xaml.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml",
|
||||||
|
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
|
||||||
|
"src/TeamsISO.App/Services/ControlPanelHtml.cs",
|
||||||
|
"src/TeamsISO.App/Services/OscBridge.cs",
|
||||||
|
"src/TeamsISO.App/Services/NotesService.cs",
|
||||||
|
"docs/CONTROL-SURFACE.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #103 Duplicate preset action ──────────────────────────────────────
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat(ui): duplicate-preset action in Presets dialog" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/PresetsDialog.xaml",
|
||||||
|
"src/TeamsISO.App/PresetsDialog.xaml.cs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #104 CHANGELOG.md ─────────────────────────────────────────────────
|
||||||
|
Stage-AndCommit `
|
||||||
|
"docs: add CHANGELOG.md tracking the May 2026 batch" `
|
||||||
|
@(
|
||||||
|
"CHANGELOG.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #105 / #106 / #107 Final UI polish ───────────────────────────────
|
||||||
|
# NotesWindow viewer + ShowNotesCommand + IN-CALL bar Notes button + README
|
||||||
|
# rewrite. Confirm-before-Stop-All (catches mid-show misclicks).
|
||||||
|
# About dialog gained "Logs / Recordings / Notes" folder shortcut buttons.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat(ui): notes viewer + Stop-All confirm + folder shortcuts + README" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/NotesWindow.xaml",
|
||||||
|
"src/TeamsISO.App/NotesWindow.xaml.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml",
|
||||||
|
"src/TeamsISO.App/AboutWindow.xaml",
|
||||||
|
"src/TeamsISO.App/AboutWindow.xaml.cs",
|
||||||
|
"README.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #116 / #117 / #118 Operator polish (toast, restart, roll) ───────
|
||||||
|
# Always-toast on participant disconnect (not just auto-disable path).
|
||||||
|
# Per-pipeline "Restart this ISO" right-click action.
|
||||||
|
# "Roll recording" via UI command + REST /recording/roll + OSC.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat(ui+control): disconnect toast, per-pipeline restart, roll recording" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml",
|
||||||
|
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
|
||||||
|
"src/TeamsISO.App/Services/OscBridge.cs",
|
||||||
|
"docs/CONTROL-SURFACE.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #115 Test-pattern generator + console flag ──────────────────────
|
||||||
|
# TestPatternGenerator: SMPTE color bars + sweep band BGRA frames.
|
||||||
|
# TeamsISO.Console --test-pattern broadcasts TEAMSISO_TEST at 720p30.
|
||||||
|
# Useful for verifying NDI runtime without Teams running.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat(engine+console): SMPTE test-pattern generator + --test-pattern flag" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.Engine/Pipeline/TestPatternGenerator.cs",
|
||||||
|
"src/TeamsISO.Console/Program.cs",
|
||||||
|
"src/tests/TeamsISO.Engine.Tests/Pipeline/TestPatternGeneratorTests.cs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #114 / #119 Tray icon + WinForms/WPF disambiguation ─────────────
|
||||||
|
# Adds System.Windows.Forms via UseWindowsForms=true for NotifyIcon.
|
||||||
|
# GlobalUsings.cs aliases Application + MessageBox to WPF (resolves
|
||||||
|
# CS0104 ambiguity caused by WinForms exposing same-named types).
|
||||||
|
# ControlSurfaceServer.cs gained explicit `using System.IO;` (implicit
|
||||||
|
# usings shifted with UseWindowsForms).
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat(ui): system tray icon + WinForms/WPF namespace disambiguation" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/Services/TrayIconHost.cs",
|
||||||
|
"src/TeamsISO.App/Services/UIPreferences.cs",
|
||||||
|
"src/TeamsISO.App/App.xaml.cs",
|
||||||
|
"src/TeamsISO.App/TeamsISO.App.csproj",
|
||||||
|
"src/TeamsISO.App/GlobalUsings.cs",
|
||||||
|
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #76 / #74 / #112 Tests + audio meter scaffold + MF recorder ─────
|
||||||
|
# OperatorPresetStore + OutputNameTemplate + OscMessage tests in a new
|
||||||
|
# net8.0-windows test project. Audio level VU bar in DataGrid (engine
|
||||||
|
# field added; capture path is a follow-up). MediaFoundationRecorderSink
|
||||||
|
# scaffold gated behind MF_AVAILABLE build symbol.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"test+feat: App.Tests project + audio VU scaffold + MF recorder stub" `
|
||||||
|
@(
|
||||||
|
"src/tests/TeamsISO.App.Tests/TeamsISO.App.Tests.csproj",
|
||||||
|
"src/tests/TeamsISO.App.Tests/Services/OperatorPresetStoreTests.cs",
|
||||||
|
"src/tests/TeamsISO.App.Tests/Services/OutputNameTemplateTests.cs",
|
||||||
|
"src/tests/TeamsISO.App.Tests/Services/OscMessageTests.cs",
|
||||||
|
"src/TeamsISO.App/Services/OperatorPresetStore.cs",
|
||||||
|
"src/TeamsISO.App/TeamsISO.App.csproj",
|
||||||
|
"src/TeamsISO.Engine/Domain/IsoHealthStats.cs",
|
||||||
|
"src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml",
|
||||||
|
"TeamsISO.sln",
|
||||||
|
"TeamsISO.Windows.slnf",
|
||||||
|
"docs/REAL-TIME-RECORDING.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #108 / #109 / #110 / #111 Final session-2 polish ─────────────────
|
||||||
|
# UIPreferences persists DISPLAY toggles + ParticipantSort across launches.
|
||||||
|
# PreviewWindow non-modal floating preview at 20Hz for multi-monitor.
|
||||||
|
# Configurable participant sort order via ICollectionView.SortDescriptions.
|
||||||
|
# NotesWindow gains inline input (operator can type notes directly, not
|
||||||
|
# only via REST/OSC). HTML control panel gains a "Note…" button. Richer
|
||||||
|
# GET / response. Updated CHANGELOG + README to reflect all of session 2.
|
||||||
|
Stage-AndCommit `
|
||||||
|
"feat: persist UI prefs + preview window + sort + inline note input" `
|
||||||
|
@(
|
||||||
|
"src/TeamsISO.App/Services/UIPreferences.cs",
|
||||||
|
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
|
||||||
|
"src/TeamsISO.App/Services/ControlPanelHtml.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
|
||||||
|
"src/TeamsISO.App/PreviewWindow.xaml",
|
||||||
|
"src/TeamsISO.App/PreviewWindow.xaml.cs",
|
||||||
|
"src/TeamsISO.App/NotesWindow.xaml",
|
||||||
|
"src/TeamsISO.App/NotesWindow.xaml.cs",
|
||||||
|
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
|
||||||
|
"src/TeamsISO.App/MainWindow.xaml",
|
||||||
|
"README.md",
|
||||||
|
"CHANGELOG.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── #72 / #75 UIA polish ──────────────────────────────────────────────
|
||||||
|
# (Already committed above as part of the #66-#69 batch since they touched
|
||||||
|
# the same TeamsControlBridge / TeamsLauncher files.)
|
||||||
|
|
||||||
|
# ─── docs ───────────────────────────────────────────────────────────────
|
||||||
|
Stage-AndCommit `
|
||||||
|
"docs: refresh _NEXT.md after recording + control surface" `
|
||||||
|
@(
|
||||||
|
"docs/superpowers/plans/_NEXT.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Push ───────────────────────────────────────────────────────────────
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "──── Pushing to origin/main ────" -ForegroundColor Cyan
|
||||||
|
git push origin main
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git push failed" }
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Done. Commits pushed to forge.wilddragon.net/zgaetano/teamsiso." -ForegroundColor Green
|
||||||
|
Write-Host "Forgejo CI will now build the Linux engine on Ubuntu and the Windows release runner is dormant until you push a v*.*.* tag." -ForegroundColor DarkGray
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Linq;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Shapes;
|
using System.Windows.Shapes;
|
||||||
using TeamsISO.App.Services;
|
using TeamsISO.App.Services;
|
||||||
|
|
@ -54,6 +55,63 @@ public partial class MainWindow : Window
|
||||||
about.ShowDialog();
|
about.ShowDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the operator-presets dialog. Hands it the current participants
|
||||||
|
/// snapshot (so Save captures live state) and the engine controller (so
|
||||||
|
/// Apply can reconcile enable/disable). Owner is set so the chromeless
|
||||||
|
/// dialog centers over the main window and inherits z-order.
|
||||||
|
/// </summary>
|
||||||
|
private void OnPresetsClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not MainViewModel vm) return;
|
||||||
|
var dialog = new PresetsDialog(vm.Controller, vm.Participants.ToList(), vm.Toast)
|
||||||
|
{
|
||||||
|
Owner = this,
|
||||||
|
};
|
||||||
|
dialog.ShowDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks whether we have hidden Teams' windows so the next click reverses
|
||||||
|
/// the action. We treat this as "intent" rather than a query of OS state
|
||||||
|
/// because hidden windows still report as hidden if the operator manually
|
||||||
|
/// re-opens them and we only care about TeamsISO's own toggle history.
|
||||||
|
/// </summary>
|
||||||
|
private bool _teamsWindowsHidden;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase E.2 toggle. Hides every visible top-level Teams window on first
|
||||||
|
/// click; shows them again on the next. Surfaces the result via the toast
|
||||||
|
/// so the operator gets feedback even though the affected windows aren't
|
||||||
|
/// visible anymore.
|
||||||
|
/// </summary>
|
||||||
|
private void OnToggleTeamsWindowClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!TeamsLauncher.IsRunning())
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
"Microsoft Teams isn't running. Click the camera icon above to launch it first.",
|
||||||
|
"TeamsISO — Hide / show Teams",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var toast = (DataContext as MainViewModel)?.Toast;
|
||||||
|
if (_teamsWindowsHidden)
|
||||||
|
{
|
||||||
|
var shown = TeamsLauncher.ShowWindows();
|
||||||
|
_teamsWindowsHidden = false;
|
||||||
|
toast?.Show(shown > 0 ? $"Restored {shown} Teams window(s)" : "No Teams windows to restore");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var hidden = TeamsLauncher.HideWindows();
|
||||||
|
_teamsWindowsHidden = hidden > 0;
|
||||||
|
toast?.Show(hidden > 0 ? $"Hid {hidden} Teams window(s)" : "Teams has no visible windows yet");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Toggle behavior: if Teams is already running, ask to stop it; otherwise
|
/// Toggle behavior: if Teams is already running, ask to stop it; otherwise
|
||||||
/// launch via TeamsLauncher's fallback chain. First step toward the
|
/// launch via TeamsLauncher's fallback chain. First step toward the
|
||||||
|
|
|
||||||
273
src/TeamsISO.App/Services/TeamsControlBridge.cs
Normal file
273
src/TeamsISO.App/Services/TeamsControlBridge.cs
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Windows.Automation;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase E.3 — UIAutomation bridge for the in-call controls (mute, camera,
|
||||||
|
/// leave, share screen). Walks Teams' automation tree to locate the relevant
|
||||||
|
/// buttons and invokes their <see cref="InvokePattern"/> or <see cref="TogglePattern"/>.
|
||||||
|
///
|
||||||
|
/// This is intentionally tolerant of Teams' UI volatility: we search by a
|
||||||
|
/// chain of (AutomationId, Name, LocalizedControlType) candidates rather than
|
||||||
|
/// pinning to a single identifier. When Teams ships a new build that renames a
|
||||||
|
/// button, the operator gets a clear "control not found" toast rather than a
|
||||||
|
/// crash, and we add the new identifier to the candidate list.
|
||||||
|
///
|
||||||
|
/// Limitations:
|
||||||
|
/// - Requires Teams' main window to be present (not minimized to the system tray
|
||||||
|
/// in a way that detaches its automation peers; minimized to taskbar is fine).
|
||||||
|
/// - Some Teams builds host the call UI in a separate WebView2-backed top-level
|
||||||
|
/// window; we enumerate every top-level window owned by every Teams process,
|
||||||
|
/// so we'll find it wherever it lives.
|
||||||
|
/// - Hidden windows (after <see cref="TeamsLauncher.HideWindows"/>) are still
|
||||||
|
/// traversable by UIAutomation — the buttons exist in the automation tree
|
||||||
|
/// even when their HWND is SW_HIDDEN. This is what makes the "hide Teams,
|
||||||
|
/// drive it from 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
|
||||||
|
// with the German word "Stumm" in its name would false-positive). In
|
||||||
|
// practice Teams' button Names are highly distinctive and we haven't seen
|
||||||
|
// false positives during development.
|
||||||
|
//
|
||||||
|
// Adding a locale: append the localized strings to each command's array.
|
||||||
|
// Order doesn't matter for correctness; for performance we put the most
|
||||||
|
// common locales first since the array is iterated in order.
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static readonly string[] MuteCandidates =
|
||||||
|
{
|
||||||
|
// English (US/UK)
|
||||||
|
"Mute", "Unmute", "Microphone", "Toggle mute",
|
||||||
|
// German
|
||||||
|
"Stumm", "Stummschaltung", "Stummschalten", "Stummschaltung aufheben", "Mikrofon",
|
||||||
|
// Spanish
|
||||||
|
"Silenciar", "Activar audio", "Micrófono",
|
||||||
|
// French
|
||||||
|
"Désactiver le micro", "Activer le micro", "Micro", "Microphone",
|
||||||
|
// Portuguese
|
||||||
|
"Desativar áudio", "Ativar áudio", "Microfone",
|
||||||
|
// Japanese
|
||||||
|
"ミュート", "ミュート解除", "マイク",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] CameraCandidates =
|
||||||
|
{
|
||||||
|
"Camera", "Turn camera on", "Turn camera off", "Video",
|
||||||
|
// German
|
||||||
|
"Kamera", "Kamera einschalten", "Kamera ausschalten", "Video",
|
||||||
|
// Spanish
|
||||||
|
"Cámara", "Activar cámara", "Desactivar cámara", "Vídeo",
|
||||||
|
// French
|
||||||
|
"Caméra", "Activer la caméra", "Désactiver la caméra", "Vidéo",
|
||||||
|
// Portuguese
|
||||||
|
"Câmera", "Ativar câmera", "Desativar câmera",
|
||||||
|
// Japanese
|
||||||
|
"カメラ", "ビデオ",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] LeaveCandidates =
|
||||||
|
{
|
||||||
|
"Leave", "Hang up", "End call", "Leave call",
|
||||||
|
// German
|
||||||
|
"Verlassen", "Auflegen", "Anruf beenden",
|
||||||
|
// Spanish
|
||||||
|
"Salir", "Colgar", "Finalizar llamada",
|
||||||
|
// French
|
||||||
|
"Quitter", "Raccrocher", "Terminer l'appel",
|
||||||
|
// Portuguese
|
||||||
|
"Sair", "Desligar", "Encerrar chamada",
|
||||||
|
// Japanese
|
||||||
|
"退出", "通話を終了",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] ShareCandidates =
|
||||||
|
{
|
||||||
|
"Share", "Share content", "Share screen", "Open share tray",
|
||||||
|
// German
|
||||||
|
"Teilen", "Inhalt teilen", "Bildschirm teilen",
|
||||||
|
// Spanish
|
||||||
|
"Compartir", "Compartir contenido", "Compartir pantalla",
|
||||||
|
// French
|
||||||
|
"Partager", "Partager du contenu", "Partager l'écran",
|
||||||
|
// Portuguese
|
||||||
|
"Compartilhar", "Compartilhar conteúdo", "Compartilhar tela",
|
||||||
|
// Japanese
|
||||||
|
"共有", "コンテンツの共有", "画面を共有",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] RaiseHandCandidates =
|
||||||
|
{
|
||||||
|
"Raise", "Raise hand", "Lower hand",
|
||||||
|
// German
|
||||||
|
"Hand heben", "Hand senken",
|
||||||
|
// Spanish
|
||||||
|
"Levantar la mano", "Bajar la mano",
|
||||||
|
// French
|
||||||
|
"Lever la main", "Baisser la main",
|
||||||
|
// Portuguese
|
||||||
|
"Levantar a mão", "Abaixar a mão",
|
||||||
|
// Japanese
|
||||||
|
"手を挙げる", "手を下ろす",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] ToggleChatCandidates =
|
||||||
|
{
|
||||||
|
"Show conversation", "Hide conversation", "Chat", "Show chat",
|
||||||
|
// German
|
||||||
|
"Unterhaltung anzeigen", "Unterhaltung ausblenden", "Chat",
|
||||||
|
// Spanish
|
||||||
|
"Mostrar conversación", "Ocultar conversación", "Chat",
|
||||||
|
// French
|
||||||
|
"Afficher la conversation", "Masquer la conversation", "Conversation",
|
||||||
|
// Portuguese
|
||||||
|
"Mostrar conversa", "Ocultar conversa", "Chat",
|
||||||
|
// Japanese
|
||||||
|
"会話を表示", "会話を非表示", "チャット",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] BackgroundBlurCandidates =
|
||||||
|
{
|
||||||
|
"Background effects", "Apply background effects", "Background filters",
|
||||||
|
// German
|
||||||
|
"Hintergrundeffekte", "Hintergrundfilter",
|
||||||
|
// Spanish
|
||||||
|
"Efectos de fondo", "Filtros de fondo",
|
||||||
|
// French
|
||||||
|
"Effets d'arrière-plan", "Filtres d'arrière-plan",
|
||||||
|
// Portuguese
|
||||||
|
"Efeitos de plano de fundo", "Filtros de plano de fundo",
|
||||||
|
// Japanese
|
||||||
|
"背景効果", "背景フィルター",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Result of attempting one of the in-call commands.</summary>
|
||||||
|
public enum InvokeResult
|
||||||
|
{
|
||||||
|
/// <summary>The control was found and invoked successfully.</summary>
|
||||||
|
Invoked,
|
||||||
|
/// <summary>Teams isn't running, or its automation root couldn't be located.</summary>
|
||||||
|
TeamsNotRunning,
|
||||||
|
/// <summary>Teams is running but the matching button isn't currently exposed (maybe not in a call).</summary>
|
||||||
|
ControlNotFound,
|
||||||
|
/// <summary>The button was found but didn't expose a usable invoke / toggle pattern.</summary>
|
||||||
|
InvokeFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static InvokeResult ToggleMute() => InvokeFirstMatch(MuteCandidates);
|
||||||
|
public static InvokeResult ToggleCamera() => InvokeFirstMatch(CameraCandidates);
|
||||||
|
public static InvokeResult LeaveCall() => InvokeFirstMatch(LeaveCandidates);
|
||||||
|
public static InvokeResult OpenShareTray() => InvokeFirstMatch(ShareCandidates);
|
||||||
|
public static InvokeResult ToggleRaiseHand() => InvokeFirstMatch(RaiseHandCandidates);
|
||||||
|
public static InvokeResult ToggleChat() => InvokeFirstMatch(ToggleChatCandidates);
|
||||||
|
public static InvokeResult OpenBackgroundEffects() => InvokeFirstMatch(BackgroundBlurCandidates);
|
||||||
|
|
||||||
|
private static InvokeResult InvokeFirstMatch(IReadOnlyList<string> candidateNames)
|
||||||
|
{
|
||||||
|
var roots = GetTeamsAutomationRoots();
|
||||||
|
if (roots.Count == 0) return InvokeResult.TeamsNotRunning;
|
||||||
|
|
||||||
|
foreach (var root in roots)
|
||||||
|
{
|
||||||
|
// Search by Name first (most common case for Teams). Use a NameProperty
|
||||||
|
// contains-style match by collecting all Buttons in the subtree and then
|
||||||
|
// filtering manually — Condition only supports equality, and Teams'
|
||||||
|
// labels can include trailing state ("(unmuted)") that breaks equality.
|
||||||
|
var allButtons = root.FindAll(
|
||||||
|
TreeScope.Descendants,
|
||||||
|
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
|
||||||
|
|
||||||
|
foreach (AutomationElement btn in allButtons)
|
||||||
|
{
|
||||||
|
var name = SafeGetName(btn);
|
||||||
|
if (string.IsNullOrEmpty(name)) continue;
|
||||||
|
if (!candidateNames.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (TryInvoke(btn)) return InvokeResult.Invoked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InvokeResult.ControlNotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the AutomationElement root for every top-level window owned by
|
||||||
|
/// any running Teams process. Multiple roots is the normal case for new
|
||||||
|
/// MSTeams (which uses one window per call/chat).
|
||||||
|
/// </summary>
|
||||||
|
private static List<AutomationElement> GetTeamsAutomationRoots()
|
||||||
|
{
|
||||||
|
var teamsPids = new HashSet<int>(
|
||||||
|
Process.GetProcessesByName("ms-teams")
|
||||||
|
.Concat(Process.GetProcessesByName("msteams"))
|
||||||
|
.Concat(Process.GetProcessesByName("Teams"))
|
||||||
|
.Select(p => { try { return p.Id; } finally { p.Dispose(); } }));
|
||||||
|
|
||||||
|
if (teamsPids.Count == 0) return new List<AutomationElement>();
|
||||||
|
|
||||||
|
// Filter the desktop's children to windows whose ProcessId matches.
|
||||||
|
var desktop = AutomationElement.RootElement;
|
||||||
|
var allWindows = desktop.FindAll(
|
||||||
|
TreeScope.Children,
|
||||||
|
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Window));
|
||||||
|
|
||||||
|
var roots = new List<AutomationElement>();
|
||||||
|
foreach (AutomationElement w in allWindows)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pid = (int)w.Current.ProcessId;
|
||||||
|
if (teamsPids.Contains(pid)) roots.Add(w);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Window died between enumeration and property read; skip.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeGetName(AutomationElement el)
|
||||||
|
{
|
||||||
|
try { return el.Current.Name ?? string.Empty; }
|
||||||
|
catch { return string.Empty; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Try Invoke first (most buttons), then Toggle (mute/camera are usually
|
||||||
|
/// toggle-pattern). Returns true if either succeeded.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryInvoke(AutomationElement el)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (el.TryGetCurrentPattern(InvokePattern.Pattern, out var invoke))
|
||||||
|
{
|
||||||
|
((InvokePattern)invoke).Invoke();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (el.TryGetCurrentPattern(TogglePattern.Pattern, out var toggle))
|
||||||
|
{
|
||||||
|
((TogglePattern)toggle).Toggle();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ElementNotEnabledException, ElementNotAvailableException — Teams
|
||||||
|
// disabled the button mid-traversal (e.g. mute is disabled before
|
||||||
|
// joining a call). Treat as "found but couldn't invoke" so the
|
||||||
|
// caller can surface a useful message.
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace TeamsISO.App.Services;
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
|
@ -140,4 +141,181 @@ public static class TeamsLauncher
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Phase E.2 — window orchestration
|
||||||
|
//
|
||||||
|
// Once Teams is running, we want to be able to hide its main window so the
|
||||||
|
// operator only sees 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.
|
||||||
|
//
|
||||||
|
// We deliberately don't use the Process.MainWindowHandle convenience because
|
||||||
|
// new MSTeams (WebView2-hosted) creates several top-level windows per
|
||||||
|
// process and Process picks an inconsistent one across launches; iterating
|
||||||
|
// via EnumWindows + GetWindowThreadProcessId catches every visible window
|
||||||
|
// owned by the process.
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private const int SW_HIDE = 0;
|
||||||
|
private const int SW_SHOWNORMAL = 1;
|
||||||
|
private const int SW_SHOW = 5;
|
||||||
|
private const int SW_RESTORE = 9;
|
||||||
|
|
||||||
|
private const uint GW_OWNER = 4;
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool IsWindowVisible(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||||
|
|
||||||
|
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerate every visible top-level window owned by any running Teams
|
||||||
|
/// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is
|
||||||
|
/// not a tooltip or popup of another). Used by Hide/Show.
|
||||||
|
/// </summary>
|
||||||
|
private static List<IntPtr> FindTeamsTopLevelWindows()
|
||||||
|
{
|
||||||
|
var teamsPids = new HashSet<uint>(
|
||||||
|
TeamsProcessNames
|
||||||
|
.SelectMany(n => Process.GetProcessesByName(n))
|
||||||
|
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
|
||||||
|
if (teamsPids.Count == 0) return new List<IntPtr>();
|
||||||
|
|
||||||
|
var windows = new List<IntPtr>();
|
||||||
|
EnumWindows((hWnd, _) =>
|
||||||
|
{
|
||||||
|
if (!IsWindowVisible(hWnd)) return true;
|
||||||
|
if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true; // not top-level
|
||||||
|
GetWindowThreadProcessId(hWnd, out var pid);
|
||||||
|
if (teamsPids.Contains(pid)) windows.Add(hWnd);
|
||||||
|
return true;
|
||||||
|
}, IntPtr.Zero);
|
||||||
|
return windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hides every visible top-level Teams window. Returns the count hidden;
|
||||||
|
/// 0 means Teams isn't running or has no visible windows yet (it can take
|
||||||
|
/// a couple seconds after launch for the splash to materialize).
|
||||||
|
/// </summary>
|
||||||
|
public static int HideWindows()
|
||||||
|
{
|
||||||
|
var windows = FindTeamsTopLevelWindows();
|
||||||
|
foreach (var w in windows) ShowWindow(w, SW_HIDE);
|
||||||
|
return windows.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Keyboard-shortcut forwarding (PostMessage path).
|
||||||
|
//
|
||||||
|
// UIAutomation (TeamsControlBridge) is our preferred way to drive Teams
|
||||||
|
// because it works regardless of foreground/visibility state. PostMessage
|
||||||
|
// is a fallback for shortcuts that don't have a stable UIA-discoverable
|
||||||
|
// button — chat scroll, custom keymap actions, etc. Note: WebView2-hosted
|
||||||
|
// Teams (the modern client) frequently ignores PostMessage(WM_KEYDOWN) at
|
||||||
|
// its app-shortcut layer because shortcut routing happens after focus
|
||||||
|
// changes, not on raw key messages. Treat this as best-effort.
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private const uint WM_KEYDOWN = 0x0100;
|
||||||
|
private const uint WM_KEYUP = 0x0101;
|
||||||
|
private const uint WM_CHAR = 0x0102;
|
||||||
|
private const uint WM_SYSKEYDOWN = 0x0104;
|
||||||
|
private const uint WM_SYSKEYUP = 0x0105;
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum ShortcutModifiers
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Ctrl = 1,
|
||||||
|
Shift = 2,
|
||||||
|
Alt = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a synthesized key press (modifier-down, key-down, key-up,
|
||||||
|
/// modifier-up) to the most recently used top-level Teams window via
|
||||||
|
/// PostMessage. Returns true if a window was found to send to. Note that
|
||||||
|
/// returning true doesn't guarantee Teams reacted — modern WebView2-based
|
||||||
|
/// Teams sometimes ignores synthesized key messages at the app-shortcut
|
||||||
|
/// layer. Prefer UIA (<see cref="TeamsControlBridge"/>) when an equivalent
|
||||||
|
/// button exists.
|
||||||
|
/// </summary>
|
||||||
|
public static bool SendShortcut(ShortcutModifiers modifiers, int virtualKey)
|
||||||
|
{
|
||||||
|
var windows = FindTeamsTopLevelWindows();
|
||||||
|
if (windows.Count == 0) return false;
|
||||||
|
var hwnd = windows[^1];
|
||||||
|
|
||||||
|
// Modifier key downs
|
||||||
|
if ((modifiers & ShortcutModifiers.Ctrl) != 0)
|
||||||
|
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)0x11, IntPtr.Zero); // VK_CONTROL
|
||||||
|
if ((modifiers & ShortcutModifiers.Shift) != 0)
|
||||||
|
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)0x10, IntPtr.Zero); // VK_SHIFT
|
||||||
|
if ((modifiers & ShortcutModifiers.Alt) != 0)
|
||||||
|
PostMessage(hwnd, WM_SYSKEYDOWN, (IntPtr)0x12, IntPtr.Zero); // VK_MENU
|
||||||
|
|
||||||
|
// Main key down + up
|
||||||
|
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)virtualKey, IntPtr.Zero);
|
||||||
|
PostMessage(hwnd, WM_KEYUP, (IntPtr)virtualKey, IntPtr.Zero);
|
||||||
|
|
||||||
|
// Modifier key ups (reverse order)
|
||||||
|
if ((modifiers & ShortcutModifiers.Alt) != 0)
|
||||||
|
PostMessage(hwnd, WM_SYSKEYUP, (IntPtr)0x12, IntPtr.Zero);
|
||||||
|
if ((modifiers & ShortcutModifiers.Shift) != 0)
|
||||||
|
PostMessage(hwnd, WM_KEYUP, (IntPtr)0x10, IntPtr.Zero);
|
||||||
|
if ((modifiers & ShortcutModifiers.Ctrl) != 0)
|
||||||
|
PostMessage(hwnd, WM_KEYUP, (IntPtr)0x11, IntPtr.Zero);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restores every Teams top-level window from hidden state and brings the
|
||||||
|
/// most recently used one to the foreground. Returns the count shown.
|
||||||
|
/// </summary>
|
||||||
|
public static int ShowWindows()
|
||||||
|
{
|
||||||
|
// To find hidden windows too we still enumerate, but our IsWindowVisible
|
||||||
|
// filter would skip them. Re-implement here with the visible check off.
|
||||||
|
var teamsPids = new HashSet<uint>(
|
||||||
|
TeamsProcessNames
|
||||||
|
.SelectMany(n => Process.GetProcessesByName(n))
|
||||||
|
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
|
||||||
|
var windows = new List<IntPtr>();
|
||||||
|
EnumWindows((hWnd, _) =>
|
||||||
|
{
|
||||||
|
if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true;
|
||||||
|
GetWindowThreadProcessId(hWnd, out var pid);
|
||||||
|
if (teamsPids.Contains(pid)) windows.Add(hWnd);
|
||||||
|
return true;
|
||||||
|
}, IntPtr.Zero);
|
||||||
|
|
||||||
|
foreach (var w in windows) ShowWindow(w, SW_SHOW);
|
||||||
|
if (windows.Count > 0) SetForegroundWindow(windows[^1]);
|
||||||
|
return windows.Count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
231
src/TeamsISO.App/Services/UpdateChecker.cs
Normal file
231
src/TeamsISO.App/Services/UpdateChecker.cs
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
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
|
||||||
|
/// operator can click "Check for updates" in the About dialog whenever they
|
||||||
|
/// want, and a positive result opens the release page in their browser
|
||||||
|
/// (rather than auto-downloading; we don't want a long-running show
|
||||||
|
/// interrupted by a surprise installer).
|
||||||
|
///
|
||||||
|
/// We use the public release endpoint so no auth is needed:
|
||||||
|
/// GET /api/v1/repos/zgaetano/teamsiso/releases?limit=1
|
||||||
|
///
|
||||||
|
/// On any error (offline, DNS failure, repo private, malformed response),
|
||||||
|
/// the caller gets <see cref="UpdateCheckResult.Failed"/> with a short
|
||||||
|
/// human-readable message rather than an exception.
|
||||||
|
/// </summary>
|
||||||
|
public static class UpdateChecker
|
||||||
|
{
|
||||||
|
private const string ReleasesApi =
|
||||||
|
"https://forge.wilddragon.net/api/v1/repos/zgaetano/teamsiso/releases?limit=1";
|
||||||
|
|
||||||
|
private const string ReleasesPage =
|
||||||
|
"https://forge.wilddragon.net/zgaetano/teamsiso/releases";
|
||||||
|
|
||||||
|
/// <summary>Outcome of a single check.</summary>
|
||||||
|
public sealed record UpdateCheckResult(
|
||||||
|
UpdateStatus Status,
|
||||||
|
string? LatestTag,
|
||||||
|
string? CurrentVersion,
|
||||||
|
string? Message)
|
||||||
|
{
|
||||||
|
public static UpdateCheckResult Failed(string message) =>
|
||||||
|
new(UpdateStatus.Error, null, null, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum UpdateStatus
|
||||||
|
{
|
||||||
|
UpToDate,
|
||||||
|
UpdateAvailable,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<UpdateCheckResult> CheckAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var current = GetCurrentVersion();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
|
||||||
|
client.DefaultRequestHeaders.Add("User-Agent", "TeamsISO/" + current);
|
||||||
|
|
||||||
|
using var res = await client.GetAsync(ReleasesApi, ct);
|
||||||
|
if (!res.IsSuccessStatusCode)
|
||||||
|
return UpdateCheckResult.Failed($"Server returned {(int)res.StatusCode}.");
|
||||||
|
|
||||||
|
var json = await res.Content.ReadAsStringAsync(ct);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
if (doc.RootElement.ValueKind != JsonValueKind.Array || doc.RootElement.GetArrayLength() == 0)
|
||||||
|
return new UpdateCheckResult(UpdateStatus.UpToDate, null, current,
|
||||||
|
"No releases published yet.");
|
||||||
|
|
||||||
|
var first = doc.RootElement[0];
|
||||||
|
if (!first.TryGetProperty("tag_name", out var tagProp))
|
||||||
|
return UpdateCheckResult.Failed("Release record missing tag_name.");
|
||||||
|
|
||||||
|
var latestTag = tagProp.GetString();
|
||||||
|
if (string.IsNullOrWhiteSpace(latestTag))
|
||||||
|
return UpdateCheckResult.Failed("Latest tag was empty.");
|
||||||
|
|
||||||
|
var latestVersion = TryParseSemVer(latestTag);
|
||||||
|
var currentVersion = TryParseSemVer("v" + current);
|
||||||
|
|
||||||
|
if (latestVersion is null || currentVersion is null)
|
||||||
|
return new UpdateCheckResult(UpdateStatus.UpToDate, latestTag, current,
|
||||||
|
"Couldn't compare versions; latest tag is " + latestTag);
|
||||||
|
|
||||||
|
return latestVersion > currentVersion
|
||||||
|
? new UpdateCheckResult(UpdateStatus.UpdateAvailable, latestTag, current,
|
||||||
|
$"A newer version ({latestTag}) is available.")
|
||||||
|
: new UpdateCheckResult(UpdateStatus.UpToDate, latestTag, current,
|
||||||
|
"You're on the latest release.");
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
return UpdateCheckResult.Failed("Update check timed out.");
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return UpdateCheckResult.Failed("Couldn't reach the update server: " + ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return UpdateCheckResult.Failed("Unexpected error: " + ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Open the releases page in the user's default browser. Used by the
|
||||||
|
/// "Update available" dialog button — we deliberately don't download/run
|
||||||
|
/// the MSI ourselves, so the operator decides when to install.
|
||||||
|
/// </summary>
|
||||||
|
public static void OpenReleasesPage()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = ReleasesPage,
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// best-effort; the dialog already shows the URL as text fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Silent throttled launch-time check. Returns the result if a check actually
|
||||||
|
/// happened, or null if the cooldown window suppressed it. The cooldown lives
|
||||||
|
/// in <c>%LOCALAPPDATA%\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)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = CooldownPath;
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
var raw = File.ReadAllText(path).Trim();
|
||||||
|
if (DateTimeOffset.TryParse(raw, out var last) &&
|
||||||
|
DateTimeOffset.UtcNow - last < cooldown)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Throttle check is best-effort; on read failures we just check now.
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await CheckAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(CooldownPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
File.WriteAllText(CooldownPath, DateTimeOffset.UtcNow.ToString("o"));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// If we can't write the cooldown stamp, future launches will check
|
||||||
|
// again immediately. Annoying but not broken.
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CooldownPath =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", "last-update-check.txt");
|
||||||
|
|
||||||
|
private static string OptOutPath =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", "no-update-check.flag");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether launch-time update checks are enabled. Inverted-flag-file storage:
|
||||||
|
/// the absence of the file means "checks on" (default), the presence means
|
||||||
|
/// "checks off". Operators can ship the flag file via group policy / config-
|
||||||
|
/// management to suppress checks across a fleet.
|
||||||
|
/// </summary>
|
||||||
|
public static bool LaunchCheckEnabled
|
||||||
|
{
|
||||||
|
get => !File.Exists(OptOutPath);
|
||||||
|
set
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (value)
|
||||||
|
{
|
||||||
|
if (File.Exists(OptOutPath)) File.Delete(OptOutPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(OptOutPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
File.WriteAllText(OptOutPath, "Update checks suppressed by user. Delete this file to re-enable.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort; toggle won't persist if disk is read-only, but
|
||||||
|
// the in-memory checkbox state still reflects the user's intent
|
||||||
|
// for this session.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCurrentVersion()
|
||||||
|
{
|
||||||
|
var asm = typeof(UpdateChecker).Assembly;
|
||||||
|
return asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||||
|
?? asm.GetName().Version?.ToString()
|
||||||
|
?? "0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a "vX.Y.Z" or "X.Y.Z(.N)" string into a Version. Strips any
|
||||||
|
/// pre-release suffix ("-alpha", "-beta") so the comparison is on
|
||||||
|
/// numeric components only — pre-release vs. release ordering is a
|
||||||
|
/// follow-up if we need it.
|
||||||
|
/// </summary>
|
||||||
|
private static Version? TryParseSemVer(string s)
|
||||||
|
{
|
||||||
|
var trimmed = s.TrimStart('v', 'V');
|
||||||
|
var dash = trimmed.IndexOf('-');
|
||||||
|
if (dash >= 0) trimmed = trimmed[..dash];
|
||||||
|
return Version.TryParse(trimmed, out var v) ? v : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -492,6 +492,87 @@
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- ════ TabControl (settings panel) ════ -->
|
||||||
|
<!--
|
||||||
|
Lightweight, flush tabs that match the dark surface. Underline-on-active
|
||||||
|
rather than the WPF default raised-border, so the tab strip reads as a
|
||||||
|
section header rather than a separate widget.
|
||||||
|
-->
|
||||||
|
<Style TargetType="TabControl" x:Key="Wd.TabControl">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="0"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="TabControl">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Border Grid.Row="0"
|
||||||
|
BorderBrush="{StaticResource Wd.Border}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<TabPanel x:Name="HeaderPanel"
|
||||||
|
IsItemsHost="True"
|
||||||
|
Background="Transparent"/>
|
||||||
|
</Border>
|
||||||
|
<ContentPresenter Grid.Row="1"
|
||||||
|
ContentSource="SelectedContent"
|
||||||
|
Margin="0,16,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="TabItem" x:Key="Wd.TabItem">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
|
||||||
|
<Setter Property="FontSize" Value="11"/>
|
||||||
|
<Setter Property="FontWeight" Value="Medium"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Tertiary}"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Padding" Value="0,8,0,10"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,20,0"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="TabItem">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<ContentPresenter x:Name="Content"
|
||||||
|
Grid.Row="0"
|
||||||
|
ContentSource="Header"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextBlock.FontFamily="{TemplateBinding FontFamily}"
|
||||||
|
TextBlock.FontSize="{TemplateBinding FontSize}"
|
||||||
|
TextBlock.FontWeight="{TemplateBinding FontWeight}"
|
||||||
|
TextBlock.Foreground="{TemplateBinding Foreground}"
|
||||||
|
Margin="{TemplateBinding Padding}"/>
|
||||||
|
<Rectangle x:Name="Underline"
|
||||||
|
Grid.Row="1"
|
||||||
|
Height="2"
|
||||||
|
Fill="{StaticResource Wd.Accent.Cyan}"
|
||||||
|
Visibility="Hidden"/>
|
||||||
|
</Grid>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="Underline" Property="Visibility" Value="Visible"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Secondary}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- ════ Card ════ -->
|
<!-- ════ Card ════ -->
|
||||||
<Style x:Key="Wd.Card" TargetType="Border">
|
<Style x:Key="Wd.Card" TargetType="Border">
|
||||||
<Setter Property="Background" Value="{StaticResource Wd.Surface}"/>
|
<Setter Property="Background" Value="{StaticResource Wd.Surface}"/>
|
||||||
|
|
|
||||||
65
src/TeamsISO.App/ViewModels/UpdateBannerViewModel.cs
Normal file
65
src/TeamsISO.App/ViewModels/UpdateBannerViewModel.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persistent banner shown when the launch-time update check finds a newer
|
||||||
|
/// release. Stays visible until the operator clicks "Get update" (which opens
|
||||||
|
/// the releases page) or "Dismiss" (which hides until next launch).
|
||||||
|
///
|
||||||
|
/// Distinct from <see cref="AlertBannerViewModel"/> because that one's tied to
|
||||||
|
/// engine alerts and would force us to leak update concerns into the engine
|
||||||
|
/// layer; the update banner is purely a host-shell concern.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdateBannerViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private bool _isVisible;
|
||||||
|
private string _latestVersion = string.Empty;
|
||||||
|
private string _currentVersion = string.Empty;
|
||||||
|
|
||||||
|
public bool IsVisible
|
||||||
|
{
|
||||||
|
get => _isVisible;
|
||||||
|
private set => SetField(ref _isVisible, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string LatestVersion
|
||||||
|
{
|
||||||
|
get => _latestVersion;
|
||||||
|
private set => SetField(ref _latestVersion, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CurrentVersion
|
||||||
|
{
|
||||||
|
get => _currentVersion;
|
||||||
|
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}";
|
||||||
|
|
||||||
|
public RelayCommand OpenReleasePageCommand { get; }
|
||||||
|
public RelayCommand DismissCommand { get; }
|
||||||
|
|
||||||
|
public UpdateBannerViewModel()
|
||||||
|
{
|
||||||
|
OpenReleasePageCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
UpdateChecker.OpenReleasesPage();
|
||||||
|
IsVisible = false;
|
||||||
|
});
|
||||||
|
DismissCommand = new RelayCommand(() => IsVisible = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Show the banner with a "you're on X, latest is Y" message. Idempotent:
|
||||||
|
/// re-showing while already visible just refreshes the version strings.
|
||||||
|
/// </summary>
|
||||||
|
public void Show(string current, string latest)
|
||||||
|
{
|
||||||
|
CurrentVersion = current;
|
||||||
|
LatestVersion = latest;
|
||||||
|
OnPropertyChanged(nameof(Message));
|
||||||
|
IsVisible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,4 +27,27 @@ public sealed record IsoPipelineConfig(
|
||||||
/// from the global NdiGroupSettings without changing this record's identity contract.
|
/// from the global NdiGroupSettings without changing this record's identity contract.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? OutputGroups { get; init; }
|
public string? OutputGroups { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional recorder. When non-null, every <see cref="ProcessedFrame"/> emitted by
|
||||||
|
/// the inner pipeline is also fed to this sink for persistence. The pipeline owns
|
||||||
|
/// the recorder's lifecycle: <see cref="IRecorderSink.Open"/> on the first frame
|
||||||
|
/// (so we know the actual output dimensions), <see cref="IRecorderSink.Close"/>
|
||||||
|
/// when the inner loop ends or the supervisor cancels.
|
||||||
|
/// </summary>
|
||||||
|
public IRecorderSink? Recorder { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Output directory for the recorder, if one is configured. The recorder will
|
||||||
|
/// create a subdirectory named after the participant under this root. Ignored
|
||||||
|
/// when <see cref="Recorder"/> is null.
|
||||||
|
/// </summary>
|
||||||
|
public string? RecordingOutputDirectory { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Operator-facing display name; used by the recorder to derive the filename.
|
||||||
|
/// Distinct from <see cref="OutputName"/> (the NDI source name, which is for
|
||||||
|
/// machine consumption) — the recorder wants something human-readable.
|
||||||
|
/// </summary>
|
||||||
|
public string? RecorderDisplayName { get; init; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue