From 63bd93d0c2441cfdd0e91a1c2022db4284bdf4d0 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 10 May 2026 09:42:29 -0400 Subject: [PATCH] chore: sweep orphaned files (UpdateChecker, UpdateBanner, TeamsControlBridge, helper scripts) --- build-and-test.ps1 | 41 ++ commit-and-push.ps1 | 443 ++++++++++++++++++ src/TeamsISO.App/MainWindow.xaml.cs | 58 +++ .../Services/TeamsControlBridge.cs | 273 +++++++++++ src/TeamsISO.App/Services/TeamsLauncher.cs | 178 +++++++ src/TeamsISO.App/Services/UpdateChecker.cs | 231 +++++++++ src/TeamsISO.App/Themes/WildDragonTheme.xaml | 81 ++++ .../ViewModels/UpdateBannerViewModel.cs | 65 +++ .../Pipeline/IsoPipelineConfig.cs | 23 + 9 files changed, 1393 insertions(+) create mode 100644 build-and-test.ps1 create mode 100644 commit-and-push.ps1 create mode 100644 src/TeamsISO.App/Services/TeamsControlBridge.cs create mode 100644 src/TeamsISO.App/Services/UpdateChecker.cs create mode 100644 src/TeamsISO.App/ViewModels/UpdateBannerViewModel.cs diff --git a/build-and-test.ps1 b/build-and-test.ps1 new file mode 100644 index 0000000..60fcd35 --- /dev/null +++ b/build-and-test.ps1 @@ -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 : " +} + +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 diff --git a/commit-and-push.ps1 b/commit-and-push.ps1 new file mode 100644 index 0000000..44bb515 --- /dev/null +++ b/commit-and-push.ps1 @@ -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 diff --git a/src/TeamsISO.App/MainWindow.xaml.cs b/src/TeamsISO.App/MainWindow.xaml.cs index bcf64e6..15c7309 100644 --- a/src/TeamsISO.App/MainWindow.xaml.cs +++ b/src/TeamsISO.App/MainWindow.xaml.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Windows; using System.Windows.Shapes; using TeamsISO.App.Services; @@ -54,6 +55,63 @@ public partial class MainWindow : Window about.ShowDialog(); } + /// + /// 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. + /// + 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(); + } + + /// + /// 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. + /// + private bool _teamsWindowsHidden; + + /// + /// 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. + /// + 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"); + } + } + /// /// Toggle behavior: if Teams is already running, ask to stop it; otherwise /// launch via TeamsLauncher's fallback chain. First step toward the diff --git a/src/TeamsISO.App/Services/TeamsControlBridge.cs b/src/TeamsISO.App/Services/TeamsControlBridge.cs new file mode 100644 index 0000000..0657ef3 --- /dev/null +++ b/src/TeamsISO.App/Services/TeamsControlBridge.cs @@ -0,0 +1,273 @@ +using System.Diagnostics; +using System.Windows.Automation; + +namespace TeamsISO.App.Services; + +/// +/// 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 or . +/// +/// 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 ) 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. +/// +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 + "背景効果", "背景フィルター", + }; + + /// Result of attempting one of the in-call commands. + public enum InvokeResult + { + /// The control was found and invoked successfully. + Invoked, + /// Teams isn't running, or its automation root couldn't be located. + TeamsNotRunning, + /// Teams is running but the matching button isn't currently exposed (maybe not in a call). + ControlNotFound, + /// The button was found but didn't expose a usable invoke / toggle pattern. + 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 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; + } + + /// + /// 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). + /// + private static List GetTeamsAutomationRoots() + { + var teamsPids = new HashSet( + 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(); + + // 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(); + 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; } + } + + /// + /// Try Invoke first (most buttons), then Toggle (mute/camera are usually + /// toggle-pattern). Returns true if either succeeded. + /// + 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; + } +} diff --git a/src/TeamsISO.App/Services/TeamsLauncher.cs b/src/TeamsISO.App/Services/TeamsLauncher.cs index d32b1ad..7e4f6b3 100644 --- a/src/TeamsISO.App/Services/TeamsLauncher.cs +++ b/src/TeamsISO.App/Services/TeamsLauncher.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.IO; +using System.Runtime.InteropServices; namespace TeamsISO.App.Services; @@ -140,4 +141,181 @@ public static class TeamsLauncher 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); + + /// + /// 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. + /// + private static List FindTeamsTopLevelWindows() + { + var teamsPids = new HashSet( + TeamsProcessNames + .SelectMany(n => Process.GetProcessesByName(n)) + .Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } })); + if (teamsPids.Count == 0) return new List(); + + var windows = new List(); + 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; + } + + /// + /// 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). + /// + 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); + + /// + /// 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 () when an equivalent + /// button exists. + /// + 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; + } + + /// + /// Restores every Teams top-level window from hidden state and brings the + /// most recently used one to the foreground. Returns the count shown. + /// + 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( + TeamsProcessNames + .SelectMany(n => Process.GetProcessesByName(n)) + .Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } })); + var windows = new List(); + 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; + } } diff --git a/src/TeamsISO.App/Services/UpdateChecker.cs b/src/TeamsISO.App/Services/UpdateChecker.cs new file mode 100644 index 0000000..38575ee --- /dev/null +++ b/src/TeamsISO.App/Services/UpdateChecker.cs @@ -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; + +/// +/// 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 with a short +/// human-readable message rather than an exception. +/// +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"; + + /// Outcome of a single check. + 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 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); + } + } + + /// + /// 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. + /// + 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 + } + } + + /// + /// 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 %LOCALAPPDATA%\TeamsISO\last-update-check.txt as an ISO 8601 + /// timestamp; a missing file means "never checked, do it now." + /// + public static async Task 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"); + + /// + /// 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. + /// + 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()?.InformationalVersion + ?? asm.GetName().Version?.ToString() + ?? "0.0.0"; + } + + /// + /// 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. + /// + 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; + } +} diff --git a/src/TeamsISO.App/Themes/WildDragonTheme.xaml b/src/TeamsISO.App/Themes/WildDragonTheme.xaml index e9bc0c2..78da5de 100644 --- a/src/TeamsISO.App/Themes/WildDragonTheme.xaml +++ b/src/TeamsISO.App/Themes/WildDragonTheme.xaml @@ -492,6 +492,87 @@ + + + + + +