Compare commits
5 commits
84861dafa5
...
150dd3f029
| Author | SHA1 | Date | |
|---|---|---|---|
| 150dd3f029 | |||
| f3e1f50ece | |||
| a98ab811a9 | |||
| 25229bdf1b | |||
| 29f7e56eb9 |
61 changed files with 2174 additions and 3802 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -28,6 +28,3 @@ publish/
|
|||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local Claude session metadata
|
||||
.claude/
|
||||
|
|
|
|||
85
CHANGELOG.md
85
CHANGELOG.md
|
|
@ -6,46 +6,55 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added — v2 "Studio Terminal" GUI (2026-05-13)
|
||||
### Added — Ground-up GUI redesign (started 2026-05-12)
|
||||
|
||||
The May 2026 ground-up redesign — explicit anti-reference to "the v1
|
||||
GUI screamed AI made it" — landed on the WPF host
|
||||
(`src/TeamsISO.App/`). The shape brief lives at
|
||||
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`. An earlier WinUI
|
||||
3 replatform was scoped on 2026-05-12 and abandoned in favour of doing
|
||||
the redesign in WPF (activation blockers + redundant work given the
|
||||
shared view-model surface). The abandoned migration plan + bootstrap
|
||||
probe are archived under `docs/archive/`.
|
||||
After greenlighting a from-the-scratch redesign and an explicit WinUI 3
|
||||
replatform target, the May 2026 batch is followed by a major
|
||||
restructuring of the host UI. Highlights:
|
||||
|
||||
- **PRODUCT.md** + **DESIGN.md**: impeccable context for the redesign.
|
||||
Solo broadcast operator at 1:50am is the canonical persona; "vibe-coded
|
||||
GUI" is the explicit anti-reference. Tokens cover dark + light palettes
|
||||
with context-aware accent split (cyan surface fill stays bright in
|
||||
both modes; cyan-as-text darkens to `#0E7C82` on light for AA contrast).
|
||||
- **Theme system** (`Themes/Theme.Dark.xaml`, `Theme.Light.xaml`,
|
||||
`WildDragonTheme.xaml`) + `Services/ThemeManager.cs` singleton that
|
||||
swaps the merged dictionary at runtime, reads
|
||||
`HKCU\…\AppsUseLightTheme` for System mode, subscribes to
|
||||
`SystemEvents.UserPreferenceChanged`, persists via
|
||||
`UIPreferences.Theme`. `Ctrl+T` toggles dark ↔ light.
|
||||
- **v2 main window shell**: default system title bar; 32px header (Wild
|
||||
Dragon mark + wordmark left, ⌘K / theme / settings icons right); 40px
|
||||
transport strip (`● 02:14:32 PART 4 · LIVE 2 CTRL :9755`); body with
|
||||
alert banner + update banner + action toolbar + participants
|
||||
DataGrid; conditional `IN CALL` meeting bar at bottom; slide-over
|
||||
settings drawer (420px from right) with OUTPUT / NETWORK / APP tabs.
|
||||
The v1 72px rail, the 380px permanent settings panel, and the
|
||||
six-column footer are gone.
|
||||
- **Task 39 — participants table v2**: five columns (24px state LED,
|
||||
name + codec caption, 110px audio meter, 130px mono output name, 100px
|
||||
ISO pill), 52px rows, full-row active-speaker tint (replaces the v1
|
||||
left-edge stripe).
|
||||
- **Task 40 — Ctrl+K command palette**: `Views/CommandPaletteWindow.xaml`
|
||||
+ `ViewModels/CommandPaletteViewModel.cs`. Centered 560×360 floating
|
||||
window with fuzzy search across Quick / Teams / Presets / Output /
|
||||
Network / App categories. ↑/↓ navigates, Enter invokes, Esc closes.
|
||||
- **Interactive HTML preview** at `docs/preview/redesigned-mainwindow.html`
|
||||
for stakeholders to see the v2 shell.
|
||||
both modes; cyan-as-text darkens to #0E7C82 on light for AA contrast).
|
||||
- **WinUI 3 host scaffold** as `src/TeamsISO.App.WinUI/` coexisting with
|
||||
the existing WPF host. WindowsAppSDK 1.6 LTS, unpackaged mode, win-x64
|
||||
pinned RID, custom Bootstrap-aware `Program.Main`, post-build runtimeconfig
|
||||
patch to drop the .NET-SDK-implicit `Microsoft.WindowsDesktop.App`
|
||||
framework reference (WinUI 3 doesn't use it).
|
||||
- **Redesigned MainWindow**: 64px rail with brand mark + nav +
|
||||
engine-status puck; 44px custom title bar absorbing live pills
|
||||
(session timer · REC count · disk free) + theme toggle; section header
|
||||
with single Primary CTA + Secondary actions; participants list
|
||||
(ItemsRepeater stub pending DataGrid migration) at 64px row height
|
||||
with cyan-left-border active-speaker treatment; conditional slim
|
||||
in-call control bar; 32px status bar.
|
||||
- **ThemeManager service** holds the user theme preference
|
||||
(System / Dark / Light), resolves via UISettings.GetColorValue when
|
||||
System, broadcasts changes so the AppWindow title-bar buttons stay
|
||||
in sync with the visual tree.
|
||||
- **Settings drawer** that slides in from the right (220ms ease-out-quart)
|
||||
with five tabs (Appearance / Routing / Display / Control / Advanced).
|
||||
Appearance tab includes the theme tri-state picker + an accent palette
|
||||
peek. Replaces the WPF host's 380px permanent settings panel.
|
||||
- **Help / About / Onboarding** as ContentDialog-based surfaces. Help
|
||||
is the keyboard shortcut cheat sheet; About has Wild Dragon mark +
|
||||
version + quick-access folder shortcuts; Onboarding is three numbered
|
||||
steps for first launch (Install NDI Runtime / Enable Teams NDI / Pick
|
||||
transcoder topology) with "Don't show again" defaulted on.
|
||||
- **Interactive HTML preview** at `docs/preview/redesigned-mainwindow.html` —
|
||||
faithful single-file render of the WinUI 3 XAML with theme toggle and
|
||||
drawer interaction, so stakeholders can see the redesign before the
|
||||
WinUI 3 build is feature-complete.
|
||||
- **Migration plan** at `docs/superpowers/plans/2026-05-12-winui3-migration.md`
|
||||
with nine phases (scaffold / shell / activation / VM wiring / DataGrid
|
||||
/ secondary windows / hardening / tests / retire-WPF) and a risk
|
||||
register flagging fallback paths.
|
||||
|
||||
The WPF host (`src/TeamsISO.App/`) is unchanged and remains the shipping
|
||||
build until the WinUI 3 build passes a real-meeting smoke test. The
|
||||
view-model layer is unchanged so the WinUI 3 host will reuse it via
|
||||
ProjectReference once view-model wiring lands (Phase 4 of the plan).
|
||||
|
||||
### Added — May 2026 feature batch
|
||||
|
||||
|
|
@ -243,6 +252,14 @@ For operators who want to launch TeamsISO and never look at the Teams UI:
|
|||
- **Recording badge in footer shows elapsed duration** alongside the count
|
||||
(`REC 3 · 12:45`). Separate timer from the session timer because
|
||||
recording can start AFTER the meeting begins.
|
||||
- **Quick-join Teams meeting from URL** in the IN-CALL bar — paste a
|
||||
`teams.microsoft.com/l/meetup-join/...` or `msteams:/l/meetup-join/...`
|
||||
link, click Join, Teams launches into the meeting in one shot.
|
||||
- **IN-CALL bar surfaces Teams meeting state** — `IN CALL · <meeting title>`
|
||||
/ `READY` / empty. UIA probe at 1Hz for the Leave button, meeting title
|
||||
extracted from Teams' window title with brand suffix stripped.
|
||||
- **Auto-launch Teams + auto-hide windows** preferences for the headless
|
||||
"I only see TeamsISO" workflow.
|
||||
- **MUTED / CAM OFF pills** in the IN-CALL bar — UIA detects whether the
|
||||
local user is muted or has their camera off, surfaces as coral pills.
|
||||
Operator with auto-hide knows the local state without restoring Teams.
|
||||
|
|
|
|||
13
DESIGN.md
13
DESIGN.md
|
|
@ -219,10 +219,9 @@ the same job with less visual noise.
|
|||
|
||||
**Single icon system, one stroke width, one optical size.** The previous GUI
|
||||
inlined ~12 bespoke `<Path Data="...">` icons with stroke widths varying
|
||||
between 1.2 and 1.6. The redesign uses **Segoe Fluent Icons font** (shipped
|
||||
with Windows 11; falls back to Segoe MDL2 Assets on Windows 10) as the
|
||||
baseline, with a custom subset added only where a broadcast concept isn't
|
||||
covered (e.g. NDI signal lock, ISO routing state).
|
||||
between 1.2 and 1.6. The redesign uses **WinUI 3's bundled Segoe Fluent Icons
|
||||
font** as the baseline, with a custom subset added only where a broadcast
|
||||
concept isn't covered (e.g. NDI signal lock, ISO routing state).
|
||||
|
||||
Sizes: 16 (inline), 20 (button), 24 (rail / hero).
|
||||
Stroke: inherited from font; no hand-stroked paths.
|
||||
|
|
@ -234,8 +233,8 @@ Stroke: inherited from font; no hand-stroked paths.
|
|||
- Durations: 120ms for affordance feedback, 200ms for panel transitions,
|
||||
280ms hero (rarely used).
|
||||
- No bounce. No elastic. No spring overshoots.
|
||||
- **Never animate** layout properties. Animate `RenderTransform` and
|
||||
`Opacity` (WPF's composition layer handles these GPU-cheaply).
|
||||
- **Never animate** layout properties. Animate `Translation` and `Opacity`
|
||||
(WinUI 3's composition layer handles these GPU-cheaply).
|
||||
|
||||
## Component decisions
|
||||
|
||||
|
|
@ -330,7 +329,7 @@ no side-stripe borders, no glassmorphism). It does have:
|
|||
## Migration boundary
|
||||
|
||||
The view-model surface in `src/TeamsISO.App/ViewModels/` is the contract.
|
||||
The redesign rewrites `MainWindow.xaml` and `Themes/*` but leaves view-model
|
||||
The redesign rewrites everything in `Views/` (WinUI 3) but leaves view-model
|
||||
properties and commands untouched. Any place where the redesign needs a new
|
||||
piece of view-model state, the contract widens via additive properties —
|
||||
existing bindings keep working until the new view stops needing the old shape.
|
||||
|
|
|
|||
174
NEXT_STEPS.md
174
NEXT_STEPS.md
|
|
@ -1,81 +1,107 @@
|
|||
# Where we left off — v2 "Studio Terminal" shell complete (2026-05-15)
|
||||
# Where we left off — Studio Terminal shell shipped, paper-cuts being chipped (2026-05-16 morning)
|
||||
|
||||
## What's done on main
|
||||
## State of main right now
|
||||
|
||||
**v2 shape locked.** Approved brief at
|
||||
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`. Aesthetic
|
||||
register: "broadcast-engineering instrument" — Linear's keyboard-first
|
||||
density × Avid console legibility. Goes hard against the "screams AI"
|
||||
failure mode.
|
||||
Origin tip: `f3e1f50` — *docs: refresh participants-table column summary + ISO toggle comment*
|
||||
|
||||
**WinUI 3 replatform: abandoned.** The early-May scoping concluded that
|
||||
the redesign is purely view-layer (XAML + theme tokens + view-models);
|
||||
doing it in WPF is strictly less work than fighting WinUI 3 activation +
|
||||
DataGrid replacement. The migration plan + bootstrap probe are archived
|
||||
under `docs/archive/` for the record.
|
||||
Recent commits on `origin/main`, newest first:
|
||||
|
||||
**Shell:**
|
||||
- Default Windows title bar (no custom chromeless caption buttons).
|
||||
- 32px header — Wild Dragon mark + "TeamsISO" wordmark left; three icon
|
||||
buttons right (⌘K command palette, theme toggle, settings gear).
|
||||
- 40px transport strip — single mono line:
|
||||
`● 02:14:32 PART 4 · LIVE 2 CTRL :9755`. Cyan dot + timer only when
|
||||
at least one ISO live.
|
||||
- Body — alert banner + update banner + action toolbar + participants
|
||||
DataGrid + (conditional) meeting bar at the bottom.
|
||||
- Settings — slide-over drawer (420px from right) with OUTPUT / NETWORK /
|
||||
APP tabs. Scrim click or Esc dismisses.
|
||||
- v1 leftovers (72px rail, 380px permanent settings panel, six-column
|
||||
footer) are gone.
|
||||
|
||||
**Theme system:**
|
||||
- `Themes/Theme.Dark.xaml` + `Themes/Theme.Light.xaml` — color brushes
|
||||
only.
|
||||
- `Themes/WildDragonTheme.xaml` — styles + control templates (no color
|
||||
brushes; every brush ref is `DynamicResource`).
|
||||
- `Services/ThemeManager.cs` — swaps the merged dictionary at runtime;
|
||||
reads `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme`
|
||||
for System mode; subscribes to `SystemEvents.UserPreferenceChanged`;
|
||||
persists via `UIPreferences.Theme`.
|
||||
|
||||
**Task 39 — participants table v2 (LANDED).**
|
||||
Five columns: 24px state LED, name + codec caption, 110px audio meter,
|
||||
130px mono output name, 100px ISO pill. 52px rows. Full-row
|
||||
active-speaker tint (replaces the v1 left-stripe).
|
||||
|
||||
**Task 40 — Ctrl+K command palette (LANDED).**
|
||||
`Views/CommandPaletteWindow.xaml` + `ViewModels/CommandPaletteViewModel.cs`
|
||||
ship a centered 560×360 floating window with fuzzy search across Quick /
|
||||
Teams / Presets / Output / Network / App categories. ↑/↓ navigates,
|
||||
Enter invokes, Esc closes. The header ⌘K button and Ctrl+K (also Ctrl+P)
|
||||
keyboard binding both open it.
|
||||
|
||||
**Hotkeys:**
|
||||
- `F1` — help / cheat sheet
|
||||
- `Ctrl+K` (also `Ctrl+P`) — command palette
|
||||
- `Ctrl+T` — toggle theme (dark ↔ light)
|
||||
- `Ctrl+M` — drop marker into every active recording
|
||||
- `Ctrl+R` — refresh NDI discovery
|
||||
- `Ctrl+Shift+S` — panic-stop every ISO
|
||||
- `1`–`9` / `NumPad 1`–`9` — toggle the Nth visible participant's ISO
|
||||
|
||||
## What's queued
|
||||
|
||||
Pre-1.0 cut is gated on:
|
||||
1. Code-signing the MSI (`SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD`
|
||||
Forgejo Secrets wired in `release.yml`).
|
||||
2. A real-meeting smoke pass on a host with a live NDI runtime.
|
||||
|
||||
## Build + run
|
||||
|
||||
```powershell
|
||||
dotnet build TeamsISO.Windows.slnf -c Release
|
||||
.\src\TeamsISO.App\bin\Release\net8.0-windows\TeamsISO.exe
|
||||
```
|
||||
f3e1f50 docs: refresh participants-table column summary + ISO toggle comment
|
||||
a98ab81 ISO toggle: widen column 110->124, tighten padding so 'Enable' fits
|
||||
25229bd ISO toggle: square corners to match the rest of the button family
|
||||
29f7e56 gear icon: swap Path glyph for U+2699 + bump column to 56px
|
||||
5a43c9c feat: per-ISO framerate/resolution/aspect/audio overrides + thumbnail BMP
|
||||
647deec feat(web): topology + thumbnail endpoints, redesigned /ui control panel
|
||||
4944de5 feat(wpf): v2 - restore live thumbnail preview column in participants table
|
||||
209b643 fix(wpf): MainViewModel subscription via direct Subscribe + Dispatcher marshal
|
||||
d282e1b feat(wpf): v2 task 39+40 - studio table redesign + Ctrl+K command palette
|
||||
c271303 feat(wpf): v2 'Studio Terminal' shell - theme system, header, transport strip, drawer
|
||||
1d1ce6a feat(wpf): rollback to WPF host, axe recording, fix settings pane
|
||||
```
|
||||
|
||||
The shipped helpers `build-and-test.ps1` and `commit-and-push.ps1`
|
||||
wrap the build + test + push flow.
|
||||
The v2 "Studio Terminal" shell, the v2 participants table redesign (task 39),
|
||||
the Ctrl+K command palette (task 40), per-ISO overrides + thumbnails, and the
|
||||
redesigned `/ui` control panel are **all shipped**. The previous NEXT_STEPS.md
|
||||
listed 39 + 40 as queued — that's stale.
|
||||
|
||||
If something regresses, `1d1ce6a` is the rollback point for the WPF v1
|
||||
shell (recording was axed at that commit), and `c271303` is the v2
|
||||
shell-without-table-redesign rollback point.
|
||||
## What's running
|
||||
|
||||
A fresh Release build (PID changes each session — current one is `7928`,
|
||||
started 2026-05-16 00:17). Source: `src/TeamsISO.App/bin/Release/net8.0-windows/TeamsISO.exe`,
|
||||
last write 23:50ish after the comment-refresh rebuild.
|
||||
|
||||
## Verified this session
|
||||
|
||||
- `dotnet test src/tests/TeamsISO.Engine.Tests/` → **104 passed / 0 failed**.
|
||||
- `dotnet build src/TeamsISO.App/TeamsISO.App.csproj -c Release` → 0 warnings, 0 errors.
|
||||
- Pushed three new commits to forgejo: `5a43c9c..f3e1f50`.
|
||||
|
||||
## Candidate paper-cuts I noticed but didn't touch
|
||||
|
||||
These are the same shape of fix as the ones in `25229bd` / `29f7e56` /
|
||||
`a98ab81` — small, single-file, low-risk — but I want you to sign off
|
||||
before they ship:
|
||||
|
||||
1. **Header gear button still uses the thin-Path glyph.** `MainWindow.xaml:149`.
|
||||
The exact same near-invisible 1.4px stroke that drove the per-row swap to
|
||||
`U+2699` in `29f7e56` is still on the header settings gear. Same fix
|
||||
probably applies — swap Path → `TextBlock` with `U+2699` from Segoe UI
|
||||
Symbol, drop the size hack.
|
||||
|
||||
2. **Header theme-toggle button.** `MainWindow.xaml:137`. Hand-rolled
|
||||
crescent-moon Path with 1.4px stroke. Reads as a vague grey blob in dark
|
||||
mode. Candidates: `U+263D` (☽), `U+263E` (☾), or `U+2600` / `U+263C`
|
||||
(☀ for light), with a `DataTrigger` swapping by `ThemeManager.Current.Mode`.
|
||||
|
||||
3. **Launch-Teams / Hide-Teams icon buttons in the toolbar.**
|
||||
`MainWindow.xaml:388` and `:400`. Custom Path glyphs at 1.4 / 1.5 stroke
|
||||
thickness — same legibility complaint, just less load-bearing because
|
||||
the buttons also have tooltips.
|
||||
|
||||
4. **Per-row gear column** (`CFG`, 56px wide). Was widened in `29f7e56`
|
||||
from 32 → 56 specifically to fit the "CFG" header. The gear glyph
|
||||
inside is centered and only ~16px wide, so 56px is generous; could
|
||||
probably go to 48px and reclaim 8px for the flex `*` column without
|
||||
losing anything.
|
||||
|
||||
## What's queued for an actual feature pass
|
||||
|
||||
Nothing committed-against. Conversation has danced around:
|
||||
|
||||
- **Real command palette content.** The `Ctrl+K` window exists (commit
|
||||
`d282e1b`) — actions feed from `CommandPaletteCommands.cs`. Worth a pass
|
||||
to make sure every toolbar action and settings tab is mirrored there,
|
||||
and that the keyboard shortcut hints render right.
|
||||
|
||||
- **Settings drawer copy.** The OUTPUT / NETWORK / APP tabs were rewritten
|
||||
during the v2 shell pass. Some of the help text is still v1-era and
|
||||
refers to controls that aren't there anymore. Worth a sweep through
|
||||
`MainWindow.xaml` around the drawer body.
|
||||
|
||||
- **Onboarding window.** `OnboardingWindow.xaml` exists. Last touched
|
||||
before the v2 shell. Probably out of date visually.
|
||||
|
||||
## Build, push, demo cheatsheet
|
||||
|
||||
```powershell
|
||||
cd "C:\Users\zacga\Documents\Claude\Projects\Teams ISO"
|
||||
|
||||
# Clear a stale index lock if you hit one
|
||||
Remove-Item .git\index.lock -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Stop any running instance (release the DLL locks)
|
||||
Stop-Process -Name TeamsISO -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Build + run
|
||||
dotnet build src\TeamsISO.App\TeamsISO.App.csproj -c Release
|
||||
Start-Process .\src\TeamsISO.App\bin\Release\net8.0-windows\TeamsISO.exe
|
||||
|
||||
# Push when ready
|
||||
git add -A
|
||||
git commit -m "your message"
|
||||
git push origin main # forge.wilddragon.net
|
||||
```
|
||||
|
||||
If anything regresses the v2 shell, the v1 entry point is preserved in git
|
||||
history at `1d1ce6a` — rollback with `git reset --hard 1d1ce6a` then publish.
|
||||
|
|
|
|||
33
README.md
33
README.md
|
|
@ -44,21 +44,22 @@ Pre-1.0. The May 2026 batch is feature-complete; v1.0 cut is gated on
|
|||
code-signing the MSI and a smoke pass against a real Teams meeting.
|
||||
See `CHANGELOG.md` for the [Unreleased] entry.
|
||||
|
||||
The May 2026 ground-up redesign — the v2 "Studio Terminal" shell — has
|
||||
landed on the WPF host (`src/TeamsISO.App/`). A WinUI 3 replatform was
|
||||
explored in early May 2026 and abandoned (activation blockers + redundant
|
||||
work given the redesign is purely XAML / view-layer); the brief lives at
|
||||
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`, and the
|
||||
abandoned migration plan + bootstrap probe are archived under
|
||||
`docs/archive/`.
|
||||
A ground-up GUI redesign is in flight on `main` (see
|
||||
`docs/superpowers/plans/2026-05-12-winui3-migration.md`). The WPF host
|
||||
(`src/TeamsISO.App/`) remains the shipping build; a parallel WinUI 3 host
|
||||
(`src/TeamsISO.App.WinUI/`) is scaffolded with the redesigned MainWindow,
|
||||
theme system (dark + light), and secondary surfaces. Activation of the
|
||||
unpackaged WinUI 3 .exe is the current blocker — diagnostics in the
|
||||
migration plan's Phase 3.
|
||||
|
||||
## Build
|
||||
|
||||
Requires .NET 8 SDK on Windows. WPF is the only host:
|
||||
Requires .NET 8 SDK on Windows. The repo has two hosts:
|
||||
|
||||
- `src/TeamsISO.App` — WPF, `net8.0-windows`, the shipping build
|
||||
- `src/TeamsISO.App` — WPF, `net8.0-windows`, current shipping build
|
||||
- `src/TeamsISO.App.WinUI` — WinUI 3, `net8.0-windows10.0.19041.0`, in-flight
|
||||
|
||||
Build from the solution filter:
|
||||
Both build together from the solution filter:
|
||||
|
||||
dotnet restore TeamsISO.Windows.slnf
|
||||
dotnet build TeamsISO.Windows.slnf -c Release
|
||||
|
|
@ -79,21 +80,21 @@ The shipped helper scripts in the repo root automate this:
|
|||
- [Embedded Teams orchestration spec](docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md)
|
||||
— Phase E roadmap.
|
||||
- [Redesign brief](PRODUCT.md) + [design system](DESIGN.md) — token-level
|
||||
spec for the v2 "Studio Terminal" redesign.
|
||||
- [v2 shape brief](docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md) —
|
||||
approved aesthetic + IA for the May 2026 WPF rebuild.
|
||||
spec for the in-flight WinUI 3 redesign.
|
||||
- [WinUI 3 migration plan](docs/superpowers/plans/2026-05-12-winui3-migration.md)
|
||||
— nine-phase plan covering scaffold through retiring the WPF host.
|
||||
- [Interactive redesign preview](docs/preview/redesigned-mainwindow.html) —
|
||||
open in any browser to see and toggle the redesigned MainWindow before
|
||||
the WinUI 3 binary lands.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `F1` | Open help / cheat sheet |
|
||||
| `Ctrl + K` | Open the command palette (also `Ctrl + P`) |
|
||||
| `Ctrl + T` | Toggle theme (dark ↔ light) |
|
||||
| `Ctrl + M` | Drop a timestamped marker into every active recording |
|
||||
| `Ctrl + Shift + S` | Stop every running ISO (emergency) |
|
||||
| `Ctrl + R` | Refresh NDI discovery (rebuild finder) |
|
||||
| `1`–`9` / `NumPad 1`–`9` | Toggle the Nth visible participant's ISO |
|
||||
|
||||
## File locations
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"src\\TeamsISO.Engine.NdiInterop\\TeamsISO.Engine.NdiInterop.csproj",
|
||||
"src\\TeamsISO.Console\\TeamsISO.Console.csproj",
|
||||
"src\\TeamsISO.App\\TeamsISO.App.csproj",
|
||||
"src\\TeamsISO.App.WinUI\\TeamsISO.App.WinUI.csproj",
|
||||
"src\\tests\\TeamsISO.Engine.Tests\\TeamsISO.Engine.Tests.csproj",
|
||||
"src\\tests\\TeamsISO.Engine.IntegrationTests\\TeamsISO.Engine.IntegrationTests.csproj",
|
||||
"src\\tests\\TeamsISO.App.Tests\\TeamsISO.App.Tests.csproj"
|
||||
|
|
|
|||
|
|
@ -1,45 +1,443 @@
|
|||
# Build + test verification, then push the current branch to origin.
|
||||
# Commit + push the May 2026 polish batch to forge.wilddragon.net.
|
||||
#
|
||||
# Run from the repo root:
|
||||
# pwsh -ExecutionPolicy Bypass -File .\commit-and-push.ps1
|
||||
#
|
||||
# This is the operator's "I'm done with this branch, ship it" helper. It
|
||||
# runs build-and-test.ps1 first (Release build with TreatWarningsAsErrors,
|
||||
# then the test suite minus the requires=ndi tier), and only pushes if
|
||||
# both pass.
|
||||
#
|
||||
# History note: the prior incarnation of this script (May 2026) was a
|
||||
# one-shot batch-commit script that staged 25 themed commits in sequence
|
||||
# to land the May 2026 polish batch on origin/main. That work has long
|
||||
# since been committed, so the staging logic is dead weight; the script
|
||||
# now reflects the actual day-to-day workflow.
|
||||
# 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."
|
||||
}
|
||||
|
||||
# Step 1 — build + tests must be green before anything ships.
|
||||
Write-Host "──── Build + test ────" -ForegroundColor Cyan
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File '.\build-and-test.ps1'
|
||||
if ($LASTEXITCODE -ne 0) { throw "build-and-test.ps1 failed; aborting." }
|
||||
# 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
|
||||
}
|
||||
|
||||
# Step 2 — what are we pushing? Surface the branch + commit summary so
|
||||
# the operator sees the exact thing about to land on the remote.
|
||||
$branch = (git rev-parse --abbrev-ref HEAD).Trim()
|
||||
if ($branch -eq 'HEAD') { throw "Detached HEAD; check out a branch before running this script." }
|
||||
# ─── 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 "──── Pushing $branch to origin ────" -ForegroundColor Cyan
|
||||
git status --short
|
||||
$ahead = (git rev-list --count "origin/$branch..HEAD" 2>$null)
|
||||
if (-not $ahead) { $ahead = (git rev-list --count HEAD).Trim() }
|
||||
Write-Host " $ahead commit(s) to push." -ForegroundColor DarkGray
|
||||
|
||||
git push origin $branch
|
||||
if ($LASTEXITCODE -ne 0) { throw "git push failed." }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Done. Pushed $branch to origin." -ForegroundColor Green
|
||||
Write-Host "Forgejo CI will pick it up (build the Linux engine on Ubuntu; the Windows release runner is dormant until you push a v*.*.* tag)." -ForegroundColor DarkGray
|
||||
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,250 +0,0 @@
|
|||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Interop;
|
||||
using TeamsISO.Engine.NdiInterop;
|
||||
using TeamsISO.Engine.Persistence;
|
||||
using TeamsISO.Engine.Pipeline;
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
||||
// Linear bootstrap steps that OnStartup walks through, extracted so the
|
||||
// main file reads as a wiring pipeline rather than a single 200-line
|
||||
// procedure. Each method here either does its own work or returns a
|
||||
// signal (bool / nullable) so OnStartup can bail early on failure.
|
||||
public partial class App
|
||||
{
|
||||
/// <summary>
|
||||
/// Acquire the per-user named mutex that gates a single TeamsISO
|
||||
/// instance per Windows user. Two TeamsISOs on the same machine for
|
||||
/// the same user race over the NDI finder, the NDI senders, and
|
||||
/// %APPDATA%\TeamsISO\config.json — none of those are safe to share.
|
||||
///
|
||||
/// On loss: broadcast the bring-to-front message to wake the existing
|
||||
/// instance and signal the caller to <see cref="Application.Shutdown(int)"/>
|
||||
/// silently. On win: install the message-pump filter so subsequent
|
||||
/// duplicate launches can surface us.
|
||||
/// </summary>
|
||||
/// <returns>true if this is the first instance; false if we should exit.</returns>
|
||||
private bool TryAcquireSingleInstance()
|
||||
{
|
||||
_singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true, SingleInstanceMutexName, out var createdNew);
|
||||
_ownsSingleInstanceMutex = createdNew;
|
||||
if (!createdNew)
|
||||
{
|
||||
var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
|
||||
if (bringToFront != 0)
|
||||
SendNotifyMessageW(HWND_BROADCAST, bringToFront, IntPtr.Zero, IntPtr.Zero);
|
||||
return false;
|
||||
}
|
||||
|
||||
// We're the first instance. Install the message-pump filter so a
|
||||
// *subsequent* launch that broadcasts our bring-to-front message
|
||||
// surfaces our window. Hold the delegate in a field so OnExit can
|
||||
// unsubscribe cleanly (ComponentDispatcher is process-static).
|
||||
var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
|
||||
_bringToFrontHandler = (ref MSG msg, ref bool handled) =>
|
||||
{
|
||||
if (msg.message == (int)bringToFrontMsg && MainWindow is not null)
|
||||
{
|
||||
if (MainWindow.WindowState == WindowState.Minimized) MainWindow.WindowState = WindowState.Normal;
|
||||
MainWindow.Activate();
|
||||
MainWindow.Topmost = true;
|
||||
MainWindow.Topmost = false;
|
||||
handled = true;
|
||||
}
|
||||
};
|
||||
ComponentDispatcher.ThreadFilterMessage += _bringToFrontHandler;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the NDI interop layer. On failure (most commonly: NDI
|
||||
/// Runtime isn't installed), show the operator a "go to ndi.video/tools"
|
||||
/// dialog and signal a clean shutdown. The boolean return is checked
|
||||
/// by OnStartup so we don't continue past a broken NDI host.
|
||||
/// </summary>
|
||||
/// <returns>true on success; false if OnStartup should Shutdown(2).</returns>
|
||||
private bool TryBootstrapNdiInterop()
|
||||
{
|
||||
if (_loggerFactory is null) return false;
|
||||
try
|
||||
{
|
||||
_interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger<NdiInteropPInvoke>());
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(
|
||||
"TeamsISO could not initialize the NDI runtime.\n\n" +
|
||||
"Install the NDI Runtime from https://ndi.video/tools/ and try again.\n\n" +
|
||||
"Details: " + ex.Message,
|
||||
"TeamsISO — NDI runtime missing",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire the engine: configstore, NDI runtime probe, frame scaler,
|
||||
/// pipeline factory, IsoController. Doesn't start the engine — that's
|
||||
/// MainViewModel.InitializeAsync's job.
|
||||
/// </summary>
|
||||
private void BootstrapEngine()
|
||||
{
|
||||
if (_loggerFactory is null || _interop is null) return;
|
||||
|
||||
var configPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"TeamsISO", "config.json");
|
||||
var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger<ConfigStore>());
|
||||
|
||||
var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix);
|
||||
var scaler = new ManagedNearestNeighborFrameScaler();
|
||||
|
||||
var loggerFactoryRef = _loggerFactory;
|
||||
var interopRef = _interop;
|
||||
IsoPipeline PipelineFactory(IsoPipelineConfig config)
|
||||
{
|
||||
var clock = new PeriodicTimerFrameClock(config.Settings.FramerateHz);
|
||||
return new IsoPipeline(
|
||||
config, interopRef, scaler, clock,
|
||||
ExponentialBackoff.Default,
|
||||
(delay, ct) => Task.Delay(delay, ct),
|
||||
loggerFactoryRef);
|
||||
}
|
||||
|
||||
_controller = new IsoController(
|
||||
_interop, PipelineFactory, configStore, probe, _loggerFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Construct the view-model, the main window, and show it. After this
|
||||
/// returns, <see cref="Application.MainWindow"/> is non-null and the
|
||||
/// window is on screen.
|
||||
/// </summary>
|
||||
private MainWindow ConstructAndShowMainWindow()
|
||||
{
|
||||
_viewModel = new MainViewModel(_controller!, Dispatcher);
|
||||
var window = new MainWindow(_viewModel);
|
||||
window.Show();
|
||||
MainWindow = window;
|
||||
return window;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// REST + WebSocket control surface for Stream Deck / Companion and
|
||||
/// the OSC bridge. Created always; only Started if the operator had
|
||||
/// the toggle on in the previous session (the settings VM's setter
|
||||
/// handles the in-session flip path). Failures log + toast — we don't
|
||||
/// want a port-bind error to block app start.
|
||||
/// </summary>
|
||||
private void BootstrapControlSurfaceServices()
|
||||
{
|
||||
if (_controller is null || _viewModel is null || _loggerFactory is null) return;
|
||||
|
||||
_controlSurface = new ControlSurfaceServer(
|
||||
_controller,
|
||||
() => _viewModel,
|
||||
_loggerFactory.CreateLogger<ControlSurfaceServer>());
|
||||
_oscBridge = new OscBridge(
|
||||
_controller,
|
||||
() => _viewModel,
|
||||
_loggerFactory.CreateLogger<OscBridge>());
|
||||
|
||||
if (_viewModel.Settings.ControlSurfaceEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
_controlSurface.Start(
|
||||
_viewModel.Settings.ControlSurfacePort,
|
||||
_viewModel.Settings.ControlSurfaceLanReachable);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_loggerFactory.CreateLogger<App>().LogWarning(ex,
|
||||
"Control surface auto-start failed; operator can retry via Settings.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tray-icon host. Hosting from App (not MainWindow) ensures icon
|
||||
/// lifetime matches the process, so the icon stays visible during a
|
||||
/// minimize-to-tray (when MainWindow is hidden).
|
||||
/// </summary>
|
||||
private void BootstrapTrayIcon(MainWindow window)
|
||||
{
|
||||
if (_viewModel is null) return;
|
||||
_trayIcon = new TrayIconHost(window)
|
||||
{
|
||||
Enabled = _viewModel.Settings.MinimizeToTray,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// First-launch onboarding dialog. Shown AFTER MainWindow so it has
|
||||
/// a sensible Owner for centering + z-order. Suppressed forever once
|
||||
/// the user dismisses with the checkbox checked.
|
||||
/// </summary>
|
||||
private static void TryShowOnboarding(MainWindow window)
|
||||
{
|
||||
if (!OnboardingWindow.ShouldShow()) return;
|
||||
try
|
||||
{
|
||||
var onboarding = new OnboardingWindow { Owner = window };
|
||||
onboarding.ShowDialog();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Defensive: an onboarding-dialog failure should never block startup.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-launch Teams in the background if the operator opted in.
|
||||
/// Combined with AutoHideTeamsWindows this gives the "I only see
|
||||
/// TeamsISO" experience. Fire-and-forget — a slow Teams launch must
|
||||
/// not delay TeamsISO's own window from appearing.
|
||||
/// </summary>
|
||||
private void TryAutoLaunchTeams(ILogger logger)
|
||||
{
|
||||
if (_viewModel is null) return;
|
||||
var settings = _viewModel.Settings;
|
||||
|
||||
if (settings.LaunchTeamsOnStartup && !TeamsLauncher.IsRunning())
|
||||
{
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (TeamsLauncher.TryLaunch(out var launchError))
|
||||
{
|
||||
if (settings.AutoHideTeamsWindows)
|
||||
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("Auto-launch Teams on startup failed: {Error}", launchError);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Auto-launch Teams on startup threw");
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (settings.AutoHideTeamsWindows && TeamsLauncher.IsRunning())
|
||||
{
|
||||
// Teams is already up from a previous session. If auto-hide is
|
||||
// on, hide it now so the operator's "I only see TeamsISO" rule
|
||||
// applies even when Teams was launched externally.
|
||||
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
||||
// Crash diagnostics — the three exception channels WPF leaves open by
|
||||
// default, wired to a single handler that logs Fatal to Serilog (rolling
|
||||
// daily file at %LOCALAPPDATA%\TeamsISO\Logs) and then shows the user a
|
||||
// dialog with the log path so they can attach it to a bug report.
|
||||
//
|
||||
// We deliberately don't catch StackOverflowException or
|
||||
// ExecutionEngineException — both are uncatchable in modern .NET; if one
|
||||
// fires the OS Watson dialog takes it from here.
|
||||
public partial class App
|
||||
{
|
||||
/// <summary>
|
||||
/// Where the rolling Serilog file sink writes. Reused by the crash
|
||||
/// dialog so we can show the user the exact directory to attach when
|
||||
/// filing a bug.
|
||||
/// </summary>
|
||||
private static string LogDirectory =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO", "Logs");
|
||||
|
||||
private void OnAppDomainUnhandled(object sender, UnhandledExceptionEventArgs e)
|
||||
{
|
||||
// IsTerminating is almost always true here — finalizers and
|
||||
// managed-thread top-frames don't have a graceful path back. Log
|
||||
// + show a dialog inline since the process will exit either way.
|
||||
var ex = e.ExceptionObject as Exception;
|
||||
TryLogFatal("AppDomain.UnhandledException", ex);
|
||||
TryShowCrashDialog(ex, terminating: e.IsTerminating);
|
||||
}
|
||||
|
||||
private void OnDispatcherUnhandled(object sender, DispatcherUnhandledExceptionEventArgs e)
|
||||
{
|
||||
TryLogFatal("Dispatcher.UnhandledException", e.Exception);
|
||||
TryShowCrashDialog(e.Exception, terminating: false);
|
||||
// Mark Handled so a single bad UI thunk doesn't take the whole app
|
||||
// down — the user has the dialog and the log; they can choose to
|
||||
// keep going.
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnUnobservedTaskException(object? sender, System.Threading.Tasks.UnobservedTaskExceptionEventArgs e)
|
||||
{
|
||||
TryLogFatal("TaskScheduler.UnobservedTaskException", e.Exception);
|
||||
// Don't show a dialog here — these fire from the finalizer thread
|
||||
// and tend to be cleanup-time noise, not user-actionable. Log only.
|
||||
e.SetObserved();
|
||||
}
|
||||
|
||||
private void TryLogFatal(string source, Exception? ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logger = _loggerFactory?.CreateLogger<App>();
|
||||
logger?.LogCritical(ex, "{Source} fired", source);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Logger itself failed (rare — disk full, permission denied).
|
||||
// Swallow: nothing useful to do, and re-throwing during crash
|
||||
// handling makes things worse.
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryShowCrashDialog(Exception? ex, bool terminating)
|
||||
{
|
||||
try
|
||||
{
|
||||
var heading = terminating
|
||||
? "TeamsISO encountered an unrecoverable error and will exit."
|
||||
: "TeamsISO encountered an error.";
|
||||
var details = ex?.GetType().Name + ": " + (ex?.Message ?? "(no details)");
|
||||
var body =
|
||||
heading + "\n\n" +
|
||||
details + "\n\n" +
|
||||
$"A full diagnostic log has been written to:\n{LogDirectory}\n\n" +
|
||||
"Attach the most recent file from that directory to your bug report.";
|
||||
MessageBox.Show(body, "TeamsISO — Error",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Even the dialog failed (e.g., during shutdown when the
|
||||
// message pump is already gone). Nothing more to do.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
||||
// Background update check, throttled to once per 24h. Fire-and-forget
|
||||
// so a slow / offline update server never delays startup. Surfaces a
|
||||
// banner via UpdateBanner if newer; failures just log.
|
||||
public partial class App
|
||||
{
|
||||
/// <summary>
|
||||
/// Kick off the launch-time update check if the operator hasn't opted
|
||||
/// out via the flag file. Called from OnStartup right after the engine
|
||||
/// + view-model are live. Returns immediately; the actual HTTP call
|
||||
/// runs on a worker.
|
||||
/// </summary>
|
||||
private void StartBackgroundUpdateCheck(ILogger logger)
|
||||
{
|
||||
if (!UpdateChecker.LaunchCheckEnabled) return;
|
||||
if (_viewModel is null) return;
|
||||
|
||||
var vm = _viewModel;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
|
||||
if (result?.Status == UpdateChecker.UpdateStatus.UpdateAvailable
|
||||
&& !string.IsNullOrEmpty(result.LatestTag)
|
||||
&& !string.IsNullOrEmpty(result.CurrentVersion))
|
||||
{
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
vm.UpdateBanner.Show(result.CurrentVersion!, result.LatestTag!));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Background update check failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +1,22 @@
|
|||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Interop;
|
||||
using TeamsISO.Engine.Logging;
|
||||
using TeamsISO.Engine.NdiInterop;
|
||||
using TeamsISO.Engine.Persistence;
|
||||
using TeamsISO.Engine.Pipeline;
|
||||
|
||||
// Application + MessageBox aliases live in GlobalUsings.cs (project-wide).
|
||||
// Don't redeclare here — Roslyn errors with CS1537 on duplicate alias.
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
||||
// Split across partial files by responsibility:
|
||||
// • App.xaml.cs — class skeleton, OnStartup (the wiring
|
||||
// pipeline that calls into the partials),
|
||||
// OnExit, CLI arg parser.
|
||||
// • App.Bootstrap.cs — the linear setup steps OnStartup walks
|
||||
// (single-instance gate, NDI interop, engine,
|
||||
// main window, control surface, tray icon,
|
||||
// onboarding, Teams auto-launch).
|
||||
// • App.CrashHandlers.cs — AppDomain / Dispatcher / Task exception
|
||||
// handlers + crash dialog + LogDirectory.
|
||||
// • App.UpdateCheckBootstrap.cs — the background update-checker
|
||||
// kickoff (24h-throttled).
|
||||
public partial class App : Application
|
||||
{
|
||||
/// <summary>
|
||||
|
|
@ -89,20 +82,45 @@ public partial class App : Application
|
|||
// in place; DynamicResource refs in WildDragonTheme.xaml re-bind.
|
||||
TeamsISO.App.Services.ThemeManager.Current.Apply();
|
||||
|
||||
// Single-instance gate. Implementation in App.Bootstrap.cs; we
|
||||
// bail silently if another instance already owns the mutex (the
|
||||
// existing instance gets surfaced via the bring-to-front broadcast).
|
||||
if (!TryAcquireSingleInstance())
|
||||
// Single-instance gate: if another TeamsISO is already running for this user,
|
||||
// broadcast the bring-to-front message and exit silently. This prevents the
|
||||
// NDI/config contention seen during testing where two finders, two senders
|
||||
// with the same default name, and two writers to config.json all raced.
|
||||
bool createdNew;
|
||||
_singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true, SingleInstanceMutexName, out createdNew);
|
||||
_ownsSingleInstanceMutex = createdNew;
|
||||
if (!createdNew)
|
||||
{
|
||||
var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
|
||||
if (bringToFront != 0)
|
||||
SendNotifyMessageW(HWND_BROADCAST, bringToFront, IntPtr.Zero, IntPtr.Zero);
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for the broadcast — if a *new* instance launches and finds us already
|
||||
// running, it'll send this message; we surface our window in response. Hold the
|
||||
// delegate in a field so OnExit can unsubscribe cleanly even though the
|
||||
// AppDomain teardown would also drop it.
|
||||
var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
|
||||
_bringToFrontHandler = (ref System.Windows.Interop.MSG msg, ref bool handled) =>
|
||||
{
|
||||
if (msg.message == (int)bringToFrontMsg && MainWindow is not null)
|
||||
{
|
||||
if (MainWindow.WindowState == WindowState.Minimized) MainWindow.WindowState = WindowState.Normal;
|
||||
MainWindow.Activate();
|
||||
MainWindow.Topmost = true;
|
||||
MainWindow.Topmost = false;
|
||||
handled = true;
|
||||
}
|
||||
};
|
||||
ComponentDispatcher.ThreadFilterMessage += _bringToFrontHandler;
|
||||
|
||||
try
|
||||
{
|
||||
// WPF host: write to both console (visible if attached) and a
|
||||
// rolling daily file under %LOCALAPPDATA%\TeamsISO\Logs so users
|
||||
// have something to grab when they file an issue.
|
||||
// WPF host: write to both console (visible if attached) and a rolling daily
|
||||
// file under %LOCALAPPDATA%\TeamsISO\Logs so users have something to grab when
|
||||
// they file an issue.
|
||||
_loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
|
||||
var logger = _loggerFactory.CreateLogger<App>();
|
||||
logger.LogInformation(
|
||||
|
|
@ -110,26 +128,180 @@ public partial class App : Application
|
|||
typeof(App).Assembly.GetName().Version,
|
||||
Environment.ProcessId);
|
||||
|
||||
if (!TryBootstrapNdiInterop())
|
||||
// ---- Preflight: NDI runtime ----
|
||||
try
|
||||
{
|
||||
_interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger<NdiInteropPInvoke>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(
|
||||
"TeamsISO could not initialize the NDI runtime.\n\n" +
|
||||
"Install the NDI Runtime from https://ndi.video/tools/ and try again.\n\n" +
|
||||
"Details: " + ex.Message,
|
||||
"TeamsISO — NDI runtime missing",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Error);
|
||||
Shutdown(2);
|
||||
return;
|
||||
}
|
||||
|
||||
BootstrapEngine();
|
||||
var window = ConstructAndShowMainWindow();
|
||||
BootstrapControlSurfaceServices();
|
||||
BootstrapTrayIcon(window);
|
||||
TryShowOnboarding(window);
|
||||
// ---- Engine wiring ----
|
||||
var configPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"TeamsISO", "config.json");
|
||||
var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger<ConfigStore>());
|
||||
|
||||
// Parse CLI args BEFORE InitializeAsync so any --apply-preset
|
||||
// request overrides the persisted auto-apply preference cleanly.
|
||||
var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix);
|
||||
var scaler = new ManagedNearestNeighborFrameScaler();
|
||||
|
||||
var loggerFactoryRef = _loggerFactory;
|
||||
var interopRef = _interop;
|
||||
IsoPipeline PipelineFactory(IsoPipelineConfig config)
|
||||
{
|
||||
var clock = new PeriodicTimerFrameClock(config.Settings.FramerateHz);
|
||||
return new IsoPipeline(
|
||||
config, interopRef, scaler, clock,
|
||||
ExponentialBackoff.Default,
|
||||
(delay, ct) => Task.Delay(delay, ct),
|
||||
loggerFactoryRef);
|
||||
}
|
||||
|
||||
_controller = new IsoController(
|
||||
_interop, PipelineFactory, configStore, probe, _loggerFactory);
|
||||
|
||||
_viewModel = new MainViewModel(_controller, Dispatcher);
|
||||
var window = new MainWindow(_viewModel);
|
||||
window.Show();
|
||||
MainWindow = window;
|
||||
|
||||
// REST control surface for Stream Deck / Companion. Off by default —
|
||||
// operators turn it on via the DISPLAY tab. When the toggle flips,
|
||||
// GlobalSettingsViewModel reaches into App.Current to start/stop it.
|
||||
_controlSurface = new TeamsISO.App.Services.ControlSurfaceServer(
|
||||
_controller,
|
||||
() => _viewModel,
|
||||
_loggerFactory.CreateLogger<TeamsISO.App.Services.ControlSurfaceServer>());
|
||||
_oscBridge = new TeamsISO.App.Services.OscBridge(
|
||||
_controller,
|
||||
() => _viewModel,
|
||||
_loggerFactory.CreateLogger<TeamsISO.App.Services.OscBridge>());
|
||||
|
||||
// Auto-start the REST + WebSocket control surface if the operator
|
||||
// turned it on in a previous session. The settings VM's setter
|
||||
// also calls Start when the operator toggles it during a session;
|
||||
// this block covers the "restart the app, expect it still on" case.
|
||||
if (_viewModel.Settings.ControlSurfaceEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
_controlSurface.Start(
|
||||
_viewModel.Settings.ControlSurfacePort,
|
||||
_viewModel.Settings.ControlSurfaceLanReachable);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_loggerFactory.CreateLogger<App>().LogWarning(ex,
|
||||
"Control surface auto-start failed; operator can retry via Settings.");
|
||||
}
|
||||
}
|
||||
|
||||
// DiskSpaceWatcher removed alongside the rest of the recording surface.
|
||||
|
||||
// Tray icon host. Disabled by default; the settings VM flips
|
||||
// Enabled when the operator toggles the DISPLAY checkbox. Hosting
|
||||
// it from App ensures the icon's lifetime matches the process,
|
||||
// not the main window (which gets hidden during minimize-to-tray).
|
||||
_trayIcon = new TeamsISO.App.Services.TrayIconHost(window)
|
||||
{
|
||||
Enabled = _viewModel.Settings.MinimizeToTray,
|
||||
};
|
||||
|
||||
// First-launch onboarding. The dialog explains the once-per-machine
|
||||
// setup (NDI runtime, Teams admin permission, transcoder topology)
|
||||
// that the UI alone can't communicate clearly. Suppressed after the
|
||||
// user dismisses it with the checkbox checked. We show it AFTER the
|
||||
// main window so the dialog has a sensible Owner for centering and
|
||||
// z-order.
|
||||
if (OnboardingWindow.ShouldShow())
|
||||
{
|
||||
try
|
||||
{
|
||||
var onboarding = new OnboardingWindow { Owner = window };
|
||||
onboarding.ShowDialog();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Defensive: an onboarding-dialog failure should never block startup.
|
||||
}
|
||||
}
|
||||
|
||||
// Parse CLI args BEFORE InitializeAsync so any --apply-preset request
|
||||
// overrides the persisted auto-apply preference cleanly.
|
||||
ApplyCommandLineArgs(e.Args);
|
||||
|
||||
await _viewModel!.InitializeAsync(CancellationToken.None);
|
||||
await _viewModel.InitializeAsync(CancellationToken.None);
|
||||
|
||||
TryAutoLaunchTeams(logger);
|
||||
StartBackgroundUpdateCheck(logger);
|
||||
// Auto-launch Teams in the background if the operator has opted in.
|
||||
// Combined with AutoHideTeamsWindows this gives the "I only see
|
||||
// TeamsISO" experience — Teams runs but never appears on screen,
|
||||
// and all interaction routes through the IN-CALL bar + participants
|
||||
// DataGrid. Fire-and-forget so a slow Teams launch doesn't delay
|
||||
// TeamsISO's window from appearing.
|
||||
if (_viewModel.Settings.LaunchTeamsOnStartup && !Services.TeamsLauncher.IsRunning())
|
||||
{
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Services.TeamsLauncher.TryLaunch(out var launchError))
|
||||
{
|
||||
if (_viewModel.Settings.AutoHideTeamsWindows)
|
||||
_ = Services.TeamsLauncher.AutoHideAfterLaunchAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("Auto-launch Teams on startup failed: {Error}", launchError);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Auto-launch Teams on startup threw");
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (_viewModel.Settings.AutoHideTeamsWindows && Services.TeamsLauncher.IsRunning())
|
||||
{
|
||||
// Teams is already up from a previous session. If auto-hide is
|
||||
// on, hide it now so the operator's "I only see TeamsISO" rule
|
||||
// applies even when Teams was launched externally.
|
||||
_ = Services.TeamsLauncher.AutoHideAfterLaunchAsync();
|
||||
}
|
||||
|
||||
// Background update check, throttled to once per 24h. Fire-and-forget
|
||||
// so a slow / offline update server never delays startup. Surfaces a
|
||||
// banner via UpdateBanner if newer; failures just log.
|
||||
if (Services.UpdateChecker.LaunchCheckEnabled)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await Services.UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
|
||||
if (result?.Status == Services.UpdateChecker.UpdateStatus.UpdateAvailable
|
||||
&& !string.IsNullOrEmpty(result.LatestTag)
|
||||
&& !string.IsNullOrEmpty(result.CurrentVersion))
|
||||
{
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
_viewModel.UpdateBanner.Show(result.CurrentVersion!, result.LatestTag!));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Background update check failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -149,6 +321,15 @@ public partial class App : Application
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the rolling Serilog file sink writes. Reused by the crash dialog so we
|
||||
/// can show the user the exact directory to attach when filing a bug.
|
||||
/// </summary>
|
||||
private static string LogDirectory =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO", "Logs");
|
||||
|
||||
/// <summary>
|
||||
/// Parse the supported CLI flags. Currently:
|
||||
/// <c>--apply-preset NAME</c> — apply the named preset once participants
|
||||
|
|
@ -175,9 +356,70 @@ public partial class App : Application
|
|||
}
|
||||
}
|
||||
|
||||
// Crash handlers (OnAppDomainUnhandled / OnDispatcherUnhandled /
|
||||
// OnUnobservedTaskException / TryLogFatal / TryShowCrashDialog / LogDirectory)
|
||||
// live in App.CrashHandlers.cs.
|
||||
private void OnAppDomainUnhandled(object sender, UnhandledExceptionEventArgs e)
|
||||
{
|
||||
// IsTerminating is almost always true here — finalizers and managed-thread
|
||||
// top-frames don't have a graceful path back. Log + show a dialog inline
|
||||
// since the process will exit either way.
|
||||
var ex = e.ExceptionObject as Exception;
|
||||
TryLogFatal("AppDomain.UnhandledException", ex);
|
||||
TryShowCrashDialog(ex, terminating: e.IsTerminating);
|
||||
}
|
||||
|
||||
private void OnDispatcherUnhandled(object sender, DispatcherUnhandledExceptionEventArgs e)
|
||||
{
|
||||
TryLogFatal("Dispatcher.UnhandledException", e.Exception);
|
||||
TryShowCrashDialog(e.Exception, terminating: false);
|
||||
// Mark Handled so a single bad UI thunk doesn't take the whole app down —
|
||||
// the user has the dialog and the log; they can choose to keep going.
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnUnobservedTaskException(object? sender, System.Threading.Tasks.UnobservedTaskExceptionEventArgs e)
|
||||
{
|
||||
TryLogFatal("TaskScheduler.UnobservedTaskException", e.Exception);
|
||||
// Don't show a dialog here — these fire from the finalizer thread and
|
||||
// tend to be cleanup-time noise, not user-actionable. Log only.
|
||||
e.SetObserved();
|
||||
}
|
||||
|
||||
private void TryLogFatal(string source, Exception? ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logger = _loggerFactory?.CreateLogger<App>();
|
||||
logger?.LogCritical(ex, "{Source} fired", source);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Logger itself failed (rare — disk full, permission denied). Swallow:
|
||||
// there's nothing useful we can do, and re-throwing during crash
|
||||
// handling makes things worse.
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryShowCrashDialog(Exception? ex, bool terminating)
|
||||
{
|
||||
try
|
||||
{
|
||||
var heading = terminating
|
||||
? "TeamsISO encountered an unrecoverable error and will exit."
|
||||
: "TeamsISO encountered an error.";
|
||||
var details = ex?.GetType().Name + ": " + (ex?.Message ?? "(no details)");
|
||||
var body =
|
||||
heading + "\n\n" +
|
||||
details + "\n\n" +
|
||||
$"A full diagnostic log has been written to:\n{LogDirectory}\n\n" +
|
||||
"Attach the most recent file from that directory to your bug report.";
|
||||
MessageBox.Show(body, "TeamsISO — Error",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Even the dialog failed (e.g., during shutdown when the message pump
|
||||
// is already gone). Nothing more to do.
|
||||
}
|
||||
}
|
||||
|
||||
protected override async void OnExit(ExitEventArgs e)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@
|
|||
FalseValue="Visible"/>
|
||||
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
|
||||
<conv:LevelThresholdConverter x:Key="LevelGate"/>
|
||||
<conv:CountToVisibilityConverter x:Key="CountToVis"/>
|
||||
</Window.Resources>
|
||||
|
||||
<Window.InputBindings>
|
||||
|
|
@ -139,7 +138,7 @@
|
|||
Command="{Binding ToggleThemeCommand}"
|
||||
Padding="6,4"
|
||||
Margin="0,0,2,0"
|
||||
ToolTip="Theme (System / Dark / Light)">
|
||||
ToolTip="Toggle theme (Ctrl+T)">
|
||||
<Path Data="M 8,2 C 8,2 4,4 4,8 C 4,12 8,14 8,14 C 5,14 2,11 2,8 C 2,5 5,2 8,2 Z"
|
||||
Stroke="{DynamicResource Wd.Text.Secondary}"
|
||||
StrokeThickness="1.4"
|
||||
|
|
@ -411,33 +410,36 @@
|
|||
<!--
|
||||
Participants table — v2 "Studio Terminal" layout.
|
||||
|
||||
Spec columns (from docs/shapes/2026-05-13-…studio-terminal.md):
|
||||
Seven columns (left → right):
|
||||
1. State LED 24px — 8×8 hard-edged square. Filled cyan
|
||||
when LIVE; filled coral on ERROR;
|
||||
filled amber on NO SIGNAL / STARTING;
|
||||
hollow neutral when OFF.
|
||||
2. Name + caption * — DisplayName (Inter 13/Medium) plus
|
||||
2. Preview thumb 106px — 96×54 (16:9) WriteableBitmap fed
|
||||
from the engine's most recent
|
||||
ProcessedFrame at the 1Hz stats
|
||||
tick. Em-dash placeholder when
|
||||
no pipeline is running.
|
||||
3. Name + caption * — DisplayName (Inter 13/Medium) plus
|
||||
source machine + state label below
|
||||
in JetBrains Mono 11/Tertiary.
|
||||
3. Audio meter 110px — five vertical hard-edged bars,
|
||||
4. Audio meter 110px — five vertical hard-edged bars,
|
||||
each lit when DisplayedAudioLevel
|
||||
crosses its threshold (0.2, 0.4,
|
||||
0.6, 0.8, 1.0). No averaging.
|
||||
4. Output name 130px — JetBrains Mono 12 — the NDI source
|
||||
0.6, 0.8, 0.95). No averaging.
|
||||
5. Output name 150px — JetBrains Mono 12 — the NDI source
|
||||
name TeamsISO broadcasts as.
|
||||
5. ISO toggle pill 100px — LIVE = cyan-muted fill with cyan
|
||||
text; OFF = hollow neutral; ERROR
|
||||
gets the existing trigger swap.
|
||||
|
||||
Deliberate deviations from the spec (operator preference, see
|
||||
4944de5 — "restore live thumbnail preview column"):
|
||||
• A 106px live thumbnail column sits between State LED and
|
||||
Name. Replaces the table's previous role as the only place
|
||||
to see what the operator is broadcasting; the pop-out
|
||||
preview window is the secondary view.
|
||||
• A 32px ghost-button cell on the right edge of Name opens
|
||||
the per-ISO override dialog (framerate / resolution /
|
||||
aspect / audio). Hidden on hover-out.
|
||||
6. CFG (gear) 56px — U+2699 glyph button; opens the
|
||||
per-participant override editor
|
||||
(framerate / res / aspect / audio).
|
||||
7. ISO toggle 124px — rounded-rect Radius.M button. LIVE
|
||||
= cyan-muted fill with cyan text;
|
||||
OFF = hollow neutral; ERROR gets
|
||||
the existing trigger swap. Width
|
||||
124 chosen so 'Enable' and
|
||||
'● LIVE' don't clip at the right
|
||||
edge after the corner-radius
|
||||
change in 25229bd.
|
||||
|
||||
Row height 52 (down from 56). Active speaker = full-row
|
||||
bg.active-speaker tint set by the global DataGridRow style
|
||||
|
|
@ -449,7 +451,6 @@
|
|||
BorderThickness="1"
|
||||
CornerRadius="{StaticResource Radius.M}"
|
||||
Background="{DynamicResource Wd.Surface}">
|
||||
<Grid>
|
||||
<DataGrid x:Name="ParticipantsGrid"
|
||||
ItemsSource="{Binding ParticipantsView}"
|
||||
AutoGenerateColumns="False"
|
||||
|
|
@ -463,8 +464,7 @@
|
|||
CanUserResizeRows="False"
|
||||
SelectionMode="Single"
|
||||
SelectionUnit="FullRow"
|
||||
RowHeight="52"
|
||||
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}}">
|
||||
RowHeight="52">
|
||||
<DataGrid.Columns>
|
||||
<!-- Col 1 — State LED. 8×8 hard-edged Rectangle.
|
||||
Default fill is hollow (transparent with stroke). DataTriggers
|
||||
|
|
@ -614,7 +614,7 @@
|
|||
|
||||
<!-- Col 4 — Output name (mono). The NDI source name TeamsISO
|
||||
will broadcast this participant as. -->
|
||||
<DataGridTemplateColumn Header="Output" Width="130" IsReadOnly="True">
|
||||
<DataGridTemplateColumn Header="Output" Width="150" IsReadOnly="True">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding OutputName}"
|
||||
|
|
@ -628,36 +628,43 @@
|
|||
</DataGridTemplateColumn>
|
||||
|
||||
<!-- Col 5a — Per-row gear: opens the ISO override editor for this
|
||||
participant. Narrow (32px) so the table still fits inside a
|
||||
1280px window after the toggle column. -->
|
||||
<DataGridTemplateColumn Header="" Width="32" IsReadOnly="True">
|
||||
participant. We use the Unicode gear glyph (U+2699) instead
|
||||
of a custom Path — it renders cleanly at any size, doesn't
|
||||
disappear against dark rows the way 1.4px strokes do, and
|
||||
reads as "settings" at a glance. Header is "CFG" so the
|
||||
affordance is discoverable even when the row hover state
|
||||
isn't active. -->
|
||||
<DataGridTemplateColumn Header="CFG" Width="56" IsReadOnly="True">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||
Click="OnIsoOverrideClick"
|
||||
Padding="6,4"
|
||||
Padding="6,2"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
ToolTip="Override output settings for this participant">
|
||||
<Path Data="M 8,1 L 8,3 M 8,13 L 8,15 M 1,8 L 3,8 M 13,8 L 15,8 M 3,3 L 4.5,4.5 M 11.5,11.5 L 13,13 M 3,13 L 4.5,11.5 M 11.5,4.5 L 13,3 M 8,5.5 C 9.4,5.5 10.5,6.6 10.5,8 C 10.5,9.4 9.4,10.5 8,10.5 C 6.6,10.5 5.5,9.4 5.5,8 C 5.5,6.6 6.6,5.5 8,5.5"
|
||||
Stroke="{DynamicResource Wd.Text.Secondary}"
|
||||
StrokeThickness="1.4"
|
||||
Fill="Transparent"
|
||||
Width="14" Height="14"
|
||||
Stretch="None"/>
|
||||
ToolTip="Override output settings for this participant (framerate, resolution, audio)">
|
||||
<TextBlock Text="⚙"
|
||||
FontSize="16"
|
||||
FontFamily="Segoe UI Symbol"
|
||||
Foreground="{DynamicResource Wd.Text.Primary}"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"/>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<!-- Col 5 — ISO toggle pill. LIVE = cyan-muted fill + cyan border + cyan text.
|
||||
OFF = hollow neutral. Error states use the existing IsoToggle style. -->
|
||||
<DataGridTemplateColumn Header="ISO" Width="100">
|
||||
<!-- Col 7 — ISO toggle. LIVE = cyan-muted fill + cyan border + cyan text.
|
||||
OFF = hollow neutral. Error states use the existing IsoToggle style.
|
||||
Width 124 (was 110) so the "Enable" / "● LIVE" content has breathing
|
||||
room inside the rounded-rect — 110 was clipping the label at the
|
||||
right edge once the IsoToggle stopped being a full pill (25229bd). -->
|
||||
<DataGridTemplateColumn Header="ISO" Width="124">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Button Command="{Binding ToggleIsoCommand}"
|
||||
Margin="0,0,12,0"
|
||||
Padding="14,6"
|
||||
Padding="10,6"
|
||||
VerticalAlignment="Center">
|
||||
<Button.Style>
|
||||
<Style TargetType="Button" BasedOn="{StaticResource Wd.Button.IsoToggle}">
|
||||
|
|
@ -678,30 +685,6 @@
|
|||
</DataGridTemplateColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<!-- Empty-state placeholder. Renders when no NDI participants
|
||||
have been discovered yet. Mono sentence + one tertiary
|
||||
Refresh button — no illustration, no mascot, per the v2
|
||||
shape brief's empty-states section. -->
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}, ConverterParameter=empty}">
|
||||
<TextBlock Text="no ndi sources yet — open teams and start a meeting"
|
||||
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||
HorizontalAlignment="Center"/>
|
||||
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||
Command="{Binding RefreshDiscoveryCommand}"
|
||||
Content="Refresh discovery (Ctrl+R)"
|
||||
Padding="14,7"
|
||||
Margin="0,14,0,0"
|
||||
HorizontalAlignment="Center"
|
||||
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||
FontSize="11"
|
||||
ToolTip="Rebuild the NDI finder"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
|
|
|
|||
|
|
@ -42,12 +42,7 @@ public partial class MainWindow : Window
|
|||
/// <summary>Persist the placement on close so next launch lands in the same spot.</summary>
|
||||
private void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
// A failure persisting window state must NEVER block the window from
|
||||
// closing — operator's shutdown comes first. WindowStateStore.Save
|
||||
// already swallows its own IO errors; this is defense-in-depth for
|
||||
// anything that escapes (NRE, future regression, etc.).
|
||||
try { WindowStateStore.Save(this); }
|
||||
catch { /* best-effort: forgo placement memory for one launch */ }
|
||||
WindowStateStore.Save(this);
|
||||
}
|
||||
|
||||
/// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary>
|
||||
|
|
@ -92,8 +87,8 @@ public partial class MainWindow : Window
|
|||
if (!TeamsLauncher.IsRunning())
|
||||
{
|
||||
MessageBox.Show(
|
||||
Properties.Strings.HideShowTeams_NotRunning_Message,
|
||||
Properties.Strings.HideShowTeams_Title,
|
||||
"Microsoft Teams isn't running. Click the camera icon above to launch it first.",
|
||||
"TeamsISO — Hide / show Teams",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
return;
|
||||
|
|
@ -130,8 +125,8 @@ public partial class MainWindow : Window
|
|||
if (!TeamsLauncher.TryLaunch(out var error))
|
||||
{
|
||||
MessageBox.Show(
|
||||
string.Format(Properties.Strings.LaunchTeams_Failed_MessageFormat, error),
|
||||
Properties.Strings.LaunchTeams_Title,
|
||||
$"Could not launch Microsoft Teams.\n\n{error}",
|
||||
"TeamsISO — Launch Teams",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
|
|
@ -164,19 +159,15 @@ public partial class MainWindow : Window
|
|||
/// <summary>
|
||||
/// Right-click on the Launch button asks to stop Teams. Split out from the
|
||||
/// left-click so a normal click is "open / surface" rather than the previous
|
||||
/// "open OR ambush you with a stop dialog". The confirmation dialog here is
|
||||
/// intentional — Stop Teams is a destructive mid-show action; explicit
|
||||
/// confirmation is the right pattern, not the "ambush" anti-pattern that
|
||||
/// was fixed for left-click. The palette also offers Stop Teams for
|
||||
/// keyboard-first operators.
|
||||
/// "open OR ambush you with a stop dialog".
|
||||
/// </summary>
|
||||
private void OnLaunchTeamsRightClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (!TeamsLauncher.IsRunning()) return;
|
||||
|
||||
var confirm = MessageBox.Show(
|
||||
Properties.Strings.StopTeams_Confirm_Message,
|
||||
Properties.Strings.StopTeams_Title,
|
||||
"Microsoft Teams is currently running.\n\nClose all Teams windows now?",
|
||||
"TeamsISO — Stop Teams",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question);
|
||||
if (confirm != MessageBoxResult.Yes) return;
|
||||
|
|
@ -186,9 +177,9 @@ public partial class MainWindow : Window
|
|||
{
|
||||
MessageBox.Show(
|
||||
asked == 0
|
||||
? Properties.Strings.StopTeams_NoneResponded
|
||||
: string.Format(Properties.Strings.StopTeams_AskedFormat, asked),
|
||||
Properties.Strings.StopTeams_Title,
|
||||
? "No Teams windows responded to close."
|
||||
: $"Sent close to {asked} Teams window(s); some may still be exiting.",
|
||||
"TeamsISO — Stop Teams",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
}
|
||||
|
|
|
|||
36
src/TeamsISO.App/Properties/Strings.Designer.cs
generated
36
src/TeamsISO.App/Properties/Strings.Designer.cs
generated
|
|
@ -1,36 +0,0 @@
|
|||
// Hand-written strongly-typed accessor for Properties/Strings.resx. Kept
|
||||
// out of Visual Studio's "ResXFileCodeGenerator" auto-regeneration loop
|
||||
// so the .csproj stays simple and the file doesn't churn on every save.
|
||||
// If you add a key in Strings.resx, add a matching property here.
|
||||
|
||||
// The compiler treats `*.Designer.cs` as auto-generated and refuses
|
||||
// nullable annotations without an explicit directive — opt in.
|
||||
#nullable enable
|
||||
|
||||
using System.Globalization;
|
||||
using System.Resources;
|
||||
|
||||
namespace TeamsISO.App.Properties;
|
||||
|
||||
internal static class Strings
|
||||
{
|
||||
private static readonly ResourceManager ResourceManager = new(
|
||||
baseName: "TeamsISO.App.Properties.Strings",
|
||||
assembly: typeof(Strings).Assembly);
|
||||
|
||||
public static CultureInfo? Culture { get; set; }
|
||||
|
||||
private static string Get(string key) =>
|
||||
ResourceManager.GetString(key, Culture) ?? string.Empty;
|
||||
|
||||
public static string HideShowTeams_Title => Get(nameof(HideShowTeams_Title));
|
||||
public static string HideShowTeams_NotRunning_Message => Get(nameof(HideShowTeams_NotRunning_Message));
|
||||
|
||||
public static string LaunchTeams_Title => Get(nameof(LaunchTeams_Title));
|
||||
public static string LaunchTeams_Failed_MessageFormat => Get(nameof(LaunchTeams_Failed_MessageFormat));
|
||||
|
||||
public static string StopTeams_Title => Get(nameof(StopTeams_Title));
|
||||
public static string StopTeams_Confirm_Message => Get(nameof(StopTeams_Confirm_Message));
|
||||
public static string StopTeams_NoneResponded => Get(nameof(StopTeams_NoneResponded));
|
||||
public static string StopTeams_AskedFormat => Get(nameof(StopTeams_AskedFormat));
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
User-facing English strings shown by MainWindow's MessageBox prompts.
|
||||
Pulled out of code-behind so a future localizer has a single seam to
|
||||
translate. Strings.Designer.cs is a hand-rolled accessor backed by
|
||||
ResourceManager — no Visual-Studio auto-regeneration needed.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
|
||||
<resheader name="version"><value>2.0</value></resheader>
|
||||
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
|
||||
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
|
||||
|
||||
<data name="HideShowTeams_Title" xml:space="preserve">
|
||||
<value>TeamsISO — Hide / show Teams</value>
|
||||
</data>
|
||||
<data name="HideShowTeams_NotRunning_Message" xml:space="preserve">
|
||||
<value>Microsoft Teams isn't running. Click the camera icon above to launch it first.</value>
|
||||
</data>
|
||||
|
||||
<data name="LaunchTeams_Title" xml:space="preserve">
|
||||
<value>TeamsISO — Launch Teams</value>
|
||||
</data>
|
||||
<data name="LaunchTeams_Failed_MessageFormat" xml:space="preserve">
|
||||
<value>Could not launch Microsoft Teams.
|
||||
|
||||
{0}</value>
|
||||
<comment>{0} = error string from TeamsLauncher.TryLaunch.</comment>
|
||||
</data>
|
||||
|
||||
<data name="StopTeams_Title" xml:space="preserve">
|
||||
<value>TeamsISO — Stop Teams</value>
|
||||
</data>
|
||||
<data name="StopTeams_Confirm_Message" xml:space="preserve">
|
||||
<value>Microsoft Teams is currently running.
|
||||
|
||||
Close all Teams windows now?</value>
|
||||
</data>
|
||||
<data name="StopTeams_NoneResponded" xml:space="preserve">
|
||||
<value>No Teams windows responded to close.</value>
|
||||
</data>
|
||||
<data name="StopTeams_AskedFormat" xml:space="preserve">
|
||||
<value>Sent close to {0} Teams window(s); some may still be exiting.</value>
|
||||
<comment>{0} = number of windows the launcher asked to close.</comment>
|
||||
</data>
|
||||
</root>
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
namespace TeamsISO.App.Services;
|
||||
|
||||
// GET / — server info + endpoint catalogue. Returned as the JSON
|
||||
// homepage when a Companion / Stream Deck plugin first probes the
|
||||
// surface; humans see it via curl http://127.0.0.1:9755/.
|
||||
public sealed partial class ControlSurfaceServer
|
||||
{
|
||||
private object GetServerInfo()
|
||||
{
|
||||
// Best-effort engine snapshot — wrapped in TryRead so a transient
|
||||
// controller error doesn't 500 the homepage poll.
|
||||
var settings = TryRead(() => _controller.GlobalSettings);
|
||||
var groups = TryRead(() => _controller.GroupSettings);
|
||||
return new
|
||||
{
|
||||
product = "TeamsISO",
|
||||
version = typeof(ControlSurfaceServer).Assembly.GetName().Version?.ToString() ?? "unknown",
|
||||
engine = new
|
||||
{
|
||||
framerateHz = settings?.FramerateHz,
|
||||
targetResolution = settings?.Resolution.ToString(),
|
||||
aspectMode = settings?.Aspect.ToString(),
|
||||
audioMode = settings?.Audio.ToString(),
|
||||
discoveryGroups = groups?.DiscoveryGroups,
|
||||
outputGroups = groups?.OutputGroups,
|
||||
},
|
||||
endpoints = new[]
|
||||
{
|
||||
"GET / (this)",
|
||||
"GET /ui (HTML control panel)",
|
||||
"GET /participants",
|
||||
"GET /ws (WebSocket: live participant snapshots)",
|
||||
"POST /participants/{id}/iso",
|
||||
"POST /participants/iso (body: displayName + enabled)",
|
||||
"POST /presets/{name}/apply",
|
||||
"POST /presets/refresh-discovery",
|
||||
"POST /presets/stop-all",
|
||||
"POST /teams/mute, /camera, /leave, /share, /raise-hand",
|
||||
"POST /notes (body: text)",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static T? TryRead<T>(Func<T> reader) where T : class
|
||||
{
|
||||
try { return reader(); }
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
using System.Collections.Specialized;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
// /notes/* route handlers — append-only operator show-notes file.
|
||||
//
|
||||
// POST /notes (body: { "text": "..." }) → AppendNote
|
||||
public sealed partial class ControlSurfaceServer
|
||||
{
|
||||
private object AppendNote(JsonElement body, NameValueCollection query)
|
||||
{
|
||||
var text = TryGetString(body, query, "text");
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return new { ok = false, error = "text required" };
|
||||
var ok = NotesService.Append(text);
|
||||
return new { ok, action = "note", path = NotesService.TodayPath };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
using System.Collections.Specialized;
|
||||
using System.Text.Json;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
// /participants/* route handlers. Anything that reads or writes
|
||||
// participant + per-pipeline state lives here.
|
||||
//
|
||||
// GET /participants → GetParticipants
|
||||
// POST /participants/{id}/iso → ToggleIsoByIdAsync
|
||||
// POST /participants/iso → ToggleIsoByNameAsync
|
||||
// POST /participants/{id}/override → SetIsoOverrideByIdAsync
|
||||
// DELETE /participants/{id}/override → ClearIsoOverrideByIdAsync
|
||||
public sealed partial class ControlSurfaceServer
|
||||
{
|
||||
private object GetParticipants()
|
||||
{
|
||||
var vm = _viewModel();
|
||||
if (vm is null) return new { participants = Array.Empty<object>() };
|
||||
// Synchronously snapshot on the UI thread — ObservableCollection
|
||||
// isn't safe to enumerate from this request handler's thread-pool
|
||||
// task, and the ParticipantViewModel property reads chase
|
||||
// data-binding state.
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher is null) return new { participants = Array.Empty<object>() };
|
||||
var globals = _controller.GlobalSettings;
|
||||
var list = dispatcher.Invoke(() => vm.Participants.Select(p => {
|
||||
var ovr = _controller.GetIsoOverride(p.Id);
|
||||
return (object)new
|
||||
{
|
||||
id = p.Id,
|
||||
displayName = p.DisplayName,
|
||||
isOnline = p.IsOnline,
|
||||
isEnabled = p.IsEnabled,
|
||||
customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName,
|
||||
stateLabel = p.StateLabel,
|
||||
// Effective settings = override if set, else globals. The
|
||||
// web UI uses this to show the current per-row values
|
||||
// without a separate round-trip to /global.
|
||||
effective = new
|
||||
{
|
||||
framerate = (ovr ?? globals).Framerate.ToString(),
|
||||
resolution = (ovr ?? globals).Resolution.ToString(),
|
||||
aspect = (ovr ?? globals).Aspect.ToString(),
|
||||
audio = (ovr ?? globals).Audio.ToString(),
|
||||
isOverride = ovr is not null,
|
||||
},
|
||||
};
|
||||
}).ToArray());
|
||||
return new { participants = list, globals = new {
|
||||
framerate = globals.Framerate.ToString(),
|
||||
resolution = globals.Resolution.ToString(),
|
||||
aspect = globals.Aspect.ToString(),
|
||||
audio = globals.Audio.ToString(),
|
||||
} };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /participants/{id}/override — set or replace the per-pipeline
|
||||
/// override. Body fields: framerate (enum string), resolution (enum
|
||||
/// string), aspect (enum string), audio (enum string). All fields are
|
||||
/// optional; missing fields fall back to the current global value.
|
||||
/// </summary>
|
||||
private async Task<object> SetIsoOverrideByIdAsync(string path, JsonElement body)
|
||||
{
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override")
|
||||
return new { ok = false, error = "expected /participants/{id}/override" };
|
||||
if (!Guid.TryParse(segments[1], out var id))
|
||||
return new { ok = false, error = "invalid id" };
|
||||
|
||||
var g = _controller.GlobalSettings;
|
||||
var framerate = TryParseEnum(body, "framerate", g.Framerate);
|
||||
var resolution = TryParseEnum(body, "resolution", g.Resolution);
|
||||
var aspect = TryParseEnum(body, "aspect", g.Aspect);
|
||||
var audio = TryParseEnum(body, "audio", g.Audio);
|
||||
var ovr = new FrameProcessingSettings(framerate, resolution, aspect, audio);
|
||||
await _controller.SetIsoOverrideAsync(id, ovr, CancellationToken.None);
|
||||
return new { ok = true, id, effective = new
|
||||
{
|
||||
framerate = ovr.Framerate.ToString(),
|
||||
resolution = ovr.Resolution.ToString(),
|
||||
aspect = ovr.Aspect.ToString(),
|
||||
audio = ovr.Audio.ToString(),
|
||||
isOverride = true,
|
||||
} };
|
||||
}
|
||||
|
||||
/// <summary>DELETE /participants/{id}/override — pipeline reverts to global settings.</summary>
|
||||
private async Task<object> ClearIsoOverrideByIdAsync(string path)
|
||||
{
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override")
|
||||
return new { ok = false, error = "expected /participants/{id}/override" };
|
||||
if (!Guid.TryParse(segments[1], out var id))
|
||||
return new { ok = false, error = "invalid id" };
|
||||
await _controller.SetIsoOverrideAsync(id, null, CancellationToken.None);
|
||||
return new { ok = true, id, cleared = true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse an enum value from a JSON body, falling back to a default when
|
||||
/// the field is missing or the value doesn't match any enum member.
|
||||
/// Case-insensitive. Used by SetIsoOverrideByIdAsync for the four
|
||||
/// FrameProcessingSettings enums.
|
||||
/// </summary>
|
||||
private static TEnum TryParseEnum<TEnum>(JsonElement body, string field, TEnum fallback)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (body.ValueKind != JsonValueKind.Object) return fallback;
|
||||
if (!body.TryGetProperty(field, out var prop)) return fallback;
|
||||
if (prop.ValueKind != JsonValueKind.String) return fallback;
|
||||
var s = prop.GetString();
|
||||
if (string.IsNullOrEmpty(s)) return fallback;
|
||||
return Enum.TryParse<TEnum>(s, ignoreCase: true, out var result) ? result : fallback;
|
||||
}
|
||||
|
||||
private async Task<object> ToggleIsoByIdAsync(string path, JsonElement body, NameValueCollection query)
|
||||
{
|
||||
// path = /participants/<guid>/iso
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "iso")
|
||||
return NotFound();
|
||||
if (!Guid.TryParse(segments[1], out var id))
|
||||
return new { ok = false, error = "invalid id" };
|
||||
return await ToggleByIdAsync(id, body, query);
|
||||
}
|
||||
|
||||
private async Task<object> ToggleIsoByNameAsync(JsonElement body, NameValueCollection query)
|
||||
{
|
||||
var displayName = TryGetString(body, query, "displayName");
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
return new { ok = false, error = "displayName required" };
|
||||
var vm = _viewModel();
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (vm is null || dispatcher is null)
|
||||
return new { ok = false, error = "view-model not ready" };
|
||||
var p = await dispatcher.InvokeAsync(() => vm.Participants.FirstOrDefault(x =>
|
||||
string.Equals(x.DisplayName, displayName, StringComparison.OrdinalIgnoreCase)));
|
||||
if (p is null) return new { ok = false, error = "participant not found", displayName };
|
||||
return await ToggleByIdAsync(p.Id, body, query);
|
||||
}
|
||||
|
||||
private async Task<object> ToggleByIdAsync(Guid id, JsonElement body, NameValueCollection query)
|
||||
{
|
||||
var enabled = TryGetBool(body, query, "enabled");
|
||||
var customName = TryGetString(body, query, "customName");
|
||||
var vm = _viewModel();
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (vm is null || dispatcher is null)
|
||||
return new { ok = false, error = "view-model not ready" };
|
||||
|
||||
// Look up the VM and snapshot its current state on the UI thread —
|
||||
// ObservableCollection enumeration and view-model property reads
|
||||
// both need to happen there.
|
||||
var lookup = await dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var p = vm.Participants.FirstOrDefault(x => x.Id == id);
|
||||
return p is null
|
||||
? null
|
||||
: new { Pvm = p, p.IsEnabled, p.CustomName };
|
||||
});
|
||||
if (lookup is null) return new { ok = false, error = "participant not found", id };
|
||||
|
||||
var target = enabled ?? !lookup.IsEnabled;
|
||||
var nameToUse = !string.IsNullOrEmpty(customName) ? customName : lookup.CustomName;
|
||||
|
||||
if (target == lookup.IsEnabled && string.IsNullOrEmpty(customName))
|
||||
return new { ok = true, id, enabled = lookup.IsEnabled, action = "noop" };
|
||||
|
||||
// Apply CustomName change first (if any) on the UI thread so a
|
||||
// subsequent EnableIsoAsync sees the new name.
|
||||
if (!string.IsNullOrEmpty(customName))
|
||||
await dispatcher.InvokeAsync(() => lookup.Pvm.CustomName = customName);
|
||||
|
||||
if (target)
|
||||
{
|
||||
await _controller.EnableIsoAsync(id,
|
||||
string.IsNullOrWhiteSpace(nameToUse) ? null : nameToUse,
|
||||
CancellationToken.None);
|
||||
await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = true);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _controller.DisableIsoAsync(id, CancellationToken.None);
|
||||
await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = false);
|
||||
}
|
||||
return new { ok = true, id, enabled = target };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
namespace TeamsISO.App.Services;
|
||||
|
||||
// /presets/* route handlers.
|
||||
//
|
||||
// POST /presets/refresh-discovery → RefreshDiscovery
|
||||
// POST /presets/stop-all → StopAllAsync
|
||||
// POST /presets/{name}/apply → ApplyPresetAsync
|
||||
public sealed partial class ControlSurfaceServer
|
||||
{
|
||||
private object RefreshDiscovery()
|
||||
{
|
||||
_controller.RefreshDiscovery();
|
||||
return new { ok = true, action = "refresh-discovery" };
|
||||
}
|
||||
|
||||
private async Task<object> StopAllAsync()
|
||||
{
|
||||
var vm = _viewModel();
|
||||
if (vm is null) return new { ok = false, error = "view-model not ready" };
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher is null) return new { ok = false, error = "dispatcher not ready" };
|
||||
|
||||
// Snapshot the enabled set on the UI thread — ObservableCollection
|
||||
// isn't safe to enumerate from a thread-pool task, and reading the
|
||||
// IsEnabled property indirectly walks the data-binding system.
|
||||
var enabled = await dispatcher.InvokeAsync(() =>
|
||||
vm.Participants.Where(p => p.IsEnabled).ToArray());
|
||||
|
||||
foreach (var p in enabled)
|
||||
{
|
||||
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
|
||||
catch { /* defensive */ }
|
||||
await dispatcher.InvokeAsync(() => p.IsEnabled = false);
|
||||
}
|
||||
return new { ok = true, action = "stop-all", count = enabled.Length };
|
||||
}
|
||||
|
||||
private async Task<object> ApplyPresetAsync(string path)
|
||||
{
|
||||
// path = /presets/<name>/apply
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length != 3 || segments[0] != "presets" || segments[2] != "apply")
|
||||
return NotFound();
|
||||
var name = Uri.UnescapeDataString(segments[1]);
|
||||
var preset = OperatorPresetStore.Find(name);
|
||||
if (preset is null) return new { ok = false, error = "preset not found", name };
|
||||
|
||||
var vm = _viewModel();
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (vm is null || dispatcher is null)
|
||||
return new { ok = false, error = "view-model not ready" };
|
||||
|
||||
// Snapshot participants on the UI thread — ObservableCollection
|
||||
// enumeration and ParticipantViewModel state reads both need to
|
||||
// happen there. PresetApplier marshals subsequent property writes
|
||||
// via the dispatcher.
|
||||
var snapshot = await dispatcher.InvokeAsync(() => vm.Participants.ToList());
|
||||
|
||||
var result = await PresetApplier.ApplyAsync(
|
||||
preset, snapshot, _controller, dispatcher);
|
||||
|
||||
return new
|
||||
{
|
||||
ok = true,
|
||||
name = preset.Name,
|
||||
matched = result.Matched,
|
||||
changed = result.Changed,
|
||||
skipped = result.Skipped,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
namespace TeamsISO.App.Services;
|
||||
|
||||
// /teams/* route handlers — UIAutomation-driven in-call controls.
|
||||
//
|
||||
// POST /teams/mute → InvokeTeams(ToggleMute, "mute")
|
||||
// POST /teams/camera → InvokeTeams(ToggleCamera, "camera")
|
||||
// POST /teams/leave → InvokeTeams(LeaveCall, "leave")
|
||||
// POST /teams/share → InvokeTeams(OpenShareTray, "share")
|
||||
// POST /teams/raise-hand → InvokeTeams(ToggleRaiseHand, "raise-hand")
|
||||
public sealed partial class ControlSurfaceServer
|
||||
{
|
||||
private object InvokeTeams(Func<TeamsControlBridge.InvokeResult> invoke, string action)
|
||||
{
|
||||
var result = invoke();
|
||||
return new
|
||||
{
|
||||
ok = result == TeamsControlBridge.InvokeResult.Invoked,
|
||||
action,
|
||||
result = result.ToString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
// GET /participants/{id}/thumbnail.bmp — small BMP of the latest
|
||||
// processed frame. Used by the embedded HTML control panel for live
|
||||
// preview tiles with a cache-busting query param at ~1Hz.
|
||||
//
|
||||
// BMP (not JPEG) because the System.Windows.Media.Imaging path NREs on
|
||||
// non-UI threads and marshaling 1Hz JPEG encodes through the WPF
|
||||
// dispatcher hurts responsiveness. ~40KB at 192-wide compresses fine
|
||||
// over LAN gzip.
|
||||
public sealed partial class ControlSurfaceServer
|
||||
{
|
||||
/// <summary>
|
||||
/// Encode the engine's most recent processed frame for the given
|
||||
/// participant as a BMP. Returns null when no pipeline is running for
|
||||
/// this participant or the frame can't be encoded.
|
||||
/// </summary>
|
||||
private byte[]? TryEncodeThumbnailJpeg(Guid participantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var frame = _controller.GetLatestProcessedFrame(participantId);
|
||||
if (frame is null)
|
||||
{
|
||||
_logger?.LogDebug("Thumbnail: no frame for {Id}", participantId);
|
||||
return null;
|
||||
}
|
||||
if (frame.Pixels.Length == 0)
|
||||
{
|
||||
_logger?.LogDebug("Thumbnail: empty pixel buffer for {Id} ({W}x{H})", participantId, frame.Width, frame.Height);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Nearest-neighbor downscale to ~192 wide. Source is BGRA32.
|
||||
const int targetWidth = 192;
|
||||
var ratio = (double)frame.Height / frame.Width;
|
||||
var targetHeight = Math.Max(1, (int)(targetWidth * ratio));
|
||||
return EncodeBmpDownscaled(frame.Pixels.Span, frame.Width, frame.Height, targetWidth, targetHeight);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Thumbnail encode failed for {Id}", participantId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nearest-neighbor downscale of a BGRA32 source then write a 32-bpp
|
||||
/// top-down BMP. Returns the full BMP file bytes including the 14-byte
|
||||
/// BMP header and 40-byte BITMAPINFOHEADER. Browsers handle it directly
|
||||
/// (no JPEG / PNG codec needed in-process).
|
||||
/// </summary>
|
||||
private static byte[] EncodeBmpDownscaled(ReadOnlySpan<byte> srcBgra, int srcW, int srcH, int dstW, int dstH)
|
||||
{
|
||||
var pixelBytes = dstW * dstH * 4;
|
||||
var bmp = new byte[54 + pixelBytes];
|
||||
|
||||
// BMP file header (14 bytes): 'BM', file size, reserved, pixel offset.
|
||||
bmp[0] = (byte)'B'; bmp[1] = (byte)'M';
|
||||
WriteUInt32LE(bmp, 2, (uint)bmp.Length);
|
||||
WriteUInt32LE(bmp, 6, 0);
|
||||
WriteUInt32LE(bmp, 10, 54);
|
||||
|
||||
// DIB header (BITMAPINFOHEADER, 40 bytes): negative height = top-down.
|
||||
WriteUInt32LE(bmp, 14, 40);
|
||||
WriteInt32LE(bmp, 18, dstW);
|
||||
WriteInt32LE(bmp, 22, -dstH);
|
||||
WriteUInt16LE(bmp, 26, 1);
|
||||
WriteUInt16LE(bmp, 28, 32);
|
||||
WriteUInt32LE(bmp, 30, 0);
|
||||
WriteUInt32LE(bmp, 34, (uint)pixelBytes);
|
||||
WriteUInt32LE(bmp, 38, 2835);
|
||||
WriteUInt32LE(bmp, 42, 2835);
|
||||
WriteUInt32LE(bmp, 46, 0);
|
||||
WriteUInt32LE(bmp, 50, 0);
|
||||
|
||||
// Nearest-neighbor downscale, top-down (matches negative-height header).
|
||||
var srcStride = srcW * 4;
|
||||
var dstOffset = 54;
|
||||
for (var dy = 0; dy < dstH; dy++)
|
||||
{
|
||||
var sy = (int)((long)dy * srcH / dstH);
|
||||
for (var dx = 0; dx < dstW; dx++)
|
||||
{
|
||||
var sx = (int)((long)dx * srcW / dstW);
|
||||
var si = sy * srcStride + sx * 4;
|
||||
bmp[dstOffset++] = srcBgra[si];
|
||||
bmp[dstOffset++] = srcBgra[si + 1];
|
||||
bmp[dstOffset++] = srcBgra[si + 2];
|
||||
bmp[dstOffset++] = srcBgra[si + 3];
|
||||
}
|
||||
}
|
||||
return bmp;
|
||||
}
|
||||
|
||||
private static void WriteUInt16LE(byte[] buf, int offset, ushort value)
|
||||
{
|
||||
buf[offset] = (byte)(value & 0xFF);
|
||||
buf[offset + 1] = (byte)((value >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
private static void WriteInt32LE(byte[] buf, int offset, int value) => WriteUInt32LE(buf, offset, (uint)value);
|
||||
|
||||
private static void WriteUInt32LE(byte[] buf, int offset, uint value)
|
||||
{
|
||||
buf[offset] = (byte)(value & 0xFF);
|
||||
buf[offset + 1] = (byte)((value >> 8) & 0xFF);
|
||||
buf[offset + 2] = (byte)((value >> 16) & 0xFF);
|
||||
buf[offset + 3] = (byte)((value >> 24) & 0xFF);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
namespace TeamsISO.App.Services;
|
||||
|
||||
// /topology/* route handlers — read + apply / restore the machine NDI
|
||||
// access-manager config so the operator can flip transcoder topology
|
||||
// without leaving the web UI.
|
||||
//
|
||||
// GET /topology → GetTopology
|
||||
// POST /topology/apply → ApplyTopologyAsync
|
||||
// POST /topology/restore → RestoreTopologyAsync
|
||||
public sealed partial class ControlSurfaceServer
|
||||
{
|
||||
/// <summary>
|
||||
/// Report the current NDI machine topology. "mode" is "hidden" when
|
||||
/// local senders are confined to the private group (raw Teams sources
|
||||
/// invisible to the rest of the LAN), "public" otherwise. Reads the
|
||||
/// machine NDI config file directly — no caching, so the result
|
||||
/// reflects whatever state the file is in right now (including
|
||||
/// manual edits).
|
||||
/// </summary>
|
||||
private object GetTopology()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (mode, sends, recvs) = NdiAccessManagerConfig.ReadCurrent();
|
||||
return new
|
||||
{
|
||||
mode,
|
||||
senders = sends,
|
||||
receivers = recvs,
|
||||
configPath = NdiAccessManagerConfig.ConfigPath,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new { ok = false, error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply the transcoder topology: machine senders → <c>teamsiso-input</c>,
|
||||
/// receivers → <c>public + teamsiso-input</c>; engine groups updated to
|
||||
/// match (discover from teamsiso-input, broadcast on public). Operator
|
||||
/// MUST restart Teams afterward for it to read the new NDI config.
|
||||
/// </summary>
|
||||
private async Task<object> ApplyTopologyAsync()
|
||||
{
|
||||
var result = NdiAccessManagerConfig.ApplyTranscoderTopology();
|
||||
if (!result.Success)
|
||||
{
|
||||
return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath };
|
||||
}
|
||||
// Mirror what the WPF settings VM does so the engine groups +
|
||||
// machine config stay in lockstep.
|
||||
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings(
|
||||
DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup,
|
||||
OutputGroups: "public");
|
||||
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
|
||||
return new
|
||||
{
|
||||
ok = true,
|
||||
mode = "hidden",
|
||||
backupPath = result.BackupPath,
|
||||
note = "Restart Microsoft Teams for the new NDI config to take effect there.",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore the machine NDI defaults: senders + receivers both on
|
||||
/// <c>public</c>. Engine groups go back to null/defaults too. Operator
|
||||
/// must restart Teams for it to broadcast on public again.
|
||||
/// </summary>
|
||||
private async Task<object> RestoreTopologyAsync()
|
||||
{
|
||||
var result = NdiAccessManagerConfig.RestoreDefaults();
|
||||
if (!result.Success)
|
||||
{
|
||||
return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath };
|
||||
}
|
||||
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings(
|
||||
DiscoveryGroups: null,
|
||||
OutputGroups: null);
|
||||
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
|
||||
return new
|
||||
{
|
||||
ok = true,
|
||||
mode = "public",
|
||||
backupPath = result.BackupPath,
|
||||
note = "Restart Microsoft Teams for the new NDI config to take effect there.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
// GET /ws — upgrades to WebSocket and pushes participant-list snapshots
|
||||
// at 4Hz with diffing (no push when nothing changed). Lets controllers
|
||||
// stay live-synced without polling /participants.
|
||||
//
|
||||
// Lifecycle:
|
||||
// • Server's accept loop upgrades the request and hands the socket here.
|
||||
// • HandleWebSocketAsync owns the connection until the client closes.
|
||||
// • The Start() method wires a 4Hz DispatcherTimer that calls
|
||||
// PushSnapshotIfChangedAsync to fan out to every connected client.
|
||||
public sealed partial class ControlSurfaceServer
|
||||
{
|
||||
/// <summary>
|
||||
/// Owns a single client connection until it closes. Sends an immediate
|
||||
/// snapshot on connect (so the client doesn't have to wait up to 250ms
|
||||
/// for the next push tick), then sits in a receive loop draining any
|
||||
/// incoming text — we ignore client→server messages for v1 since all
|
||||
/// commands are REST. The receive loop is the canonical way to detect
|
||||
/// graceful close: when WebSocket.ReceiveAsync returns CloseReceived,
|
||||
/// we close back and remove the client.
|
||||
/// </summary>
|
||||
private async Task HandleWebSocketAsync(WebSocket ws)
|
||||
{
|
||||
var clientId = Guid.NewGuid();
|
||||
_clients[clientId] = ws;
|
||||
_logger?.LogInformation("WebSocket client {Id} connected.", clientId);
|
||||
|
||||
try
|
||||
{
|
||||
// Initial snapshot — fetch synchronously on the UI thread so the
|
||||
// ObservableCollection isn't enumerated cross-thread.
|
||||
await SendAsync(ws, await GetSnapshotJsonAsync());
|
||||
|
||||
var buf = new byte[1024];
|
||||
while (ws.State == WebSocketState.Open)
|
||||
{
|
||||
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buf), CancellationToken.None);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
|
||||
break;
|
||||
}
|
||||
// Ignore any client-sent messages for now; future bidirectional
|
||||
// commands could route through here.
|
||||
}
|
||||
}
|
||||
catch (WebSocketException) { /* client crashed; drop */ }
|
||||
catch (ObjectDisposedException) { /* Stop() aborted us */ }
|
||||
catch (OperationCanceledException) { /* server shutting down */ }
|
||||
finally
|
||||
{
|
||||
_clients.TryRemove(clientId, out _);
|
||||
// Don't double-dispose: Stop() already disposed the WebSocket if
|
||||
// it's tearing us down. Aborting an already-disposed socket is a
|
||||
// no-op throw which we catch + ignore.
|
||||
try { ws.Dispose(); } catch { /* defensive */ }
|
||||
_logger?.LogInformation("WebSocket client {Id} disconnected.", clientId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatcher-tick handler. Reads the current participants snapshot,
|
||||
/// and if it differs from what we last pushed, broadcasts the new
|
||||
/// JSON to every connected client. Diffing on the JSON string is
|
||||
/// cheap and saves wire bytes when nothing's actually changing —
|
||||
/// typical operator workflow has long periods of no state churn
|
||||
/// between meetings.
|
||||
/// </summary>
|
||||
private async Task PushSnapshotIfChangedAsync()
|
||||
{
|
||||
if (_clients.IsEmpty) return;
|
||||
|
||||
string snapshot;
|
||||
try { snapshot = await GetSnapshotJsonAsync(); }
|
||||
catch { return; }
|
||||
|
||||
if (snapshot == _lastPushedSnapshot) return;
|
||||
_lastPushedSnapshot = snapshot;
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(snapshot);
|
||||
foreach (var (id, ws) in _clients.ToArray())
|
||||
{
|
||||
if (ws.State != WebSocketState.Open)
|
||||
{
|
||||
_clients.TryRemove(id, out _);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
await ws.SendAsync(
|
||||
new ArraySegment<byte>(bytes),
|
||||
WebSocketMessageType.Text,
|
||||
endOfMessage: true,
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_clients.TryRemove(id, out _);
|
||||
try { ws.Dispose(); } catch { /* defensive */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendAsync(WebSocket ws, string text)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(text);
|
||||
await ws.SendAsync(
|
||||
new ArraySegment<byte>(bytes),
|
||||
WebSocketMessageType.Text,
|
||||
endOfMessage: true,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the same payload as <c>GET /participants</c> but as a JSON
|
||||
/// string for direct WebSocket Send. Reads the ObservableCollection
|
||||
/// via the UI dispatcher because WPF's ObservableCollection isn't
|
||||
/// thread-safe to enumerate from a non-UI thread.
|
||||
/// </summary>
|
||||
private async Task<string> GetSnapshotJsonAsync()
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
var participants = dispatcher is null
|
||||
? Array.Empty<object>()
|
||||
: await dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var vm = _viewModel();
|
||||
if (vm is null) return Array.Empty<object>();
|
||||
return vm.Participants.Select(p => (object)new
|
||||
{
|
||||
id = p.Id,
|
||||
displayName = p.DisplayName,
|
||||
isOnline = p.IsOnline,
|
||||
isEnabled = p.IsEnabled,
|
||||
customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName,
|
||||
stateLabel = p.StateLabel,
|
||||
}).ToArray();
|
||||
});
|
||||
return JsonSerializer.Serialize(new { type = "participants", participants }, JsonOpts);
|
||||
}
|
||||
}
|
||||
|
|
@ -44,10 +44,7 @@ namespace TeamsISO.App.Services;
|
|||
/// either via JSON body or via query string (?enabled=true&customName=Host).
|
||||
/// This is friendly to Companion's "URL with query string" mode.
|
||||
/// </summary>
|
||||
// Endpoint handlers live in partial files under Services/ControlSurface/Endpoints/.
|
||||
// This file holds the host: listener lifecycle, accept loop, dispatch table,
|
||||
// response helpers, and the WebSocket push loop.
|
||||
public sealed partial class ControlSurfaceServer : IAsyncDisposable
|
||||
public sealed class ControlSurfaceServer : IAsyncDisposable
|
||||
{
|
||||
public const int DefaultPort = 9755;
|
||||
|
||||
|
|
@ -343,16 +340,680 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
|
|||
}
|
||||
|
||||
// ─── handlers ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Endpoint bodies live in Services/ControlSurface/Endpoints/*.cs as
|
||||
// partials of this class. See HomeEndpoints, ParticipantsEndpoints,
|
||||
// PresetsEndpoints, TeamsEndpoints, TopologyEndpoints, NotesEndpoints,
|
||||
// and ThumbnailEndpoint. The WebSocket push surface is at
|
||||
// Services/ControlSurface/WebSocketHub.cs.
|
||||
|
||||
private object GetServerInfo()
|
||||
{
|
||||
// Best-effort engine snapshot — wrapped in try/catch so a transient
|
||||
// controller error doesn't 500 the homepage poll.
|
||||
var settings = TryRead(() => _controller.GlobalSettings);
|
||||
var groups = TryRead(() => _controller.GroupSettings);
|
||||
return new
|
||||
{
|
||||
product = "TeamsISO",
|
||||
version = typeof(ControlSurfaceServer).Assembly.GetName().Version?.ToString() ?? "unknown",
|
||||
engine = new
|
||||
{
|
||||
framerateHz = settings?.FramerateHz,
|
||||
targetResolution = settings?.Resolution.ToString(),
|
||||
aspectMode = settings?.Aspect.ToString(),
|
||||
audioMode = settings?.Audio.ToString(),
|
||||
discoveryGroups = groups?.DiscoveryGroups,
|
||||
outputGroups = groups?.OutputGroups,
|
||||
},
|
||||
// recording status fields removed alongside the rest of the recording surface.
|
||||
endpoints = new[]
|
||||
{
|
||||
"GET / (this)",
|
||||
"GET /ui (HTML control panel)",
|
||||
"GET /participants",
|
||||
"GET /ws (WebSocket: live participant snapshots)",
|
||||
"POST /participants/{id}/iso",
|
||||
"POST /participants/iso (body: displayName + enabled)",
|
||||
"POST /presets/{name}/apply",
|
||||
"POST /presets/refresh-discovery",
|
||||
"POST /presets/stop-all",
|
||||
"POST /teams/mute, /camera, /leave, /share, /raise-hand",
|
||||
"POST /notes (body: text)",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static T? TryRead<T>(Func<T> reader) where T : class
|
||||
{
|
||||
try { return reader(); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private object GetParticipants()
|
||||
{
|
||||
var vm = _viewModel();
|
||||
if (vm is null) return new { participants = Array.Empty<object>() };
|
||||
// Synchronously snapshot on the UI thread — ObservableCollection isn't safe
|
||||
// to enumerate from this request handler's thread-pool task, and the
|
||||
// ParticipantViewModel property reads chase data-binding state.
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher is null) return new { participants = Array.Empty<object>() };
|
||||
var globals = _controller.GlobalSettings;
|
||||
var list = dispatcher.Invoke(() => vm.Participants.Select(p => {
|
||||
var ovr = _controller.GetIsoOverride(p.Id);
|
||||
return (object)new
|
||||
{
|
||||
id = p.Id,
|
||||
displayName = p.DisplayName,
|
||||
isOnline = p.IsOnline,
|
||||
isEnabled = p.IsEnabled,
|
||||
customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName,
|
||||
stateLabel = p.StateLabel,
|
||||
// Effective settings = override if set, else globals. The web
|
||||
// UI uses this to show the current per-row values without a
|
||||
// separate round-trip to /global.
|
||||
effective = new
|
||||
{
|
||||
framerate = (ovr ?? globals).Framerate.ToString(),
|
||||
resolution = (ovr ?? globals).Resolution.ToString(),
|
||||
aspect = (ovr ?? globals).Aspect.ToString(),
|
||||
audio = (ovr ?? globals).Audio.ToString(),
|
||||
isOverride = ovr is not null,
|
||||
},
|
||||
};
|
||||
}).ToArray());
|
||||
return new { participants = list, globals = new {
|
||||
framerate = globals.Framerate.ToString(),
|
||||
resolution = globals.Resolution.ToString(),
|
||||
aspect = globals.Aspect.ToString(),
|
||||
audio = globals.Audio.ToString(),
|
||||
} };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /participants/{id}/override — set or replace the per-pipeline
|
||||
/// override. Body fields: framerate (enum string), resolution (enum
|
||||
/// string), aspect (enum string), audio (enum string). All fields are
|
||||
/// optional; missing fields fall back to the current global value.
|
||||
/// </summary>
|
||||
private async Task<object> SetIsoOverrideByIdAsync(string path, JsonElement body)
|
||||
{
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override")
|
||||
return new { ok = false, error = "expected /participants/{id}/override" };
|
||||
if (!Guid.TryParse(segments[1], out var id))
|
||||
return new { ok = false, error = "invalid id" };
|
||||
|
||||
var g = _controller.GlobalSettings;
|
||||
var framerate = TryParseEnum(body, "framerate", g.Framerate);
|
||||
var resolution = TryParseEnum(body, "resolution", g.Resolution);
|
||||
var aspect = TryParseEnum(body, "aspect", g.Aspect);
|
||||
var audio = TryParseEnum(body, "audio", g.Audio);
|
||||
var ovr = new FrameProcessingSettings(framerate, resolution, aspect, audio);
|
||||
await _controller.SetIsoOverrideAsync(id, ovr, CancellationToken.None);
|
||||
return new { ok = true, id, effective = new
|
||||
{
|
||||
framerate = ovr.Framerate.ToString(),
|
||||
resolution = ovr.Resolution.ToString(),
|
||||
aspect = ovr.Aspect.ToString(),
|
||||
audio = ovr.Audio.ToString(),
|
||||
isOverride = true,
|
||||
} };
|
||||
}
|
||||
|
||||
/// <summary>DELETE /participants/{id}/override — pipeline reverts to global settings.</summary>
|
||||
private async Task<object> ClearIsoOverrideByIdAsync(string path)
|
||||
{
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override")
|
||||
return new { ok = false, error = "expected /participants/{id}/override" };
|
||||
if (!Guid.TryParse(segments[1], out var id))
|
||||
return new { ok = false, error = "invalid id" };
|
||||
await _controller.SetIsoOverrideAsync(id, null, CancellationToken.None);
|
||||
return new { ok = true, id, cleared = true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse an enum value from a JSON body, falling back to a default when
|
||||
/// the field is missing or the value doesn't match any enum member.
|
||||
/// Case-insensitive. Used by SetIsoOverrideByIdAsync for the four
|
||||
/// FrameProcessingSettings enums.
|
||||
/// </summary>
|
||||
private static TEnum TryParseEnum<TEnum>(JsonElement body, string field, TEnum fallback)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (body.ValueKind != JsonValueKind.Object) return fallback;
|
||||
if (!body.TryGetProperty(field, out var prop)) return fallback;
|
||||
if (prop.ValueKind != JsonValueKind.String) return fallback;
|
||||
var s = prop.GetString();
|
||||
if (string.IsNullOrEmpty(s)) return fallback;
|
||||
return Enum.TryParse<TEnum>(s, ignoreCase: true, out var result) ? result : fallback;
|
||||
}
|
||||
|
||||
private object RefreshDiscovery()
|
||||
{
|
||||
_controller.RefreshDiscovery();
|
||||
return new { ok = true, action = "refresh-discovery" };
|
||||
}
|
||||
|
||||
private async Task<object> StopAllAsync()
|
||||
{
|
||||
var vm = _viewModel();
|
||||
if (vm is null) return new { ok = false, error = "view-model not ready" };
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher is null) return new { ok = false, error = "dispatcher not ready" };
|
||||
|
||||
// Snapshot the enabled set on the UI thread — ObservableCollection isn't
|
||||
// safe to enumerate from a thread-pool task, and reading the IsEnabled
|
||||
// property indirectly walks the data-binding system.
|
||||
var enabled = await dispatcher.InvokeAsync(() =>
|
||||
vm.Participants.Where(p => p.IsEnabled).ToArray());
|
||||
|
||||
foreach (var p in enabled)
|
||||
{
|
||||
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
|
||||
catch { /* defensive */ }
|
||||
await dispatcher.InvokeAsync(() => p.IsEnabled = false);
|
||||
}
|
||||
return new { ok = true, action = "stop-all", count = enabled.Length };
|
||||
}
|
||||
|
||||
private object InvokeTeams(Func<TeamsControlBridge.InvokeResult> invoke, string action)
|
||||
{
|
||||
var result = invoke();
|
||||
return new
|
||||
{
|
||||
ok = result == TeamsControlBridge.InvokeResult.Invoked,
|
||||
action,
|
||||
result = result.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
// SetRecording and DropMarker methods removed alongside the rest of the recording surface.
|
||||
|
||||
/// <summary>
|
||||
/// Report the current NDI machine topology. "mode" is "hidden" when local
|
||||
/// senders are confined to the private group (raw Teams sources invisible
|
||||
/// to the rest of the LAN), "public" otherwise. Reads the machine NDI
|
||||
/// config file directly — no caching, so the result reflects whatever
|
||||
/// state the file is in right now (including manual edits).
|
||||
/// </summary>
|
||||
private object GetTopology()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (mode, sends, recvs) = NdiAccessManagerConfig.ReadCurrent();
|
||||
return new
|
||||
{
|
||||
mode,
|
||||
senders = sends,
|
||||
receivers = recvs,
|
||||
configPath = NdiAccessManagerConfig.ConfigPath,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new { ok = false, error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply the transcoder topology: machine senders → <c>teamsiso-input</c>,
|
||||
/// receivers → <c>public + teamsiso-input</c>; engine groups updated to
|
||||
/// match (discover from teamsiso-input, broadcast on public). Operator
|
||||
/// MUST restart Teams afterward for it to read the new NDI config.
|
||||
/// </summary>
|
||||
private async Task<object> ApplyTopologyAsync()
|
||||
{
|
||||
var result = NdiAccessManagerConfig.ApplyTranscoderTopology();
|
||||
if (!result.Success)
|
||||
{
|
||||
return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath };
|
||||
}
|
||||
// Mirror what the WPF settings VM does so the engine groups + machine
|
||||
// config stay in lockstep.
|
||||
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings(
|
||||
DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup,
|
||||
OutputGroups: "public");
|
||||
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
|
||||
return new
|
||||
{
|
||||
ok = true,
|
||||
mode = "hidden",
|
||||
backupPath = result.BackupPath,
|
||||
note = "Restart Microsoft Teams for the new NDI config to take effect there.",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore the machine NDI defaults: senders + receivers both on
|
||||
/// <c>public</c>. Engine groups go back to null/defaults too. Operator
|
||||
/// must restart Teams for it to broadcast on public again.
|
||||
/// </summary>
|
||||
private async Task<object> RestoreTopologyAsync()
|
||||
{
|
||||
var result = NdiAccessManagerConfig.RestoreDefaults();
|
||||
if (!result.Success)
|
||||
{
|
||||
return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath };
|
||||
}
|
||||
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings(
|
||||
DiscoveryGroups: null,
|
||||
OutputGroups: null);
|
||||
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
|
||||
return new
|
||||
{
|
||||
ok = true,
|
||||
mode = "public",
|
||||
backupPath = result.BackupPath,
|
||||
note = "Restart Microsoft Teams for the new NDI config to take effect there.",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode the engine's most recent processed frame for the given
|
||||
/// participant as a JPEG. Returns null when no pipeline is running for
|
||||
/// this participant or the frame can't be encoded for any reason.
|
||||
/// </summary>
|
||||
private byte[]? TryEncodeThumbnailJpeg(Guid participantId)
|
||||
{
|
||||
// Encode as a raw 32-bpp BMP. BMP is trivial to write byte-by-byte
|
||||
// and every browser decodes it. JPEG would be smaller, but the
|
||||
// System.Windows.Media.Imaging path NREs on non-UI threads and
|
||||
// marshaling 1Hz JPEG encodes through the WPF dispatcher hurts
|
||||
// responsiveness. ~40KB per 192-wide BMP is fine over LAN.
|
||||
try
|
||||
{
|
||||
var frame = _controller.GetLatestProcessedFrame(participantId);
|
||||
if (frame is null)
|
||||
{
|
||||
_logger?.LogDebug("Thumbnail: no frame for {Id}", participantId);
|
||||
return null;
|
||||
}
|
||||
if (frame.Pixels.Length == 0)
|
||||
{
|
||||
_logger?.LogDebug("Thumbnail: empty pixel buffer for {Id} ({W}x{H})", participantId, frame.Width, frame.Height);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Nearest-neighbor downscale to ~192 wide. Source is BGRA32.
|
||||
const int targetWidth = 192;
|
||||
var ratio = (double)frame.Height / frame.Width;
|
||||
var targetHeight = Math.Max(1, (int)(targetWidth * ratio));
|
||||
return EncodeBmpDownscaled(frame.Pixels.Span, frame.Width, frame.Height, targetWidth, targetHeight);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Thumbnail encode failed for {Id}", participantId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nearest-neighbor downscale of a BGRA32 source then write a 32-bpp
|
||||
/// top-down BMP. Returns the full BMP file bytes including the 14-byte
|
||||
/// BMP header and 40-byte BITMAPINFOHEADER. Browsers handle it directly
|
||||
/// (no JPEG / PNG codec needed in-process).
|
||||
/// </summary>
|
||||
private static byte[] EncodeBmpDownscaled(ReadOnlySpan<byte> srcBgra, int srcW, int srcH, int dstW, int dstH)
|
||||
{
|
||||
var pixelBytes = dstW * dstH * 4;
|
||||
var bmp = new byte[54 + pixelBytes];
|
||||
|
||||
// BMP file header (14 bytes): 'BM', file size, reserved, pixel offset.
|
||||
bmp[0] = (byte)'B'; bmp[1] = (byte)'M';
|
||||
WriteUInt32LE(bmp, 2, (uint)bmp.Length);
|
||||
WriteUInt32LE(bmp, 6, 0);
|
||||
WriteUInt32LE(bmp, 10, 54);
|
||||
|
||||
// DIB header (BITMAPINFOHEADER, 40 bytes): negative height = top-down.
|
||||
WriteUInt32LE(bmp, 14, 40);
|
||||
WriteInt32LE(bmp, 18, dstW);
|
||||
WriteInt32LE(bmp, 22, -dstH);
|
||||
WriteUInt16LE(bmp, 26, 1);
|
||||
WriteUInt16LE(bmp, 28, 32);
|
||||
WriteUInt32LE(bmp, 30, 0);
|
||||
WriteUInt32LE(bmp, 34, (uint)pixelBytes);
|
||||
WriteUInt32LE(bmp, 38, 2835);
|
||||
WriteUInt32LE(bmp, 42, 2835);
|
||||
WriteUInt32LE(bmp, 46, 0);
|
||||
WriteUInt32LE(bmp, 50, 0);
|
||||
|
||||
// Nearest-neighbor downscale, top-down (matches negative-height header).
|
||||
var srcStride = srcW * 4;
|
||||
var dstOffset = 54;
|
||||
for (var dy = 0; dy < dstH; dy++)
|
||||
{
|
||||
var sy = (int)((long)dy * srcH / dstH);
|
||||
for (var dx = 0; dx < dstW; dx++)
|
||||
{
|
||||
var sx = (int)((long)dx * srcW / dstW);
|
||||
var si = sy * srcStride + sx * 4;
|
||||
bmp[dstOffset++] = srcBgra[si];
|
||||
bmp[dstOffset++] = srcBgra[si + 1];
|
||||
bmp[dstOffset++] = srcBgra[si + 2];
|
||||
bmp[dstOffset++] = srcBgra[si + 3];
|
||||
}
|
||||
}
|
||||
return bmp;
|
||||
}
|
||||
|
||||
private static void WriteUInt16LE(byte[] buf, int offset, ushort value)
|
||||
{
|
||||
buf[offset] = (byte)(value & 0xFF);
|
||||
buf[offset + 1] = (byte)((value >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
private static void WriteInt32LE(byte[] buf, int offset, int value) => WriteUInt32LE(buf, offset, (uint)value);
|
||||
|
||||
private static void WriteUInt32LE(byte[] buf, int offset, uint value)
|
||||
{
|
||||
buf[offset] = (byte)(value & 0xFF);
|
||||
buf[offset + 1] = (byte)((value >> 8) & 0xFF);
|
||||
buf[offset + 2] = (byte)((value >> 16) & 0xFF);
|
||||
buf[offset + 3] = (byte)((value >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
// Legacy WPF-imaging path kept dead-coded for posterity. The BMP path
|
||||
// above is what's wired through the endpoint. If we ever want JPEG
|
||||
// again, marshal this to the dispatcher and call from there.
|
||||
private byte[]? TryEncodeThumbnailJpeg_WpfDeadCode(Guid participantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var frame = _controller.GetLatestProcessedFrame(participantId);
|
||||
if (frame is null) return null;
|
||||
// 192-wide thumbnail at the source aspect. BGRA32 input.
|
||||
const int targetWidth = 192;
|
||||
var ratio = (double)frame.Height / frame.Width;
|
||||
var targetHeight = Math.Max(1, (int)(targetWidth * ratio));
|
||||
|
||||
// WPF imaging is NOT free-threaded by default: BitmapSource and
|
||||
// friends own DispatcherObject affinity until Freeze() drops it.
|
||||
// The control surface handler runs on an HttpListener thread (NOT
|
||||
// the UI dispatcher), so every intermediate bitmap MUST be frozen
|
||||
// before the next call touches it — otherwise we get a NRE deep
|
||||
// in MIL when JpegBitmapEncoder.Save tries to walk the frame
|
||||
// chain across thread boundaries.
|
||||
var stride = frame.Width * 4;
|
||||
var source = System.Windows.Media.Imaging.BitmapSource.Create(
|
||||
frame.Width, frame.Height,
|
||||
96, 96,
|
||||
System.Windows.Media.PixelFormats.Bgra32,
|
||||
null,
|
||||
frame.Pixels.ToArray(),
|
||||
stride);
|
||||
if (source.CanFreeze) source.Freeze();
|
||||
|
||||
var transform = new System.Windows.Media.ScaleTransform(
|
||||
(double)targetWidth / frame.Width,
|
||||
(double)targetHeight / frame.Height);
|
||||
if (transform.CanFreeze) transform.Freeze();
|
||||
|
||||
var scaled = new System.Windows.Media.Imaging.TransformedBitmap(source, transform);
|
||||
if (scaled.CanFreeze) scaled.Freeze();
|
||||
|
||||
var bitmapFrame = System.Windows.Media.Imaging.BitmapFrame.Create(scaled);
|
||||
if (bitmapFrame.CanFreeze) bitmapFrame.Freeze();
|
||||
|
||||
using var ms = new System.IO.MemoryStream();
|
||||
var encoder = new System.Windows.Media.Imaging.JpegBitmapEncoder { QualityLevel = 60 };
|
||||
encoder.Frames.Add(bitmapFrame);
|
||||
encoder.Save(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogDebug(ex, "Thumbnail encode failed for {Id}", participantId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private object AppendNote(JsonElement body, System.Collections.Specialized.NameValueCollection query)
|
||||
{
|
||||
var text = TryGetString(body, query, "text");
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return new { ok = false, error = "text required" };
|
||||
var ok = NotesService.Append(text);
|
||||
return new { ok, action = "note", path = NotesService.TodayPath };
|
||||
}
|
||||
|
||||
// RollRecordingAsync handler removed alongside the rest of the recording surface.
|
||||
|
||||
private async Task<object> ToggleIsoByIdAsync(string path, JsonElement body, System.Collections.Specialized.NameValueCollection query)
|
||||
{
|
||||
// path = /participants/<guid>/iso
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "iso")
|
||||
return NotFound();
|
||||
if (!Guid.TryParse(segments[1], out var id))
|
||||
return new { ok = false, error = "invalid id" };
|
||||
return await ToggleByIdAsync(id, body, query);
|
||||
}
|
||||
|
||||
private async Task<object> ToggleIsoByNameAsync(JsonElement body, System.Collections.Specialized.NameValueCollection query)
|
||||
{
|
||||
var displayName = TryGetString(body, query, "displayName");
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
return new { ok = false, error = "displayName required" };
|
||||
var vm = _viewModel();
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (vm is null || dispatcher is null)
|
||||
return new { ok = false, error = "view-model not ready" };
|
||||
var p = await dispatcher.InvokeAsync(() => vm.Participants.FirstOrDefault(x =>
|
||||
string.Equals(x.DisplayName, displayName, StringComparison.OrdinalIgnoreCase)));
|
||||
if (p is null) return new { ok = false, error = "participant not found", displayName };
|
||||
return await ToggleByIdAsync(p.Id, body, query);
|
||||
}
|
||||
|
||||
private async Task<object> ToggleByIdAsync(Guid id, JsonElement body, System.Collections.Specialized.NameValueCollection query)
|
||||
{
|
||||
var enabled = TryGetBool(body, query, "enabled");
|
||||
var customName = TryGetString(body, query, "customName");
|
||||
var vm = _viewModel();
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (vm is null || dispatcher is null)
|
||||
return new { ok = false, error = "view-model not ready" };
|
||||
|
||||
// Look up the VM and snapshot its current state on the UI thread —
|
||||
// ObservableCollection enumeration and view-model property reads both
|
||||
// need to happen there.
|
||||
var lookup = await dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var p = vm.Participants.FirstOrDefault(x => x.Id == id);
|
||||
return p is null
|
||||
? null
|
||||
: new { Pvm = p, p.IsEnabled, p.CustomName };
|
||||
});
|
||||
if (lookup is null) return new { ok = false, error = "participant not found", id };
|
||||
|
||||
var target = enabled ?? !lookup.IsEnabled;
|
||||
var nameToUse = !string.IsNullOrEmpty(customName) ? customName : lookup.CustomName;
|
||||
|
||||
if (target == lookup.IsEnabled && string.IsNullOrEmpty(customName))
|
||||
return new { ok = true, id, enabled = lookup.IsEnabled, action = "noop" };
|
||||
|
||||
// Apply CustomName change first (if any) on the UI thread so a subsequent
|
||||
// EnableIsoAsync sees the new name.
|
||||
if (!string.IsNullOrEmpty(customName))
|
||||
await dispatcher.InvokeAsync(() => lookup.Pvm.CustomName = customName);
|
||||
|
||||
if (target)
|
||||
{
|
||||
await _controller.EnableIsoAsync(id,
|
||||
string.IsNullOrWhiteSpace(nameToUse) ? null : nameToUse,
|
||||
CancellationToken.None);
|
||||
await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = true);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _controller.DisableIsoAsync(id, CancellationToken.None);
|
||||
await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = false);
|
||||
}
|
||||
return new { ok = true, id, enabled = target };
|
||||
}
|
||||
|
||||
private async Task<object> ApplyPresetAsync(string path)
|
||||
{
|
||||
// path = /presets/<name>/apply
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length != 3 || segments[0] != "presets" || segments[2] != "apply")
|
||||
return NotFound();
|
||||
var name = Uri.UnescapeDataString(segments[1]);
|
||||
var preset = OperatorPresetStore.Find(name);
|
||||
if (preset is null) return new { ok = false, error = "preset not found", name };
|
||||
|
||||
var vm = _viewModel();
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (vm is null || dispatcher is null)
|
||||
return new { ok = false, error = "view-model not ready" };
|
||||
|
||||
// Snapshot participants on the UI thread — ObservableCollection enumeration
|
||||
// and ParticipantViewModel state reads both need to happen there.
|
||||
// PresetApplier marshals subsequent property writes via the dispatcher.
|
||||
var snapshot = await dispatcher.InvokeAsync(() => vm.Participants.ToList());
|
||||
|
||||
var result = await PresetApplier.ApplyAsync(
|
||||
preset, snapshot, _controller, dispatcher);
|
||||
|
||||
return new
|
||||
{
|
||||
ok = true,
|
||||
name = preset.Name,
|
||||
matched = result.Matched,
|
||||
changed = result.Changed,
|
||||
skipped = result.Skipped,
|
||||
};
|
||||
}
|
||||
|
||||
[SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")]
|
||||
private object NotFound() => new { error = "not found" };
|
||||
|
||||
// ─── WebSocket push ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Owns a single client connection until it closes. Sends an immediate
|
||||
/// snapshot on connect (so the client doesn't have to wait up to 250ms
|
||||
/// for the next push tick), then sits in a receive loop draining any
|
||||
/// incoming text — we ignore client→server messages for v1 since all
|
||||
/// commands are REST. The receive loop is the canonical way to detect
|
||||
/// graceful close: when WebSocket.ReceiveAsync returns CloseReceived,
|
||||
/// we close back and remove the client.
|
||||
/// </summary>
|
||||
private async Task HandleWebSocketAsync(WebSocket ws)
|
||||
{
|
||||
var clientId = Guid.NewGuid();
|
||||
_clients[clientId] = ws;
|
||||
_logger?.LogInformation("WebSocket client {Id} connected.", clientId);
|
||||
|
||||
try
|
||||
{
|
||||
// Initial snapshot — fetch synchronously on the UI thread so the
|
||||
// ObservableCollection isn't enumerated cross-thread.
|
||||
await SendAsync(ws, await GetSnapshotJsonAsync());
|
||||
|
||||
var buf = new byte[1024];
|
||||
while (ws.State == WebSocketState.Open)
|
||||
{
|
||||
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buf), CancellationToken.None);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
|
||||
break;
|
||||
}
|
||||
// Ignore any client-sent messages for now; future bidirectional
|
||||
// commands could route through here.
|
||||
}
|
||||
}
|
||||
catch (WebSocketException) { /* client crashed; drop */ }
|
||||
catch (ObjectDisposedException) { /* Stop() aborted us */ }
|
||||
catch (OperationCanceledException) { /* server shutting down */ }
|
||||
finally
|
||||
{
|
||||
_clients.TryRemove(clientId, out _);
|
||||
// Don't double-dispose: Stop() already disposed the WebSocket if it's
|
||||
// tearing us down. Aborting an already-disposed socket is a no-op
|
||||
// throw which we catch + ignore.
|
||||
try { ws.Dispose(); } catch { /* defensive */ }
|
||||
_logger?.LogInformation("WebSocket client {Id} disconnected.", clientId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatcher-tick handler. Reads the current participants snapshot, and if
|
||||
/// it differs from what we last pushed, broadcasts the new JSON to every
|
||||
/// connected client. Diffing on the JSON string is cheap and saves wire
|
||||
/// bytes when nothing's actually changing — typical operator workflow has
|
||||
/// long periods of no state churn between meetings.
|
||||
/// </summary>
|
||||
private async Task PushSnapshotIfChangedAsync()
|
||||
{
|
||||
if (_clients.IsEmpty) return;
|
||||
|
||||
string snapshot;
|
||||
try { snapshot = await GetSnapshotJsonAsync(); }
|
||||
catch { return; }
|
||||
|
||||
if (snapshot == _lastPushedSnapshot) return;
|
||||
_lastPushedSnapshot = snapshot;
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(snapshot);
|
||||
foreach (var (id, ws) in _clients.ToArray())
|
||||
{
|
||||
if (ws.State != WebSocketState.Open)
|
||||
{
|
||||
_clients.TryRemove(id, out _);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
await ws.SendAsync(
|
||||
new ArraySegment<byte>(bytes),
|
||||
WebSocketMessageType.Text,
|
||||
endOfMessage: true,
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_clients.TryRemove(id, out _);
|
||||
try { ws.Dispose(); } catch { /* defensive */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendAsync(WebSocket ws, string text)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(text);
|
||||
await ws.SendAsync(
|
||||
new ArraySegment<byte>(bytes),
|
||||
WebSocketMessageType.Text,
|
||||
endOfMessage: true,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the same payload as <c>GET /participants</c> but as a JSON string
|
||||
/// for direct WebSocket Send. Reads the ObservableCollection via the UI
|
||||
/// dispatcher because WPF's ObservableCollection isn't thread-safe to
|
||||
/// enumerate from a non-UI thread.
|
||||
/// </summary>
|
||||
private async Task<string> GetSnapshotJsonAsync()
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
var participants = dispatcher is null
|
||||
? Array.Empty<object>()
|
||||
: await dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var vm = _viewModel();
|
||||
if (vm is null) return Array.Empty<object>();
|
||||
return vm.Participants.Select(p => (object)new
|
||||
{
|
||||
id = p.Id,
|
||||
displayName = p.DisplayName,
|
||||
isOnline = p.IsOnline,
|
||||
isEnabled = p.IsEnabled,
|
||||
customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName,
|
||||
stateLabel = p.StateLabel,
|
||||
}).ToArray();
|
||||
});
|
||||
return JsonSerializer.Serialize(new { type = "participants", participants }, JsonOpts);
|
||||
}
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<JsonElement> ReadBodyAsync(HttpListenerRequest req)
|
||||
|
|
|
|||
|
|
@ -19,16 +19,8 @@ public static class NotesService
|
|||
{
|
||||
private static readonly object _gate = new();
|
||||
|
||||
/// <summary>
|
||||
/// Test-only seam — when set, overrides the default
|
||||
/// %LOCALAPPDATA%\TeamsISO\Notes path. Lets tests write to a
|
||||
/// tempdir without polluting the dev's real notes folder.
|
||||
/// InternalsVisibleTo grants TeamsISO.App.Tests access.
|
||||
/// </summary>
|
||||
internal static string? DirectoryOverride { get; set; }
|
||||
|
||||
private static string NotesDirectory =>
|
||||
DirectoryOverride ?? Path.Combine(
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO", "Notes");
|
||||
|
||||
|
|
|
|||
|
|
@ -139,10 +139,7 @@ public sealed class OscBridge : IAsyncDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// Internal so unit tests can construct an OscMessage and verify
|
||||
// route dispatch reaches the right controller / TeamsControlBridge /
|
||||
// NotesService call without driving the full UDP receive loop.
|
||||
internal async Task DispatchAsync(OscMessage msg)
|
||||
private async Task DispatchAsync(OscMessage msg)
|
||||
{
|
||||
var addr = msg.Address;
|
||||
switch (addr)
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Phase E.4 — Embedded Teams via SetParent.
|
||||
///
|
||||
/// Reparents Teams' main top-level window into a TeamsISO-owned host
|
||||
/// (typically a Border element's HWND). Strips the captured window's
|
||||
/// caption + thick frame so it integrates flush with the host, and
|
||||
/// remembers enough about the original to restore it cleanly later.
|
||||
///
|
||||
/// The Win32 behavior is well understood for classic Win32 apps, but
|
||||
/// modern Teams runs WebView2 in its main window; WebView2's renderer is
|
||||
/// sensitive to parent changes and may flash white frames during
|
||||
/// reparent, drop input focus, or refuse to redraw until forced. We mark
|
||||
/// the feature experimental and ensure the restore path always runs (the
|
||||
/// caller wraps Embed in a finally block) so operators can fall back to
|
||||
/// auto-hide mode if embedding misbehaves on their specific Teams build.
|
||||
///
|
||||
/// Lives in its own static class — separated from <see cref="TeamsLauncher"/>
|
||||
/// because the embedding lifecycle (reparent → resize → restore) is its
|
||||
/// own thing, and the Win32 surface it requires (SetParent / window-style
|
||||
/// muck / SetWindowPos / MoveWindow) isn't reused by the launch / hide /
|
||||
/// in-call control paths.
|
||||
/// </summary>
|
||||
public static class TeamsEmbedHost
|
||||
{
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern int GetWindowLongPtr(IntPtr hWnd, int nIndex);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern int SetWindowLongPtr(IntPtr hWnd, int nIndex, int dwNewLong);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, [MarshalAs(UnmanagedType.Bool)] bool bRepaint);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern int GetWindowTextLengthW(IntPtr hWnd);
|
||||
|
||||
private const int GWL_STYLE = -16;
|
||||
private const long WS_CHILD = 0x40000000;
|
||||
private const long WS_POPUP = unchecked((long)0x80000000);
|
||||
private const long WS_CAPTION = 0x00C00000;
|
||||
private const long WS_THICKFRAME = 0x00040000;
|
||||
private const long WS_BORDER = 0x00800000;
|
||||
private const long WS_DLGFRAME = 0x00400000;
|
||||
private const uint SWP_FRAMECHANGED = 0x0020;
|
||||
private const uint SWP_NOMOVE = 0x0002;
|
||||
private const uint SWP_NOSIZE = 0x0001;
|
||||
private const uint SWP_NOZORDER = 0x0004;
|
||||
private const uint SWP_NOACTIVATE = 0x0010;
|
||||
|
||||
/// <summary>
|
||||
/// Captures the original parent + window style so embedding can be
|
||||
/// reversed cleanly. Tracked per-HWND so multiple consecutive
|
||||
/// embed / unembed cycles don't lose the original chrome.
|
||||
/// </summary>
|
||||
private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState;
|
||||
private static IntPtr _embeddedHwnd = IntPtr.Zero;
|
||||
|
||||
/// <summary>True when a Teams window is currently parented inside a TeamsISO host.</summary>
|
||||
public static bool IsEmbedded => _embeddedHwnd != IntPtr.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Reparents Teams' most-recently-used top-level window into
|
||||
/// <paramref name="hostHwnd"/>. Strips Teams' caption + thick frame
|
||||
/// so it integrates flush with the host. Returns true on success,
|
||||
/// false if no Teams window could be found.
|
||||
///
|
||||
/// The host HWND is typically obtained via:
|
||||
/// var src = (System.Windows.Interop.HwndSource)
|
||||
/// PresentationSource.FromVisual(MyHostBorder);
|
||||
/// src.Handle // → IntPtr suitable for hostHwnd
|
||||
/// </summary>
|
||||
public static bool EmbedTeamsInto(IntPtr hostHwnd, int width, int height)
|
||||
{
|
||||
if (hostHwnd == IntPtr.Zero) return false;
|
||||
var teamsWindows = TeamsLauncher.EnumerateTopLevelTeamsWindows();
|
||||
if (teamsWindows.Count == 0) return false;
|
||||
|
||||
// Pick the longest-title window as the "main" one — same
|
||||
// heuristic GetActiveWindowTitle uses; matches the call /
|
||||
// meeting window.
|
||||
IntPtr best = IntPtr.Zero;
|
||||
int bestLen = -1;
|
||||
foreach (var w in teamsWindows)
|
||||
{
|
||||
var len = GetWindowTextLengthW(w);
|
||||
if (len > bestLen) { bestLen = len; best = w; }
|
||||
}
|
||||
if (best == IntPtr.Zero) return false;
|
||||
|
||||
// Already embedded? Unembed first to clean state.
|
||||
if (_embeddedHwnd != IntPtr.Zero) RestoreEmbed();
|
||||
|
||||
// Save original style + parent so we can fully reverse later.
|
||||
var originalStyle = GetWindowLongPtr(best, GWL_STYLE);
|
||||
var originalParent = SetParent(best, hostHwnd); // returns old parent
|
||||
|
||||
_embedSavedState = (originalParent, originalStyle);
|
||||
_embeddedHwnd = best;
|
||||
|
||||
// Strip top-level decorations + add WS_CHILD so the OS treats
|
||||
// it as a child window of the host.
|
||||
var newStyle = originalStyle;
|
||||
unchecked
|
||||
{
|
||||
newStyle &= ~(int)WS_CAPTION;
|
||||
newStyle &= ~(int)WS_THICKFRAME;
|
||||
newStyle &= ~(int)WS_BORDER;
|
||||
newStyle &= ~(int)WS_DLGFRAME;
|
||||
newStyle &= ~(int)WS_POPUP;
|
||||
newStyle |= (int)WS_CHILD;
|
||||
}
|
||||
SetWindowLongPtr(best, GWL_STYLE, newStyle);
|
||||
|
||||
// Force a non-client recalculation so the style change takes
|
||||
// effect.
|
||||
SetWindowPos(best, IntPtr.Zero, 0, 0, 0, 0,
|
||||
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
|
||||
// Place at top-left of host, full host size.
|
||||
MoveWindow(best, 0, 0, width, height, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resize the currently-embedded Teams window to <paramref name="width"/>
|
||||
/// × <paramref name="height"/>. Called when the host element resizes
|
||||
/// (window resize, layout change, etc.). No-op if nothing is embedded.
|
||||
/// </summary>
|
||||
public static void ResizeEmbedded(int width, int height)
|
||||
{
|
||||
if (_embeddedHwnd == IntPtr.Zero || width <= 0 || height <= 0) return;
|
||||
MoveWindow(_embeddedHwnd, 0, 0, width, height, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverse an active embed: SetParent back to desktop + restore the
|
||||
/// original window style so Teams looks/behaves like a normal
|
||||
/// top-level window again. Safe to call when nothing is embedded —
|
||||
/// no-op.
|
||||
/// </summary>
|
||||
public static void RestoreEmbed()
|
||||
{
|
||||
if (_embeddedHwnd == IntPtr.Zero || _embedSavedState is null) return;
|
||||
var (origParent, origStyle) = _embedSavedState.Value;
|
||||
try
|
||||
{
|
||||
// Restore original style FIRST so when we reparent the
|
||||
// window's top-level decorations come back correctly.
|
||||
SetWindowLongPtr(_embeddedHwnd, GWL_STYLE, origStyle);
|
||||
// SetParent(hwnd, Zero) returns to desktop. We could pass
|
||||
// origParent verbatim but for Teams that's always the
|
||||
// desktop anyway, and IntPtr.Zero is documented as
|
||||
// "reparent to desktop".
|
||||
SetParent(_embeddedHwnd, IntPtr.Zero);
|
||||
SetWindowPos(_embeddedHwnd, IntPtr.Zero, 0, 0, 0, 0,
|
||||
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
catch { /* defensive — restore must never throw */ }
|
||||
finally
|
||||
{
|
||||
_embedSavedState = null;
|
||||
_embeddedHwnd = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -271,6 +271,168 @@ public static class TeamsLauncher
|
|||
private static extern int GetWindowTextLengthW(IntPtr hWnd);
|
||||
|
||||
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Phase E.4 — Embedded Teams via SetParent.
|
||||
//
|
||||
// Reparents Teams' main top-level window into a TeamsISO-owned host
|
||||
// (typically a Border element's HWND). The Win32 behavior is well
|
||||
// understood for classic Win32 apps but modern Teams runs WebView2 in
|
||||
// its main window; WebView2's renderer is sensitive to parent changes
|
||||
// and may flash white frames during reparent, drop input focus, or
|
||||
// refuse to redraw until forced.
|
||||
//
|
||||
// We mark the feature experimental and provide a clean restore path
|
||||
// (SetParent back to desktop + restore the original window styles)
|
||||
// so operators can fall back to auto-hide mode if embedding misbehaves
|
||||
// on their specific Teams build.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern int GetWindowLongPtr(IntPtr hWnd, int nIndex);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern int SetWindowLongPtr(IntPtr hWnd, int nIndex, int dwNewLong);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, [MarshalAs(UnmanagedType.Bool)] bool bRepaint);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GetDesktopWindow();
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
|
||||
|
||||
private const int GWL_STYLE = -16;
|
||||
private const long WS_CHILD = 0x40000000;
|
||||
private const long WS_POPUP = unchecked((long)0x80000000);
|
||||
private const long WS_CAPTION = 0x00C00000;
|
||||
private const long WS_THICKFRAME = 0x00040000;
|
||||
private const long WS_BORDER = 0x00800000;
|
||||
private const long WS_DLGFRAME = 0x00400000;
|
||||
private const uint SWP_FRAMECHANGED = 0x0020;
|
||||
private const uint SWP_NOMOVE = 0x0002;
|
||||
private const uint SWP_NOSIZE = 0x0001;
|
||||
private const uint SWP_NOZORDER = 0x0004;
|
||||
private const uint SWP_NOACTIVATE = 0x0010;
|
||||
|
||||
/// <summary>
|
||||
/// Captures the original parent + window style so embedding can be
|
||||
/// reversed cleanly. Tracked per-HWND so multiple consecutive
|
||||
/// embed/unembed cycles don't lose the original chrome.
|
||||
/// </summary>
|
||||
private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState;
|
||||
private static IntPtr _embeddedHwnd = IntPtr.Zero;
|
||||
|
||||
/// <summary>True when a Teams window is currently parented inside a TeamsISO host.</summary>
|
||||
public static bool IsEmbedded => _embeddedHwnd != IntPtr.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Reparents Teams' most-recently-used top-level window into
|
||||
/// <paramref name="hostHwnd"/>. Strips Teams' caption + thick frame so
|
||||
/// it integrates flush with the host. Returns true on success, false
|
||||
/// if no Teams window could be found.
|
||||
///
|
||||
/// The host HWND is typically obtained via:
|
||||
/// var src = (System.Windows.Interop.HwndSource)
|
||||
/// PresentationSource.FromVisual(MyHostBorder);
|
||||
/// src.Handle // → IntPtr suitable for hostHwnd
|
||||
/// </summary>
|
||||
public static bool EmbedTeamsInto(IntPtr hostHwnd, int width, int height)
|
||||
{
|
||||
if (hostHwnd == IntPtr.Zero) return false;
|
||||
var teamsWindows = FindTeamsTopLevelWindows();
|
||||
if (teamsWindows.Count == 0) return false;
|
||||
|
||||
// Pick the longest-title window as the "main" one — same heuristic
|
||||
// GetActiveWindowTitle uses; matches the call/meeting window.
|
||||
IntPtr best = IntPtr.Zero;
|
||||
int bestLen = -1;
|
||||
foreach (var w in teamsWindows)
|
||||
{
|
||||
var len = GetWindowTextLengthW(w);
|
||||
if (len > bestLen) { bestLen = len; best = w; }
|
||||
}
|
||||
if (best == IntPtr.Zero) return false;
|
||||
|
||||
// Already embedded? Unembed first to clean state.
|
||||
if (_embeddedHwnd != IntPtr.Zero) RestoreEmbed();
|
||||
|
||||
// Save original style + parent so we can fully reverse later.
|
||||
var originalStyle = GetWindowLongPtr(best, GWL_STYLE);
|
||||
var originalParent = SetParent(best, hostHwnd); // returns old parent
|
||||
|
||||
_embedSavedState = (originalParent, originalStyle);
|
||||
_embeddedHwnd = best;
|
||||
|
||||
// Strip top-level decorations + add WS_CHILD so the OS treats it
|
||||
// as a child window of the host.
|
||||
var newStyle = originalStyle;
|
||||
unchecked
|
||||
{
|
||||
newStyle &= ~(int)WS_CAPTION;
|
||||
newStyle &= ~(int)WS_THICKFRAME;
|
||||
newStyle &= ~(int)WS_BORDER;
|
||||
newStyle &= ~(int)WS_DLGFRAME;
|
||||
newStyle &= ~(int)WS_POPUP;
|
||||
newStyle |= (int)WS_CHILD;
|
||||
}
|
||||
SetWindowLongPtr(best, GWL_STYLE, newStyle);
|
||||
|
||||
// Force a non-client recalculation so the style change takes effect.
|
||||
SetWindowPos(best, IntPtr.Zero, 0, 0, 0, 0,
|
||||
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
|
||||
// Place at top-left of host, full host size.
|
||||
MoveWindow(best, 0, 0, width, height, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resize the currently-embedded Teams window to <paramref name="width"/>
|
||||
/// × <paramref name="height"/>. Called when the host element resizes
|
||||
/// (window resize, layout change, etc.). No-op if nothing is embedded.
|
||||
/// </summary>
|
||||
public static void ResizeEmbedded(int width, int height)
|
||||
{
|
||||
if (_embeddedHwnd == IntPtr.Zero || width <= 0 || height <= 0) return;
|
||||
MoveWindow(_embeddedHwnd, 0, 0, width, height, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverse an active embed: SetParent back to desktop + restore the
|
||||
/// original window style so Teams looks/behaves like a normal top-level
|
||||
/// window again. Safe to call when nothing is embedded — no-op.
|
||||
/// </summary>
|
||||
public static void RestoreEmbed()
|
||||
{
|
||||
if (_embeddedHwnd == IntPtr.Zero || _embedSavedState is null) return;
|
||||
var (origParent, origStyle) = _embedSavedState.Value;
|
||||
try
|
||||
{
|
||||
// Restore original style FIRST so when we reparent the window's
|
||||
// top-level decorations come back correctly.
|
||||
SetWindowLongPtr(_embeddedHwnd, GWL_STYLE, origStyle);
|
||||
// SetParent(hwnd, Zero) returns to desktop. We could pass
|
||||
// origParent verbatim but for Teams that's always the desktop
|
||||
// anyway, and IntPtr.Zero is documented as "reparent to desktop".
|
||||
SetParent(_embeddedHwnd, IntPtr.Zero);
|
||||
SetWindowPos(_embeddedHwnd, IntPtr.Zero, 0, 0, 0, 0,
|
||||
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
catch { /* defensive — restore must never throw */ }
|
||||
finally
|
||||
{
|
||||
_embedSavedState = null;
|
||||
_embeddedHwnd = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the title bar text of Teams' most-recently-used top-level
|
||||
/// window, or empty string if Teams isn't running. Modern Teams puts
|
||||
|
|
@ -320,14 +482,7 @@ public static class TeamsLauncher
|
|||
/// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is
|
||||
/// not a tooltip or popup of another). Used by Hide/Show.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Return the visible top-level windows owned by any Teams process.
|
||||
/// Exposed internal so <see cref="TeamsEmbedHost"/> can pick the
|
||||
/// "best" candidate to reparent without re-implementing the
|
||||
/// enumeration. Keep this in TeamsLauncher because the launch /
|
||||
/// hide / show paths use the same list.
|
||||
/// </summary>
|
||||
internal static List<IntPtr> EnumerateTopLevelTeamsWindows()
|
||||
private static List<IntPtr> FindTeamsTopLevelWindows()
|
||||
{
|
||||
var teamsPids = new HashSet<uint>(
|
||||
TeamsProcessNames
|
||||
|
|
@ -354,7 +509,7 @@ public static class TeamsLauncher
|
|||
/// </summary>
|
||||
public static int HideWindows()
|
||||
{
|
||||
var windows = EnumerateTopLevelTeamsWindows();
|
||||
var windows = FindTeamsTopLevelWindows();
|
||||
foreach (var w in windows) ShowWindow(w, SW_HIDE);
|
||||
return windows.Count;
|
||||
}
|
||||
|
|
@ -455,7 +610,7 @@ public static class TeamsLauncher
|
|||
/// </summary>
|
||||
public static bool SendShortcut(ShortcutModifiers modifiers, int virtualKey)
|
||||
{
|
||||
var windows = EnumerateTopLevelTeamsWindows();
|
||||
var windows = FindTeamsTopLevelWindows();
|
||||
if (windows.Count == 0) return false;
|
||||
var hwnd = windows[^1];
|
||||
|
||||
|
|
|
|||
|
|
@ -25,60 +25,35 @@ namespace TeamsISO.App.Services;
|
|||
/// </summary>
|
||||
public sealed class ThemeManager
|
||||
{
|
||||
public static ThemeManager Current { get; } = new(
|
||||
isSystemDark: ReadSystemDarkFromRegistry,
|
||||
loadPreference: TryLoadPreferenceFromDisk,
|
||||
savePreference: TrySavePreferenceToDisk,
|
||||
subscribeToSystemPreference: true);
|
||||
public static ThemeManager Current { get; } = new();
|
||||
|
||||
// Pack URIs (rather than relative "/Themes/…") so the resolution
|
||||
// works equally well from production (where Application.Current's
|
||||
// base URI is the TeamsISO entry assembly) and from xUnit tests
|
||||
// (where it's the test assembly — relative URIs would miss).
|
||||
private const string DarkUri = "pack://application:,,,/TeamsISO;component/Themes/Theme.Dark.xaml";
|
||||
private const string LightUri = "pack://application:,,,/TeamsISO;component/Themes/Theme.Light.xaml";
|
||||
private const string DarkUri = "/Themes/Theme.Dark.xaml";
|
||||
private const string LightUri = "/Themes/Theme.Light.xaml";
|
||||
private const string PreferenceKeySystem = "System";
|
||||
private const string PreferenceKeyDark = "Dark";
|
||||
private const string PreferenceKeyLight = "Light";
|
||||
|
||||
// Test seams. The production singleton wires these to the real
|
||||
// registry / UIPreferences. Tests construct via the internal ctor
|
||||
// with their own stubs so they don't touch HKCU or %LOCALAPPDATA%.
|
||||
private readonly Func<bool> _isSystemDark;
|
||||
private readonly Action<string> _savePreference;
|
||||
|
||||
internal ThemeManager(
|
||||
Func<bool> isSystemDark,
|
||||
Func<string?> loadPreference,
|
||||
Action<string> savePreference,
|
||||
bool subscribeToSystemPreference)
|
||||
private ThemeManager()
|
||||
{
|
||||
_isSystemDark = isSystemDark;
|
||||
_savePreference = savePreference;
|
||||
|
||||
// Hydrate preference from the seam on first access. Disk / load
|
||||
// failures fall back to defaults so the app always boots into a
|
||||
// deterministic theme.
|
||||
// Hydrate preference from disk on first access. UIPreferences.Load()
|
||||
// is best-effort — disk failures fall back to defaults so the app
|
||||
// always boots into a deterministic theme.
|
||||
try
|
||||
{
|
||||
var loaded = loadPreference();
|
||||
if (IsValidPreference(loaded))
|
||||
var prefs = UIPreferences.Load();
|
||||
if (IsValidPreference(prefs.Theme))
|
||||
{
|
||||
_preference = loaded!;
|
||||
_preference = prefs.Theme;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Defensive — ctor must not throw or the app loses theming.
|
||||
// Defensive — singleton ctor must not throw or the app loses theming.
|
||||
}
|
||||
|
||||
// Re-evaluate when Windows app-mode flips, but only when the
|
||||
// operator hasn't pinned a preference. The explicit choice wins.
|
||||
// Tests opt out so they don't latch into a process-wide event.
|
||||
if (subscribeToSystemPreference)
|
||||
{
|
||||
SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged;
|
||||
}
|
||||
SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged;
|
||||
}
|
||||
|
||||
private string _preference = PreferenceKeySystem;
|
||||
|
|
@ -98,7 +73,7 @@ public sealed class ThemeManager
|
|||
{
|
||||
PreferenceKeyDark => PreferenceKeyDark,
|
||||
PreferenceKeyLight => PreferenceKeyLight,
|
||||
_ => _isSystemDark() ? PreferenceKeyDark : PreferenceKeyLight,
|
||||
_ => IsSystemDark() ? PreferenceKeyDark : PreferenceKeyLight,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -114,7 +89,7 @@ public sealed class ThemeManager
|
|||
}
|
||||
|
||||
_preference = preference;
|
||||
try { _savePreference(preference); }
|
||||
try { UIPreferences.SetTheme(preference); }
|
||||
catch { /* persistence is best-effort */ }
|
||||
Apply();
|
||||
}
|
||||
|
|
@ -169,7 +144,7 @@ public sealed class ThemeManager
|
|||
}
|
||||
}
|
||||
|
||||
var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Absolute) };
|
||||
var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Relative) };
|
||||
if (old is null)
|
||||
{
|
||||
dicts.Insert(0, fresh);
|
||||
|
|
@ -185,10 +160,9 @@ public sealed class ThemeManager
|
|||
/// <summary>
|
||||
/// Read Windows' AppsUseLightTheme registry value. 1 = light, 0 = dark.
|
||||
/// Returns true (dark) on any read failure — the dark scene is the
|
||||
/// default per DESIGN.md so a missing value still lands somewhere
|
||||
/// sensible. Backs the singleton's _isSystemDark seam.
|
||||
/// default per DESIGN.md so a missing value still lands somewhere sensible.
|
||||
/// </summary>
|
||||
private static bool ReadSystemDarkFromRegistry()
|
||||
private static bool IsSystemDark()
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -206,31 +180,6 @@ public sealed class ThemeManager
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load the operator's persisted theme preference from
|
||||
/// %LOCALAPPDATA%\TeamsISO\ui-prefs.json. Returns null on any read
|
||||
/// failure (missing file, corrupt JSON, schema mismatch) so the
|
||||
/// caller falls back to the in-memory default of "System". Backs
|
||||
/// the singleton's loadPreference seam.
|
||||
/// </summary>
|
||||
private static string? TryLoadPreferenceFromDisk()
|
||||
{
|
||||
try { return UIPreferences.Load().Theme; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persist the operator's theme preference to ui-prefs.json. Errors
|
||||
/// are swallowed — persistence is best-effort and a single failed
|
||||
/// save shouldn't break the in-session UI experience. Backs the
|
||||
/// singleton's savePreference seam.
|
||||
/// </summary>
|
||||
private static void TrySavePreferenceToDisk(string preference)
|
||||
{
|
||||
try { UIPreferences.SetTheme(preference); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
private void OnSystemPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
|
||||
{
|
||||
if (e.Category != UserPreferenceCategory.General) return;
|
||||
|
|
|
|||
|
|
@ -164,26 +164,15 @@ public static class UpdateChecker
|
|||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only seam — when set, overrides the default
|
||||
/// %LOCALAPPDATA%\TeamsISO path that holds the cooldown stamp +
|
||||
/// the opt-out flag. Tests use this to write to a tempdir so
|
||||
/// CheckIfDueAsync's throttle path can be exercised without
|
||||
/// hitting real disk paths or the real network (the throttle
|
||||
/// short-circuits before the HTTP call).
|
||||
/// </summary>
|
||||
internal static string? StateDirectoryOverride { get; set; }
|
||||
|
||||
private static string StateDirectory => StateDirectoryOverride ??
|
||||
private static string CooldownPath =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO");
|
||||
|
||||
private static string CooldownPath =>
|
||||
Path.Combine(StateDirectory, "last-update-check.txt");
|
||||
"TeamsISO", "last-update-check.txt");
|
||||
|
||||
private static string OptOutPath =>
|
||||
Path.Combine(StateDirectory, "no-update-check.flag");
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO", "no-update-check.flag");
|
||||
|
||||
/// <summary>
|
||||
/// Whether launch-time update checks are enabled. Inverted-flag-file storage:
|
||||
|
|
@ -230,10 +219,9 @@ public static class UpdateChecker
|
|||
/// 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. Internal so tests can pin parsing
|
||||
/// behaviour without HTTP.
|
||||
/// follow-up if we need it.
|
||||
/// </summary>
|
||||
internal static Version? TryParseSemVer(string s)
|
||||
private static Version? TryParseSemVer(string s)
|
||||
{
|
||||
var trimmed = s.TrimStart('v', 'V');
|
||||
var dash = trimmed.IndexOf('-');
|
||||
|
|
|
|||
|
|
@ -13,15 +13,7 @@ namespace TeamsISO.App.Services;
|
|||
/// </summary>
|
||||
public static class WindowStateStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Test-only seam — when set, overrides the default
|
||||
/// %LOCALAPPDATA%\TeamsISO\window.json path. Lets tests verify
|
||||
/// the serialization round-trip without polluting the dev's
|
||||
/// real placement state.
|
||||
/// </summary>
|
||||
internal static string? PathOverride { get; set; }
|
||||
|
||||
private static string Path => PathOverride ??
|
||||
private static readonly string Path =
|
||||
System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ namespace TeamsISO.App;
|
|||
/// instead of leaving the host blank.
|
||||
/// • Restore-on-close runs in a finally block so a crash mid-host
|
||||
/// can't leave Teams orphaned with stripped window styles.
|
||||
/// • TeamsEmbedHost.RestoreEmbed is idempotent — safe to call even if
|
||||
/// • TeamsLauncher.RestoreEmbed is idempotent — safe to call even if
|
||||
/// embedding never succeeded.
|
||||
/// </summary>
|
||||
public partial class TeamsEmbedWindow : Window
|
||||
|
|
@ -43,7 +43,7 @@ public partial class TeamsEmbedWindow : Window
|
|||
|
||||
var w = (int)EmbedHost.ActualWidth;
|
||||
var h = (int)EmbedHost.ActualHeight;
|
||||
if (!TeamsEmbedHost.EmbedTeamsInto(src.Handle, w, h))
|
||||
if (!TeamsLauncher.EmbedTeamsInto(src.Handle, w, h))
|
||||
{
|
||||
MessageBox.Show(
|
||||
"Couldn't find a Microsoft Teams window to embed. " +
|
||||
|
|
@ -57,14 +57,14 @@ public partial class TeamsEmbedWindow : Window
|
|||
{
|
||||
// Keep Teams sized to match the host as the embed window resizes.
|
||||
// No-op when nothing is embedded.
|
||||
TeamsEmbedHost.ResizeEmbedded((int)e.NewSize.Width, (int)e.NewSize.Height);
|
||||
TeamsLauncher.ResizeEmbedded((int)e.NewSize.Width, (int)e.NewSize.Height);
|
||||
}
|
||||
|
||||
private void OnWindowClosed(object? sender, EventArgs e)
|
||||
{
|
||||
// ALWAYS restore Teams to top-level state when this window closes,
|
||||
// even if the embed never succeeded. Idempotent.
|
||||
try { TeamsEmbedHost.RestoreEmbed(); }
|
||||
try { TeamsLauncher.RestoreEmbed(); }
|
||||
catch { /* defensive — restore is best-effort */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,17 +39,6 @@
|
|||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Strings.resx — user-facing English MessageBox copy. Embedded as a
|
||||
.NET resource so ResourceManager can resolve TeamsISO.App.Properties.Strings
|
||||
by basename. Strings.Designer.cs is hand-written (see file comment).
|
||||
-->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Properties\Strings.resx">
|
||||
<LogicalName>TeamsISO.App.Properties.Strings.resources</LogicalName>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. -->
|
||||
<ItemGroup>
|
||||
<Resource Include="Assets\dragon-mark.png" />
|
||||
|
|
|
|||
|
|
@ -319,7 +319,11 @@
|
|||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ISO toggle: pill, status-coded -->
|
||||
<!-- ISO toggle: rounded-rect (Radius.M) to match the rest of the button
|
||||
family, status-coded background (LIVE cyan / ERROR coral / NO SIGNAL
|
||||
amber). Previously a full pill (CornerRadius=999); pill made the LIVE
|
||||
indicator visually distinct from the toolbar buttons in a way that
|
||||
read as "different control type" rather than "different state". -->
|
||||
<Style x:Key="Wd.Button.IsoToggle" TargetType="Button">
|
||||
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
|
|
@ -340,7 +344,7 @@
|
|||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="999">
|
||||
CornerRadius="{StaticResource Radius.M}">
|
||||
<ContentPresenter HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="{TemplateBinding Padding}"/>
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
|
|||
: Visible.FirstOrDefault();
|
||||
}
|
||||
|
||||
internal static bool Matches(PaletteCommand c, string query)
|
||||
private static bool Matches(PaletteCommand c, string query)
|
||||
{
|
||||
if (c.Label.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (c.Category.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
using TeamsISO.App.Services;
|
||||
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
||||
// Bulk operations that touch every (or every-enabled) participant —
|
||||
// Stop all ISOs, Enable all online, Snapshot all enabled frames.
|
||||
// Split out of MainViewModel.cs so the main file isn't dominated by
|
||||
// long async iteration loops.
|
||||
//
|
||||
// The RecordingCommands partial originally planned at this slot is
|
||||
// intentionally absent: the recording surface was axed earlier in the
|
||||
// May 2026 batch (see commit 1d1ce6a). What remains is bulk-state
|
||||
// manipulation across the participants collection.
|
||||
public sealed partial class MainViewModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable ISOs for every online + non-enabled participant in
|
||||
/// parallel-ish (sequential await, but each individual EnableIsoAsync
|
||||
/// is fast). Tolerates per-participant failures so one bad source
|
||||
/// doesn't abort the rest.
|
||||
/// </summary>
|
||||
private async Task EnableAllOnlineAsync()
|
||||
{
|
||||
var candidates = Participants.Where(p => p.IsOnline && !p.IsEnabled).ToArray();
|
||||
var enabled = 0;
|
||||
foreach (var p in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
|
||||
? OutputNameTemplate.Render(
|
||||
OutputNameTemplate.Get(),
|
||||
p.Id,
|
||||
p.DisplayName)
|
||||
: p.CustomName;
|
||||
// 3-arg overload (no recordOverride) — recording surface axed,
|
||||
// so the engine's per-pipeline recorder sink stays unattached.
|
||||
await _controller.EnableIsoAsync(p.Id, resolvedName, CancellationToken.None);
|
||||
p.IsEnabled = true;
|
||||
enabled++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Per-participant best-effort — one bad source shouldn't
|
||||
// abort the bulk operation.
|
||||
}
|
||||
}
|
||||
Toast.Show(enabled == 0
|
||||
? "No participants to enable"
|
||||
: $"Enabled {enabled} ISO(s)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emergency-stop: disable every running ISO. Confirmation dialog with
|
||||
/// default-No guards mid-show misclicks; the regret cost of yanking 5
|
||||
/// ISOs is far higher than the Enter-press cost of the prompt.
|
||||
/// </summary>
|
||||
private async Task StopAllIsosAsync()
|
||||
{
|
||||
// Snapshot first so the collection doesn't mutate while we iterate.
|
||||
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
|
||||
if (enabled.Length == 0)
|
||||
{
|
||||
Toast.Show("No ISOs to stop");
|
||||
return;
|
||||
}
|
||||
var confirm = System.Windows.MessageBox.Show(
|
||||
$"Stop {enabled.Length} running ISO(s)?\n\nThis tears down every active pipeline immediately.",
|
||||
"TeamsISO — Stop all ISOs",
|
||||
System.Windows.MessageBoxButton.YesNo,
|
||||
System.Windows.MessageBoxImage.Warning,
|
||||
System.Windows.MessageBoxResult.No);
|
||||
if (confirm != System.Windows.MessageBoxResult.Yes) return;
|
||||
|
||||
foreach (var p in enabled)
|
||||
{
|
||||
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
|
||||
catch { /* defensive */ }
|
||||
p.IsEnabled = false;
|
||||
}
|
||||
Toast.Show($"Stopped {enabled.Length} ISO(s)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save a PNG of every currently-enabled participant's latest
|
||||
/// processed frame to a timestamped subdirectory under
|
||||
/// %USERPROFILE%\Pictures\TeamsISO\. One folder per Snapshot All click
|
||||
/// so back-to-back clicks don't comingle. Useful for end-of-meeting
|
||||
/// archives, recapping who showed up, etc.
|
||||
/// </summary>
|
||||
private void SnapshotAll()
|
||||
{
|
||||
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
|
||||
if (enabled.Length == 0)
|
||||
{
|
||||
Toast.Warn("No enabled participants to snapshot");
|
||||
return;
|
||||
}
|
||||
|
||||
var rootDir = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
|
||||
"TeamsISO",
|
||||
$"snapshots-{DateTimeOffset.Now:yyyyMMdd_HHmmss}");
|
||||
|
||||
try
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(rootDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Toast.Warn($"Couldn't create snapshot dir: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
var saved = 0;
|
||||
var failed = 0;
|
||||
foreach (var p in enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var frame = _controller.GetLatestProcessedFrame(p.Id);
|
||||
if (frame is null || frame.Pixels.IsEmpty) { failed++; continue; }
|
||||
|
||||
var safeName = string.Join("_", p.DisplayName.Split(System.IO.Path.GetInvalidFileNameChars()));
|
||||
if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant";
|
||||
var path = System.IO.Path.Combine(rootDir, $"{safeName}.png");
|
||||
|
||||
var stride = frame.Width * 4;
|
||||
var bmp = new System.Windows.Media.Imaging.WriteableBitmap(
|
||||
frame.Width, frame.Height, 96, 96,
|
||||
System.Windows.Media.PixelFormats.Bgra32, null);
|
||||
bmp.WritePixels(
|
||||
new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
|
||||
frame.Pixels.ToArray(), stride, 0);
|
||||
|
||||
using var fs = new System.IO.FileStream(path, System.IO.FileMode.Create);
|
||||
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
|
||||
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bmp));
|
||||
encoder.Save(fs);
|
||||
saved++;
|
||||
}
|
||||
catch { failed++; }
|
||||
}
|
||||
|
||||
Toast.Show(failed > 0
|
||||
? $"Saved {saved} snapshot(s) ({failed} failed) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}"
|
||||
: $"Saved {saved} snapshot(s) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
using System.Windows.Threading;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
||||
// Auto-apply-last-preset path. Split out of MainViewModel.cs so the
|
||||
// pending-preset bookkeeping doesn't clutter the main file.
|
||||
//
|
||||
// Lifecycle:
|
||||
// • InitializeAsync (in main file) reads operator preference + last-applied
|
||||
// name from OperatorPresetStore and sets _pendingPresetName + deadline.
|
||||
// • OnParticipantsChanged (in main file) calls TryAutoApplyPendingPreset
|
||||
// once participants populate.
|
||||
// • RequestApplyPresetOnStartup is the CLI hook: `--apply-preset NAME`.
|
||||
public sealed partial class MainViewModel
|
||||
{
|
||||
// Set on InitializeAsync from disk; cleared once we successfully apply
|
||||
// (so we don't re-apply when the participant list later mutates). The
|
||||
// grace deadline gives Teams enough time to publish all initial sources
|
||||
// after engine start before we attempt the apply — applying before
|
||||
// everyone's visible would partially-restore the routing and silently
|
||||
// drop assignments for late-appearing participants.
|
||||
private string? _pendingPresetName;
|
||||
private DateTimeOffset _pendingPresetDeadline;
|
||||
private bool _pendingPresetApplied;
|
||||
|
||||
/// <summary>
|
||||
/// CLI override for the launch-time auto-apply. Called from App.OnStartup
|
||||
/// after parsing <c>--apply-preset</c>. Sets the same pending-preset state
|
||||
/// that the user-toggled auto-apply path uses, so a single trigger flow
|
||||
/// covers both. Wins over the persisted preference (operator's CLI intent
|
||||
/// is more recent than what's on disk).
|
||||
/// </summary>
|
||||
public void RequestApplyPresetOnStartup(string presetName)
|
||||
{
|
||||
_pendingPresetName = presetName;
|
||||
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
|
||||
_pendingPresetApplied = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the operator's auto-apply preference + last-applied preset name
|
||||
/// from disk and seeds the pending-preset state. Called by InitializeAsync
|
||||
/// during engine startup. Failures are swallowed — a preset read fault
|
||||
/// should never block the engine from coming up.
|
||||
/// </summary>
|
||||
private void LoadPendingPresetFromPreferences()
|
||||
{
|
||||
try
|
||||
{
|
||||
var pref = OperatorPresetStore.GetStartupPreference();
|
||||
if (pref.AutoApplyOnStartup && !string.IsNullOrEmpty(pref.LastAppliedName))
|
||||
{
|
||||
_pendingPresetName = pref.LastAppliedName;
|
||||
// 30s grace window is generous: Teams typically advertises all
|
||||
// existing participants within 5–10s of NDI discovery starting.
|
||||
// After this deadline we apply with whoever is visible.
|
||||
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
|
||||
}
|
||||
}
|
||||
catch { /* preset read failures shouldn't block engine startup */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to apply <c>_pendingPresetName</c> if either every preset
|
||||
/// assignment matches a live participant, or the grace deadline has
|
||||
/// passed. Idempotent — repeat calls without state change are no-ops;
|
||||
/// once we fire we flag <c>_pendingPresetApplied</c> so subsequent
|
||||
/// participant churn doesn't trigger a second apply. Failures (missing
|
||||
/// preset on disk, preset that no longer matches anyone) are swallowed:
|
||||
/// the operator can always re-apply manually via the dialog. Delegates
|
||||
/// to <see cref="PresetApplier.ApplyAsync"/> for the actual
|
||||
/// reconciliation so the dialog, REST surface, and this auto-apply path
|
||||
/// all share a single implementation.
|
||||
/// </summary>
|
||||
private void TryAutoApplyPendingPreset()
|
||||
{
|
||||
OperatorPresetStore.Preset? preset;
|
||||
try { preset = OperatorPresetStore.Find(_pendingPresetName!); }
|
||||
catch { preset = null; }
|
||||
if (preset is null)
|
||||
{
|
||||
_pendingPresetApplied = true; // give up; nothing on disk to apply
|
||||
return;
|
||||
}
|
||||
|
||||
var liveNames = new HashSet<string>(
|
||||
Participants.Select(p => p.DisplayName),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var allPresent = preset.Assignments.All(a => liveNames.Contains(a.DisplayName));
|
||||
if (!allPresent && DateTimeOffset.UtcNow < _pendingPresetDeadline)
|
||||
return; // wait for the rest of the meeting to populate
|
||||
|
||||
_pendingPresetApplied = true;
|
||||
var captured = preset;
|
||||
// Snapshot the participants list since we're about to await on a
|
||||
// worker thread; the live ObservableCollection isn't safe to
|
||||
// enumerate from outside the dispatcher.
|
||||
var snapshot = Participants.ToList();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var result = await PresetApplier.ApplyAsync(
|
||||
captured, snapshot, _controller, _dispatcher);
|
||||
await _dispatcher.InvokeAsync(() =>
|
||||
Toast.Show($"Auto-applied preset \"{captured.Name}\" — {result.Matched} participant(s)"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
using System.Windows.Threading;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
||||
// Teams launch / in-call / join-by-URL command helpers — split out of
|
||||
// MainViewModel.cs so the body methods don't live alongside the
|
||||
// constructor wiring + reactive subscriptions. The four command
|
||||
// PROPERTIES are declared back in MainViewModel.cs (public API surface);
|
||||
// this file holds the helpers they invoke.
|
||||
public sealed partial class MainViewModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Wraps a <see cref="TeamsControlBridge"/> invocation in a RelayCommand
|
||||
/// that translates the result to a user-visible toast. Centralizes the
|
||||
/// toast wording so the four control commands stay consistent.
|
||||
/// </summary>
|
||||
private RelayCommand MakeTeamsCommand(string label, Func<TeamsControlBridge.InvokeResult> invoke, string successMessage)
|
||||
{
|
||||
return new RelayCommand(() =>
|
||||
{
|
||||
switch (invoke())
|
||||
{
|
||||
case TeamsControlBridge.InvokeResult.Invoked:
|
||||
Toast.Show(successMessage);
|
||||
break;
|
||||
case TeamsControlBridge.InvokeResult.TeamsNotRunning:
|
||||
Toast.Warn("Teams isn't running.");
|
||||
break;
|
||||
case TeamsControlBridge.InvokeResult.ControlNotFound:
|
||||
Toast.Warn($"{label} control not found — are you in a call?");
|
||||
break;
|
||||
case TeamsControlBridge.InvokeResult.InvokeFailed:
|
||||
Toast.Warn($"{label} button found but disabled.");
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Body of <c>JoinMeetingCommand</c>. Trims the pasted URL, hands it to
|
||||
/// <see cref="TeamsLauncher.TryJoinMeeting"/>, and runs the auto-hide
|
||||
/// follow-up if the operator has that preference set.
|
||||
/// </summary>
|
||||
private void JoinPastedMeeting()
|
||||
{
|
||||
var url = (_joinMeetingUrl ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(url)) return;
|
||||
if (TeamsLauncher.TryJoinMeeting(url, out var error))
|
||||
{
|
||||
Toast.Show("Joining Teams meeting…");
|
||||
JoinMeetingUrl = string.Empty;
|
||||
if (Settings.AutoHideTeamsWindows)
|
||||
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Toast.Warn($"Could not join: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pull the meaningful "meeting title" out of Teams' raw window title.
|
||||
/// Teams uses formats like:
|
||||
/// "Weekly Standup | Microsoft Teams"
|
||||
/// "Meeting with Alice | Microsoft Teams"
|
||||
/// "Microsoft Teams" (no meeting, just the app)
|
||||
/// Strip the trailing " | Microsoft Teams" so the IN-CALL pill stays
|
||||
/// short and readable. Truncate beyond 50 chars so a long meeting
|
||||
/// subject doesn't push the rest of the IN-CALL bar off screen.
|
||||
/// </summary>
|
||||
internal static string ExtractMeetingTitle(string windowTitle)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(windowTitle)) return string.Empty;
|
||||
var t = windowTitle.Trim();
|
||||
foreach (var sep in new[] { " | Microsoft Teams", " | Teams", " - Microsoft Teams", " - Teams" })
|
||||
{
|
||||
var idx = t.IndexOf(sep, StringComparison.Ordinal);
|
||||
if (idx > 0) { t = t.Substring(0, idx).Trim(); break; }
|
||||
}
|
||||
if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty;
|
||||
if (t.Length > 50) t = t.Substring(0, 47) + "…";
|
||||
return t;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Meeting-state probe — runs on every 1Hz stats tick. We fire the UIA
|
||||
/// traversal on a worker thread because it can take 50–200ms in a busy
|
||||
/// call; the result is marshalled back to the dispatcher to update the
|
||||
/// view-model properties. One-tick latency on the displayed state is
|
||||
/// preferable to a UI hiccup.
|
||||
/// </summary>
|
||||
private void PollTeamsMeetingState()
|
||||
{
|
||||
try
|
||||
{
|
||||
var teamsRunning = TeamsLauncher.IsRunning();
|
||||
if (!teamsRunning)
|
||||
{
|
||||
TeamsMeetingState = string.Empty;
|
||||
IsTeamsInCall = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Single UIA traversal returns all three signals (in-call /
|
||||
// muted / camera-off) so we don't pay for three walks of
|
||||
// the same descendant tree at 1Hz.
|
||||
var snap = TeamsControlBridge.DetectCallState();
|
||||
var inCall = snap.IsInCall;
|
||||
var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
|
||||
_dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
IsTeamsInCall = inCall;
|
||||
TeamsMeetingState = inCall
|
||||
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
|
||||
: "READY";
|
||||
IsLocalMuted = inCall && (snap.IsMuted ?? false);
|
||||
IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false);
|
||||
});
|
||||
}
|
||||
catch { /* UIA flakiness shouldn't crash the stats tick */ }
|
||||
});
|
||||
}
|
||||
catch { /* defensive — probe failures must never break the tick */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -14,14 +14,8 @@ namespace TeamsISO.App.ViewModels;
|
|||
/// Top-level view model for the main window. Owns the live collection of <see cref="ParticipantViewModel"/>,
|
||||
/// the global settings panel, and the alert banner. Subscribes to <see cref="IIsoController"/>'s observables
|
||||
/// and marshals updates onto the UI dispatcher.
|
||||
///
|
||||
/// Split across partial files by responsibility:
|
||||
/// • <c>MainViewModel.cs</c> — fields, properties, constructor (wires commands), OnStatsTick, Dispose
|
||||
/// • <c>MainViewModel.TeamsCommands.cs</c> — Mute / Cam / Leave / Share + Join URL + Teams meeting-state poll
|
||||
/// • <c>MainViewModel.PresetCommands.cs</c> — auto-apply-last-preset path
|
||||
/// • <c>MainViewModel.BulkCommands.cs</c> — Stop all / Enable all / Snapshot all
|
||||
/// </summary>
|
||||
public sealed partial class MainViewModel : ObservableObject, IDisposable
|
||||
public sealed class MainViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private readonly IIsoController _controller;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
|
|
@ -31,8 +25,15 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
|
|||
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
|
||||
private string _statusText = "Starting…";
|
||||
|
||||
// _pendingPresetName / Deadline / Applied + the auto-apply path
|
||||
// moved to MainViewModel.PresetCommands.cs.
|
||||
// Auto-apply-last-preset bookkeeping. Set on InitializeAsync from disk;
|
||||
// cleared once we successfully apply (so we don't re-apply when the
|
||||
// participant list later mutates). The grace deadline gives Teams enough
|
||||
// time to publish all initial sources after engine start before we attempt
|
||||
// the apply — applying before everyone's visible would partially-restore
|
||||
// the routing and silently drop assignments for late-appearing participants.
|
||||
private string? _pendingPresetName;
|
||||
private DateTimeOffset _pendingPresetDeadline;
|
||||
private bool _pendingPresetApplied;
|
||||
|
||||
public ObservableCollection<ParticipantViewModel> Participants { get; } = new();
|
||||
|
||||
|
|
@ -430,7 +431,25 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
|
|||
}
|
||||
});
|
||||
|
||||
JoinMeetingCommand = new RelayCommand(JoinPastedMeeting);
|
||||
JoinMeetingCommand = new RelayCommand(() =>
|
||||
{
|
||||
// Trim + handle the operator pasting whitespace around the URL.
|
||||
var url = (_joinMeetingUrl ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(url)) return;
|
||||
if (TeamsLauncher.TryJoinMeeting(url, out var error))
|
||||
{
|
||||
Toast.Show("Joining Teams meeting…");
|
||||
JoinMeetingUrl = string.Empty;
|
||||
// If the operator has auto-hide on, kick off the hide watcher
|
||||
// so the Teams meeting window goes away as soon as it renders.
|
||||
if (Settings.AutoHideTeamsWindows)
|
||||
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Toast.Warn($"Could not join: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
ToggleMuteCommand = MakeTeamsCommand(
|
||||
label: "Mute",
|
||||
|
|
@ -450,13 +469,202 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
|
|||
successMessage: "Opened share tray");
|
||||
}
|
||||
|
||||
// Body methods extracted to themed partial files:
|
||||
// MainViewModel.BulkCommands.cs — EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll
|
||||
// MainViewModel.TeamsCommands.cs — MakeTeamsCommand, JoinPastedMeeting,
|
||||
// ExtractMeetingTitle, PollTeamsMeetingState
|
||||
// MainViewModel.PresetCommands.cs — RequestApplyPresetOnStartup,
|
||||
// LoadPendingPresetFromPreferences,
|
||||
// TryAutoApplyPendingPreset
|
||||
/// <summary>
|
||||
/// Wraps a <see cref="TeamsControlBridge"/> invocation in a RelayCommand that
|
||||
/// translates the result to a user-visible toast. Centralizes the toast wording
|
||||
/// so the four control commands stay consistent.
|
||||
/// </summary>
|
||||
private RelayCommand MakeTeamsCommand(string label, Func<TeamsControlBridge.InvokeResult> invoke, string successMessage)
|
||||
{
|
||||
return new RelayCommand(() =>
|
||||
{
|
||||
switch (invoke())
|
||||
{
|
||||
case TeamsControlBridge.InvokeResult.Invoked:
|
||||
Toast.Show(successMessage);
|
||||
break;
|
||||
case TeamsControlBridge.InvokeResult.TeamsNotRunning:
|
||||
Toast.Warn("Teams isn't running.");
|
||||
break;
|
||||
case TeamsControlBridge.InvokeResult.ControlNotFound:
|
||||
Toast.Warn($"{label} control not found — are you in a call?");
|
||||
break;
|
||||
case TeamsControlBridge.InvokeResult.InvokeFailed:
|
||||
Toast.Warn($"{label} button found but disabled.");
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues DisableIsoAsync for every participant whose ISO is currently enabled.
|
||||
/// Each disable is awaited sequentially so we don't try to tear down N pipelines
|
||||
/// in parallel and trip channel-completion races; for ~10 participants this is
|
||||
/// still sub-second total. Failures are swallowed (best-effort emergency stop).
|
||||
/// </summary>
|
||||
// RollRecordingAsync removed — recording feature axed.
|
||||
|
||||
/// <summary>
|
||||
/// Enable ISOs for every online + non-enabled participant in parallel-ish
|
||||
/// (sequential await, but each individual EnableIsoAsync is fast). Tolerates
|
||||
/// per-participant failures so one bad source doesn't abort the rest.
|
||||
/// </summary>
|
||||
private async Task EnableAllOnlineAsync()
|
||||
{
|
||||
var candidates = Participants.Where(p => p.IsOnline && !p.IsEnabled).ToArray();
|
||||
var enabled = 0;
|
||||
foreach (var p in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
|
||||
? Services.OutputNameTemplate.Render(
|
||||
Services.OutputNameTemplate.Get(),
|
||||
p.Id,
|
||||
p.DisplayName)
|
||||
: p.CustomName;
|
||||
// 3-arg overload (no recordOverride) — recording surface axed,
|
||||
// so the engine's per-pipeline recorder sink stays unattached.
|
||||
await _controller.EnableIsoAsync(p.Id, resolvedName, CancellationToken.None);
|
||||
p.IsEnabled = true;
|
||||
enabled++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Per-participant best-effort — one bad source shouldn't abort the bulk operation.
|
||||
}
|
||||
}
|
||||
Toast.Show(enabled == 0
|
||||
? "No participants to enable"
|
||||
: $"Enabled {enabled} ISO(s)");
|
||||
}
|
||||
|
||||
private async Task StopAllIsosAsync()
|
||||
{
|
||||
// Snapshot first so the collection doesn't mutate while we iterate.
|
||||
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
|
||||
if (enabled.Length == 0)
|
||||
{
|
||||
Toast.Show("No ISOs to stop");
|
||||
return;
|
||||
}
|
||||
// Confirm before tearing down — this button is an "emergency stop" but
|
||||
// mis-clicks during a show are easy. The dialog cost is negligible
|
||||
// (one Enter press) and the regret cost is huge (yanking 5 ISOs mid-
|
||||
// broadcast). Default selection is No so accidental hits cancel.
|
||||
var confirm = System.Windows.MessageBox.Show(
|
||||
$"Stop {enabled.Length} running ISO(s)?\n\nThis tears down every active pipeline immediately.",
|
||||
"TeamsISO — Stop all ISOs",
|
||||
System.Windows.MessageBoxButton.YesNo,
|
||||
System.Windows.MessageBoxImage.Warning,
|
||||
System.Windows.MessageBoxResult.No);
|
||||
if (confirm != System.Windows.MessageBoxResult.Yes) return;
|
||||
|
||||
foreach (var p in enabled)
|
||||
{
|
||||
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
|
||||
catch { /* defensive */ }
|
||||
p.IsEnabled = false;
|
||||
}
|
||||
Toast.Show($"Stopped {enabled.Length} ISO(s)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save a PNG of every currently-enabled participant's latest processed
|
||||
/// frame to a timestamped subdirectory under
|
||||
/// %USERPROFILE%\Pictures\TeamsISO\. One folder per Snapshot All click
|
||||
/// so back-to-back clicks don't comingle. Useful for end-of-meeting
|
||||
/// archives, recapping who showed up, etc.
|
||||
/// </summary>
|
||||
private void SnapshotAll()
|
||||
{
|
||||
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
|
||||
if (enabled.Length == 0)
|
||||
{
|
||||
Toast.Warn("No enabled participants to snapshot");
|
||||
return;
|
||||
}
|
||||
|
||||
var rootDir = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
|
||||
"TeamsISO",
|
||||
$"snapshots-{DateTimeOffset.Now:yyyyMMdd_HHmmss}");
|
||||
|
||||
try
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(rootDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Toast.Warn($"Couldn't create snapshot dir: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
var saved = 0;
|
||||
var failed = 0;
|
||||
foreach (var p in enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var frame = _controller.GetLatestProcessedFrame(p.Id);
|
||||
if (frame is null || frame.Pixels.IsEmpty) { failed++; continue; }
|
||||
|
||||
var safeName = string.Join("_", p.DisplayName.Split(System.IO.Path.GetInvalidFileNameChars()));
|
||||
if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant";
|
||||
var path = System.IO.Path.Combine(rootDir, $"{safeName}.png");
|
||||
|
||||
var stride = frame.Width * 4;
|
||||
var bmp = new System.Windows.Media.Imaging.WriteableBitmap(
|
||||
frame.Width, frame.Height, 96, 96,
|
||||
System.Windows.Media.PixelFormats.Bgra32, null);
|
||||
bmp.WritePixels(
|
||||
new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
|
||||
frame.Pixels.ToArray(), stride, 0);
|
||||
|
||||
using var fs = new System.IO.FileStream(path, System.IO.FileMode.Create);
|
||||
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
|
||||
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bmp));
|
||||
encoder.Save(fs);
|
||||
saved++;
|
||||
}
|
||||
catch { failed++; }
|
||||
}
|
||||
|
||||
Toast.Show(failed > 0
|
||||
? $"Saved {saved} snapshot(s) ({failed} failed) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}"
|
||||
: $"Saved {saved} snapshot(s) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}");
|
||||
}
|
||||
|
||||
// FormatBytes removed — its only caller was the recording free-space footer
|
||||
// label, which went away with the rest of the recording surface.
|
||||
|
||||
/// <summary>
|
||||
/// Pull the meaningful "meeting title" out of Teams' raw window title.
|
||||
/// Teams uses formats like:
|
||||
/// "Weekly Standup | Microsoft Teams"
|
||||
/// "Meeting with Alice | Microsoft Teams"
|
||||
/// "Microsoft Teams" (no meeting, just the app)
|
||||
/// Strip the trailing " | Microsoft Teams" so the IN-CALL pill stays
|
||||
/// short and readable. Truncate beyond 50 chars so a long meeting
|
||||
/// subject doesn't push the rest of the IN-CALL bar off screen.
|
||||
/// </summary>
|
||||
internal static string ExtractMeetingTitle(string windowTitle)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(windowTitle)) return string.Empty;
|
||||
var t = windowTitle.Trim();
|
||||
// Common separator patterns Teams uses across locales.
|
||||
foreach (var sep in new[] { " | Microsoft Teams", " | Teams", " - Microsoft Teams", " - Teams" })
|
||||
{
|
||||
var idx = t.IndexOf(sep, StringComparison.Ordinal);
|
||||
if (idx > 0) { t = t.Substring(0, idx).Trim(); break; }
|
||||
}
|
||||
// If after stripping we're left with just "Microsoft Teams" the
|
||||
// window has no meeting context — return empty so the pill stays
|
||||
// at "IN CALL" without a stale title.
|
||||
if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty;
|
||||
if (t.Length > 50) t = t.Substring(0, 47) + "…";
|
||||
return t;
|
||||
}
|
||||
|
||||
private void OnStatsTick(object? sender, EventArgs e)
|
||||
{
|
||||
|
|
@ -569,10 +777,52 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
|
|||
StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
|
||||
}
|
||||
|
||||
// Teams meeting state — UIA traversal at 1Hz; off-thread so a slow
|
||||
// UIA call doesn't stall the UI tick. Implementation in
|
||||
// MainViewModel.TeamsCommands.cs.
|
||||
PollTeamsMeetingState();
|
||||
// Teams meeting state — UIA traversal at 1Hz. We probe by looking
|
||||
// for the Leave button in Teams' automation tree (present iff in a
|
||||
// call) and surface the result as a status pill in the IN-CALL bar.
|
||||
// Offloaded to a Task so a slow UIA call doesn't stall the UI tick;
|
||||
// the property update is dispatched back here on next tick.
|
||||
try
|
||||
{
|
||||
var teamsRunning = TeamsLauncher.IsRunning();
|
||||
if (!teamsRunning)
|
||||
{
|
||||
TeamsMeetingState = string.Empty;
|
||||
IsTeamsInCall = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fire the UIA probe off-thread — it walks the full descendant
|
||||
// tree of every Teams window and can take 50-200ms in a busy
|
||||
// call. We can tolerate one-tick latency on the displayed
|
||||
// state much more easily than a UI hiccup.
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Single UIA traversal returns all three signals — in-call,
|
||||
// muted, camera-off — so we don't pay for three walks of
|
||||
// the same descendant tree at 1Hz.
|
||||
var snap = TeamsControlBridge.DetectCallState();
|
||||
var inCall = snap.IsInCall;
|
||||
var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
|
||||
_dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
IsTeamsInCall = inCall;
|
||||
TeamsMeetingState = inCall
|
||||
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
|
||||
: "READY";
|
||||
// Mute / camera state — only meaningful in-call.
|
||||
IsLocalMuted = inCall && (snap.IsMuted ?? false);
|
||||
IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false);
|
||||
// Auto-record-on-call hook removed alongside recording feature.
|
||||
});
|
||||
}
|
||||
catch { /* UIA flakiness shouldn't crash the stats tick */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
catch { /* defensive — probe failures must never break the tick */ }
|
||||
|
||||
// Control-surface state — peek at App's owned services.
|
||||
var app = System.Windows.Application.Current as App;
|
||||
|
|
@ -603,12 +853,36 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
|
|||
await _controller.StartAsync(cancellationToken);
|
||||
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
|
||||
|
||||
// Auto-apply last preset bookkeeping. We don't apply here —
|
||||
// participants haven't been discovered yet — instead we record
|
||||
// the intent and let OnParticipantsChanged trigger the apply
|
||||
// once the meeting has populated. Implementation in
|
||||
// MainViewModel.PresetCommands.cs.
|
||||
LoadPendingPresetFromPreferences();
|
||||
// Auto-apply last preset bookkeeping. We don't apply here — participants
|
||||
// haven't been discovered yet — instead we record the intent and let
|
||||
// OnParticipantsChanged trigger the apply once the meeting has populated.
|
||||
try
|
||||
{
|
||||
var pref = Services.OperatorPresetStore.GetStartupPreference();
|
||||
if (pref.AutoApplyOnStartup && !string.IsNullOrEmpty(pref.LastAppliedName))
|
||||
{
|
||||
_pendingPresetName = pref.LastAppliedName;
|
||||
// 30s grace window is generous: Teams typically advertises all
|
||||
// existing participants within 5–10s of NDI discovery starting.
|
||||
// After this deadline we apply with whoever is visible.
|
||||
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
|
||||
}
|
||||
}
|
||||
catch { /* preset read failures shouldn't block engine startup */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI override for the launch-time auto-apply. Called from App.OnStartup
|
||||
/// after parsing <c>--apply-preset</c>. Sets the same pending-preset state
|
||||
/// that the user-toggled auto-apply path uses, so a single trigger flow
|
||||
/// covers both. Wins over the persisted preference (operator's CLI intent
|
||||
/// is more recent than what's on disk).
|
||||
/// </summary>
|
||||
public void RequestApplyPresetOnStartup(string presetName)
|
||||
{
|
||||
_pendingPresetName = presetName;
|
||||
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
|
||||
_pendingPresetApplied = false;
|
||||
}
|
||||
|
||||
private void OnParticipantsChanged(IReadOnlyList<Participant> incoming)
|
||||
|
|
@ -686,6 +960,50 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to apply <see cref="_pendingPresetName"/> if either every preset
|
||||
/// assignment matches a live participant, or the grace deadline has passed.
|
||||
/// Idempotent — repeat calls without state change are no-ops; once we fire we
|
||||
/// flag <c>_pendingPresetApplied</c> so subsequent participant churn doesn't
|
||||
/// trigger a second apply. Failures (missing preset on disk, preset that no
|
||||
/// longer matches anyone) are swallowed: the operator can always re-apply
|
||||
/// manually via the dialog. Delegates to <see cref="PresetApplier.ApplyAsync"/>
|
||||
/// for the actual reconciliation so the dialog, REST surface, and this auto-
|
||||
/// apply path all share a single implementation.
|
||||
/// </summary>
|
||||
private void TryAutoApplyPendingPreset()
|
||||
{
|
||||
Services.OperatorPresetStore.Preset? preset;
|
||||
try { preset = Services.OperatorPresetStore.Find(_pendingPresetName!); }
|
||||
catch { preset = null; }
|
||||
if (preset is null)
|
||||
{
|
||||
_pendingPresetApplied = true; // give up; nothing on disk to apply
|
||||
return;
|
||||
}
|
||||
|
||||
var liveNames = new HashSet<string>(
|
||||
Participants.Select(p => p.DisplayName),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var allPresent = preset.Assignments.All(a => liveNames.Contains(a.DisplayName));
|
||||
if (!allPresent && DateTimeOffset.UtcNow < _pendingPresetDeadline)
|
||||
return; // wait for the rest of the meeting to populate
|
||||
|
||||
_pendingPresetApplied = true;
|
||||
var captured = preset;
|
||||
// Snapshot the participants list since we're about to await on a worker
|
||||
// thread; the live ObservableCollection isn't safe to enumerate from
|
||||
// outside the dispatcher.
|
||||
var snapshot = Participants.ToList();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var result = await PresetApplier.ApplyAsync(
|
||||
captured, snapshot, _controller, _dispatcher);
|
||||
await _dispatcher.InvokeAsync(() =>
|
||||
Toast.Show($"Auto-applied preset \"{captured.Name}\" — {result.Matched} participant(s)"));
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsLocalSelf(Participant p) =>
|
||||
string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,107 +0,0 @@
|
|||
using System.Reactive.Linq;
|
||||
using System.Reactive.Subjects;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Domain;
|
||||
using TeamsISO.Engine.Pipeline;
|
||||
|
||||
namespace TeamsISO.App.Tests.Fakes;
|
||||
|
||||
// Minimal IIsoController stub for tests that need to instantiate
|
||||
// services in the App layer (ControlSurfaceServer, OscBridge, etc.)
|
||||
// without spinning up the real engine + NDI runtime.
|
||||
//
|
||||
// Everything is a sensible no-op default; tests that need a specific
|
||||
// behaviour (e.g. "EnableIsoAsync was called with these args") subclass
|
||||
// or replace methods via the action hooks.
|
||||
internal sealed class StubIsoController : IIsoController
|
||||
{
|
||||
private readonly BehaviorSubject<IReadOnlyList<Participant>> _participants =
|
||||
new(Array.Empty<Participant>());
|
||||
private readonly BehaviorSubject<EngineAlert?> _alerts = new(default);
|
||||
|
||||
public IObservable<IReadOnlyList<Participant>> Participants => _participants;
|
||||
public IObservable<EngineAlert> Alerts => _alerts.Where(a => a is not null)!;
|
||||
|
||||
public FrameProcessingSettings GlobalSettings { get; set; } = new(
|
||||
TargetFramerate.Fps30, TargetResolution.R1080p, AspectMode.Letterbox, AudioMode.Auto);
|
||||
|
||||
public NdiGroupSettings GroupSettings { get; set; } = new(
|
||||
DiscoveryGroups: null, OutputGroups: null);
|
||||
|
||||
public bool RecordingEnabled { get; private set; }
|
||||
public string? RecordingDirectory { get; private set; }
|
||||
|
||||
public Func<Guid, IsoHealthStats>? GetStatsHandler { get; set; }
|
||||
public Func<Guid, ProcessedFrame?>? GetLatestProcessedFrameHandler { get; set; }
|
||||
public Func<Guid, FrameProcessingSettings?>? GetIsoOverrideHandler { get; set; }
|
||||
|
||||
public IsoHealthStats GetStats(Guid participantId) =>
|
||||
GetStatsHandler?.Invoke(participantId) ?? IsoHealthStats.Empty;
|
||||
|
||||
public ProcessedFrame? GetLatestProcessedFrame(Guid participantId) =>
|
||||
GetLatestProcessedFrameHandler?.Invoke(participantId);
|
||||
|
||||
public FrameProcessingSettings? GetIsoOverride(Guid participantId) =>
|
||||
GetIsoOverrideHandler?.Invoke(participantId);
|
||||
|
||||
public List<(Guid Id, string? Name)> EnableCalls { get; } = new();
|
||||
public List<Guid> DisableCalls { get; } = new();
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken cancellationToken)
|
||||
{
|
||||
EnableCalls.Add((participantId, customName));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task EnableIsoAsync(Guid participantId, string? customName, bool? recordOverride, CancellationToken cancellationToken)
|
||||
{
|
||||
EnableCalls.Add((participantId, customName));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisableIsoAsync(Guid participantId, CancellationToken cancellationToken)
|
||||
{
|
||||
DisableCalls.Add(participantId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
GlobalSettings = settings;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetIsoOverrideAsync(Guid participantId, FrameProcessingSettings? settings, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task SetGroupSettingsAsync(NdiGroupSettings groupSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
GroupSettings = groupSettings;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public bool RefreshDiscoveryCalled { get; private set; }
|
||||
public void RefreshDiscovery() => RefreshDiscoveryCalled = true;
|
||||
|
||||
public void SetRecording(bool enabled, string? outputDirectory)
|
||||
{
|
||||
RecordingEnabled = enabled;
|
||||
RecordingDirectory = outputDirectory;
|
||||
}
|
||||
|
||||
public void AddRecordingMarker(string label) { /* no-op for stub */ }
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_participants.Dispose();
|
||||
_alerts.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
// Used by tests to push synthetic participant snapshots through the
|
||||
// observable chain.
|
||||
public void PublishParticipants(params Participant[] participants) =>
|
||||
_participants.OnNext(participants);
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using FluentAssertions;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.App.Tests.Fakes;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Domain;
|
||||
using Xunit;
|
||||
|
||||
namespace TeamsISO.App.Tests.Integration;
|
||||
|
||||
// End-to-end-ish integration tests that need a live WPF Application +
|
||||
// STA dispatcher. All three live in one class + share a
|
||||
// WpfHostFixture so Application is created exactly once for the
|
||||
// suite (Application is one-per-AppDomain — multiple test classes
|
||||
// trying to construct it independently collide).
|
||||
//
|
||||
// Coverage per the punch list:
|
||||
// • App-startup headless smoke — construct App's bootstrap layers
|
||||
// on STA, verify XAML resource resolution + theme apply + VM
|
||||
// wiring + MainWindow construction.
|
||||
// • ControlSurface integration — boot the server on an ephemeral
|
||||
// port, populate a real view-model, hit /participants, verify
|
||||
// the JSON includes the live participant.
|
||||
// • Theme swap — Dark → Light dictionary swap, brush key resolves
|
||||
// to a different value afterward.
|
||||
[Collection(WpfHostCollection.Name)]
|
||||
public sealed class IntegrationTests
|
||||
{
|
||||
private readonly WpfHostFixture _wpf;
|
||||
|
||||
public IntegrationTests(WpfHostFixture wpf) => _wpf = wpf;
|
||||
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try { return ((IPEndPoint)listener.LocalEndpoint).Port; }
|
||||
finally { listener.Stop(); }
|
||||
}
|
||||
|
||||
private async Task SeedDarkThemeAsync()
|
||||
{
|
||||
await _wpf.Run(() =>
|
||||
{
|
||||
var dicts = _wpf.Application.Resources.MergedDictionaries;
|
||||
dicts.Clear();
|
||||
dicts.Add(new ResourceDictionary
|
||||
{
|
||||
Source = new Uri("pack://application:,,,/TeamsISO;component/Themes/Theme.Dark.xaml", UriKind.Absolute),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThemeXaml_DarkAndLight_BothLoadWithDistinctWdCanvas()
|
||||
{
|
||||
// Verifies the real XAML files load via pack URIs (the
|
||||
// production code path) and that the two theme files
|
||||
// produce different brushes for the same key. End-to-end
|
||||
// exercise of the resource pipeline that doesn't depend on
|
||||
// Application.Resources global state — both dicts are
|
||||
// loaded fresh in this call.
|
||||
//
|
||||
// We don't test ThemeManager.SwapColorDictionary here
|
||||
// because Application.Resources is process-wide and
|
||||
// sibling-test mutations make the state observably non-
|
||||
// deterministic in xUnit's parallel-collection model;
|
||||
// ThemeManagerTests (Services/) cover the swap state
|
||||
// machine against stubbed seams. This test guards the
|
||||
// distinct-XAML-files claim, which is what would otherwise
|
||||
// get refactored out by accident.
|
||||
await _wpf.Run(() =>
|
||||
{
|
||||
var darkDict = new ResourceDictionary
|
||||
{
|
||||
Source = new Uri(
|
||||
"pack://application:,,,/TeamsISO;component/Themes/Theme.Dark.xaml",
|
||||
UriKind.Absolute),
|
||||
};
|
||||
var lightDict = new ResourceDictionary
|
||||
{
|
||||
Source = new Uri(
|
||||
"pack://application:,,,/TeamsISO;component/Themes/Theme.Light.xaml",
|
||||
UriKind.Absolute),
|
||||
};
|
||||
|
||||
var darkCanvas = ((SolidColorBrush)darkDict ["Wd.Canvas"]).Color;
|
||||
var lightCanvas = ((SolidColorBrush)lightDict["Wd.Canvas"]).Color;
|
||||
|
||||
darkCanvas.Should().Be(Color.FromRgb(0x0A, 0x0A, 0x0A),
|
||||
"Theme.Dark.xaml's Wd.Canvas is the documented #0A0A0A");
|
||||
lightCanvas.Should().Be(Color.FromRgb(0xFA, 0xFA, 0xFB),
|
||||
"Theme.Light.xaml's Wd.Canvas is the documented #FAFAFB");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppStartup_FullChain_Constructs_WithoutThrowing()
|
||||
{
|
||||
// Headless smoke for the App.OnStartup wiring sequence:
|
||||
// 1. Application + theme resources are loaded.
|
||||
// 2. ThemeManager.Apply() resolves brush keys end-to-end.
|
||||
// 3. MainViewModel constructs against a stub controller.
|
||||
// 4. MainWindow ctor resolves DataContext + finds the brushes
|
||||
// its templates reference.
|
||||
await SeedDarkThemeAsync();
|
||||
|
||||
await _wpf.Run(() =>
|
||||
{
|
||||
_wpf.Application.Resources.MergedDictionaries.Add(new ResourceDictionary
|
||||
{
|
||||
Source = new Uri(
|
||||
"pack://application:,,,/TeamsISO;component/Themes/WildDragonTheme.xaml",
|
||||
UriKind.Absolute),
|
||||
});
|
||||
});
|
||||
|
||||
// Everything DependencyObject-touching has to run on the STA
|
||||
// dispatcher (Window / DataContext / TryFindResource all
|
||||
// VerifyAccess). Do the assertions inside the Run callback so
|
||||
// we never marshal a DependencyObject reference back to the
|
||||
// test thread.
|
||||
await _wpf.Run(() =>
|
||||
{
|
||||
var tm = new ThemeManager(
|
||||
isSystemDark: () => true,
|
||||
loadPreference: () => "Dark",
|
||||
savePreference: _ => { },
|
||||
subscribeToSystemPreference: false);
|
||||
tm.Apply();
|
||||
|
||||
var controller = new StubIsoController();
|
||||
var vm = new MainViewModel(controller, _wpf.Dispatcher);
|
||||
try
|
||||
{
|
||||
var window = new MainWindow(vm);
|
||||
vm.Settings.Should().NotBeNull("MainViewModel wires GlobalSettingsViewModel");
|
||||
vm.AlertBanner.Should().NotBeNull();
|
||||
window.DataContext.Should().BeSameAs(vm);
|
||||
window.TryFindResource("Wd.Canvas").Should().NotBeNull(
|
||||
"Wd.Canvas is defined in Theme.Dark.xaml and used by MainWindow.xaml");
|
||||
}
|
||||
finally
|
||||
{
|
||||
vm.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ControlSurface_GetParticipants_ReturnsLiveViewModelState()
|
||||
{
|
||||
var controller = new StubIsoController();
|
||||
var vm = await _wpf.Run(() => new MainViewModel(controller, _wpf.Dispatcher));
|
||||
|
||||
// Publish a participant through the controller observable and
|
||||
// wait for the dispatcher to drain the InvokeAsync(Background)
|
||||
// marshal that adds Alice to the Participants collection.
|
||||
controller.PublishParticipants(new Participant(
|
||||
Id: Guid.NewGuid(),
|
||||
DisplayName: "Alice",
|
||||
CurrentSource: null,
|
||||
FirstSeen: DateTimeOffset.UtcNow,
|
||||
LastSeen: DateTimeOffset.UtcNow));
|
||||
|
||||
// Drain the queue at ApplicationIdle so the Background-priority
|
||||
// add has time to complete before we look.
|
||||
await _wpf.Dispatcher.InvokeAsync(() => { },
|
||||
System.Windows.Threading.DispatcherPriority.ApplicationIdle).Task;
|
||||
|
||||
var server = new ControlSurfaceServer(controller, () => vm, logger: null);
|
||||
var port = PickFreePort();
|
||||
server.Start(port);
|
||||
await Task.Delay(50);
|
||||
using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") };
|
||||
|
||||
try
|
||||
{
|
||||
var res = await client.GetAsync("/participants");
|
||||
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var participants = doc.RootElement.GetProperty("participants");
|
||||
participants.GetArrayLength().Should().Be(1);
|
||||
participants[0].GetProperty("displayName").GetString().Should().Be("Alice");
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.Stop();
|
||||
await _wpf.Run(() => vm.Dispose());
|
||||
await controller.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
using System.Threading;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace TeamsISO.App.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Shared WPF Application + STA dispatcher fixture. Created once for
|
||||
/// every integration test class that asks for it; all test methods
|
||||
/// post their work to the fixture's dispatcher via <see cref="Run"/>.
|
||||
///
|
||||
/// Rationale: <see cref="Application"/> is one-per-AppDomain. Tests
|
||||
/// that each instantiate their own (or use Xunit.StaFact's per-test
|
||||
/// STA) collide on the second call ("Cannot create more than one
|
||||
/// Application instance in the same AppDomain"). A long-lived
|
||||
/// fixture creates exactly one Application on a dedicated STA thread
|
||||
/// and reuses its dispatcher for the lifetime of the test class.
|
||||
/// </summary>
|
||||
public sealed class WpfHostFixture : IDisposable
|
||||
{
|
||||
private readonly Thread _uiThread;
|
||||
private readonly ManualResetEventSlim _ready = new(false);
|
||||
private Dispatcher? _dispatcher;
|
||||
private Application? _application;
|
||||
private Exception? _initFailure;
|
||||
|
||||
public WpfHostFixture()
|
||||
{
|
||||
_uiThread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Application is process-singleton; only construct if the
|
||||
// current AppDomain hasn't already minted one (e.g. another
|
||||
// fixture in the same run).
|
||||
_application = Application.Current ?? new Application();
|
||||
_dispatcher = Dispatcher.CurrentDispatcher;
|
||||
_ready.Set();
|
||||
Dispatcher.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_initFailure = ex;
|
||||
_ready.Set();
|
||||
}
|
||||
});
|
||||
_uiThread.SetApartmentState(ApartmentState.STA);
|
||||
_uiThread.IsBackground = true;
|
||||
_uiThread.Start();
|
||||
_ready.Wait();
|
||||
if (_initFailure is not null)
|
||||
throw new InvalidOperationException("WPF host thread failed to initialise.", _initFailure);
|
||||
}
|
||||
|
||||
public Application Application => _application!;
|
||||
public Dispatcher Dispatcher => _dispatcher!;
|
||||
|
||||
/// <summary>
|
||||
/// Marshal <paramref name="work"/> onto the fixture's STA dispatcher
|
||||
/// and await its completion. Exceptions inside <paramref name="work"/>
|
||||
/// surface back to the caller intact.
|
||||
/// </summary>
|
||||
public Task<T> Run<T>(Func<T> work) =>
|
||||
_dispatcher!.InvokeAsync(work).Task;
|
||||
|
||||
public Task Run(Action work) =>
|
||||
_dispatcher!.InvokeAsync(work).Task;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _dispatcher?.InvokeShutdown(); } catch { /* defensive */ }
|
||||
try { _uiThread.Join(TimeSpan.FromSeconds(2)); } catch { /* defensive */ }
|
||||
_ready.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an integration test class as sharing the single
|
||||
/// <see cref="WpfHostFixture"/> Application + Dispatcher. xUnit
|
||||
/// instantiates the fixture once per collection and injects it via
|
||||
/// constructor.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class WpfHostCollection : ICollectionFixture<WpfHostFixture>
|
||||
{
|
||||
public const string Name = "WpfHost (shared Application + Dispatcher)";
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using FluentAssertions;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.App.Tests.Fakes;
|
||||
|
||||
namespace TeamsISO.App.Tests.Services;
|
||||
|
||||
// End-to-end-ish smoke tests for ControlSurfaceServer. Each test boots
|
||||
// the server on an OS-assigned free port (127.0.0.1 only — no urlacl
|
||||
// required), makes a real HTTP request via HttpClient, and asserts
|
||||
// against the response. The tests share a StubIsoController and a
|
||||
// null view-model — endpoints that need a UI dispatcher degrade
|
||||
// gracefully (return empty arrays) which is enough to verify the
|
||||
// route table.
|
||||
//
|
||||
// We don't exercise the WebSocket path here — ClientWebSocket adds
|
||||
// non-trivial timing complexity and the upgrade is verified by the
|
||||
// 426/101 status arc of `/ws` on a non-WS GET (we hit it and confirm
|
||||
// the server doesn't 500).
|
||||
public sealed class ControlSurfaceServerTests
|
||||
{
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try { return ((IPEndPoint)listener.LocalEndpoint).Port; }
|
||||
finally { listener.Stop(); }
|
||||
}
|
||||
|
||||
private static async Task<(ControlSurfaceServer Server, HttpClient Client, int Port)> BootAsync()
|
||||
{
|
||||
var controller = new StubIsoController();
|
||||
var server = new ControlSurfaceServer(controller, () => null, logger: null);
|
||||
var port = PickFreePort();
|
||||
server.Start(port, bindToLan: false);
|
||||
// HttpListener accepts on a background task; give it a beat so
|
||||
// the first request doesn't race the bind.
|
||||
await Task.Delay(50);
|
||||
var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") };
|
||||
return (server, client, port);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRoot_Returns200_WithServerInfoBody()
|
||||
{
|
||||
var (server, client, _) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var res = await client.GetAsync("/");
|
||||
|
||||
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
body.Should().Contain("\"product\":\"TeamsISO\"");
|
||||
body.Should().Contain("\"endpoints\"");
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.Stop();
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUnknownPath_Returns200_WithErrorBody()
|
||||
{
|
||||
// Quirk: the route table's catch-all arm returns NotFound() (an
|
||||
// object {error:"not found"}) rather than null, so the response
|
||||
// pipeline writes 200 OK with that body instead of branching to
|
||||
// 404. The body is the disambiguator, matching the rest of the
|
||||
// surface's "200 + {ok:false,error:…}" convention. Pinning this
|
||||
// so a deliberate move to a true 404 is a conscious decision,
|
||||
// not an accident.
|
||||
var (server, client, _) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var res = await client.GetAsync("/this-route-does-not-exist");
|
||||
|
||||
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
body.Should().Contain("\"error\":\"not found\"");
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.Stop();
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetParticipants_Returns200_WithEmptyListWhenNoViewModel()
|
||||
{
|
||||
// No dispatcher / no view-model in tests — the endpoint should
|
||||
// gracefully return participants=[] rather than throwing.
|
||||
var (server, client, _) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var res = await client.GetAsync("/participants");
|
||||
|
||||
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
body.Should().Contain("\"participants\":[]");
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.Stop();
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostPresetsRefreshDiscovery_HitsControllerAndReturnsOk()
|
||||
{
|
||||
var controller = new StubIsoController();
|
||||
var server = new ControlSurfaceServer(controller, () => null, logger: null);
|
||||
var port = PickFreePort();
|
||||
server.Start(port);
|
||||
await Task.Delay(50);
|
||||
using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") };
|
||||
try
|
||||
{
|
||||
var res = await client.PostAsync("/presets/refresh-discovery", content: null);
|
||||
|
||||
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
controller.RefreshDiscoveryCalled.Should().BeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostPresetApply_MissingPreset_RespondsWithOkFalseAndPresetNotFound()
|
||||
{
|
||||
// Preset name that demonstrably doesn't exist on disk → endpoint
|
||||
// returns 200 with {"ok":false,"error":"preset not found",...}.
|
||||
// We don't 404 on missing presets because the operator may have
|
||||
// typed the wrong name; clearer payload is friendlier.
|
||||
var (server, client, _) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var res = await client.PostAsync(
|
||||
"/presets/__nonexistent_preset_for_test__/apply",
|
||||
content: null);
|
||||
|
||||
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
body.Should().Contain("\"ok\":false");
|
||||
body.Should().Contain("\"error\":\"preset not found\"");
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.Stop();
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUi_Returns200_WithEmbeddedHtml()
|
||||
{
|
||||
var (server, client, _) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var res = await client.GetAsync("/ui");
|
||||
|
||||
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
res.Content.Headers.ContentType?.MediaType.Should().Be("text/html");
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
body.Should().Contain("<html", "the response should be a real HTML document");
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.Stop();
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OptionsRequest_Returns204_WithCorsHeaders()
|
||||
{
|
||||
// Companion / browser-based controllers preflight POSTs; the
|
||||
// server must answer 204 with the allow-origin/allow-methods
|
||||
// headers or the actual call gets blocked by CORS.
|
||||
var (server, client, _) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Options, "/participants");
|
||||
var res = await client.SendAsync(req);
|
||||
|
||||
res.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
res.Headers.GetValues("Access-Control-Allow-Origin").Should().Contain("*");
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.Stop();
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace TeamsISO.App.Tests.Services;
|
||||
|
||||
// Unit tests for NotesService — the append-only show-notes log.
|
||||
// Uses the DirectoryOverride seam so writes land in a tempdir and
|
||||
// don't pollute the dev's real %LOCALAPPDATA%\TeamsISO\Notes folder.
|
||||
//
|
||||
// Shares NotesStateCollection with any sibling class that mutates
|
||||
// NotesService.DirectoryOverride (the same static-state-shared-via-
|
||||
// parallel-classes problem the PresetStoreCollection solves).
|
||||
[Collection(NotesStateCollection.Name)]
|
||||
public sealed class NotesServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly string? _previousOverride;
|
||||
|
||||
public NotesServiceTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"teamsiso-notes-{Guid.NewGuid():N}");
|
||||
_previousOverride = NotesService.DirectoryOverride;
|
||||
NotesService.DirectoryOverride = _tempDir;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
NotesService.DirectoryOverride = _previousOverride;
|
||||
try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); }
|
||||
catch { /* test cleanup is best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Append_WritesHeaderAndLine_OnFirstCall()
|
||||
{
|
||||
var ok = NotesService.Append("first note");
|
||||
|
||||
ok.Should().BeTrue();
|
||||
File.Exists(NotesService.TodayPath).Should().BeTrue();
|
||||
var content = File.ReadAllText(NotesService.TodayPath);
|
||||
content.Should().StartWith("# TeamsISO show notes — ");
|
||||
content.Should().Contain("— first note");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Append_PrependsTimestampPrefix_InCanonicalFormat()
|
||||
{
|
||||
NotesService.Append("checkpoint");
|
||||
|
||||
var content = File.ReadAllText(NotesService.TodayPath);
|
||||
// Each appended line follows "- **HH:mm:ss** — <text>" so a
|
||||
// reader can scan the file as Markdown without preprocessing.
|
||||
content.Should().MatchRegex(@"- \*\*\d{2}:\d{2}:\d{2}\*\* — checkpoint");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Append_AppendsAdditionalLines_AfterTheFirst()
|
||||
{
|
||||
NotesService.Append("alpha");
|
||||
NotesService.Append("beta");
|
||||
NotesService.Append("gamma");
|
||||
|
||||
var content = File.ReadAllText(NotesService.TodayPath);
|
||||
content.Should().Contain("alpha");
|
||||
content.Should().Contain("beta");
|
||||
content.Should().Contain("gamma");
|
||||
// Header written exactly once, not before every line.
|
||||
var headerCount = content.Split("# TeamsISO show notes —").Length - 1;
|
||||
headerCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Append_TrimsLeadingAndTrailingWhitespace()
|
||||
{
|
||||
NotesService.Append(" padded ");
|
||||
|
||||
var content = File.ReadAllText(NotesService.TodayPath);
|
||||
content.Should().Contain("— padded");
|
||||
content.Should().NotContain(" padded "); // leading-whitespace gone
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t\n")]
|
||||
public void Append_RejectsEmptyOrWhitespaceText(string text)
|
||||
{
|
||||
var ok = NotesService.Append(text);
|
||||
|
||||
ok.Should().BeFalse();
|
||||
File.Exists(NotesService.TodayPath).Should().BeFalse(
|
||||
"an empty append shouldn't create the daily file");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TodayPath_ReflectsCurrentDate_AndOverride()
|
||||
{
|
||||
var path = NotesService.TodayPath;
|
||||
|
||||
Path.GetDirectoryName(path).Should().Be(_tempDir);
|
||||
Path.GetFileName(path).Should().MatchRegex(@"\d{4}-\d{2}-\d{2}\.md");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
namespace TeamsISO.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes any test class that mutates
|
||||
/// <c>NotesService.DirectoryOverride</c>. Without this, xUnit runs the
|
||||
/// classes in parallel collections and one ctor can clobber the
|
||||
/// override another's test is depending on (manifests as a brand-new
|
||||
/// notes file landing in the WRONG temp dir mid-test).
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class NotesStateCollection
|
||||
{
|
||||
public const string Name = "NotesService (DirectoryOverride mutators)";
|
||||
}
|
||||
|
|
@ -5,18 +5,15 @@ using TeamsISO.App.Services;
|
|||
namespace TeamsISO.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="OperatorPresetStore"/>. Each test redirects
|
||||
/// the store's file path to a per-test temp path via the internal
|
||||
/// Unit tests for <see cref="OperatorPresetStore"/>. Each test redirects the
|
||||
/// store's file path to a per-test temp path via the internal
|
||||
/// <c>PathOverride</c> hook so the operator's real
|
||||
/// <c>%LOCALAPPDATA%\TeamsISO\presets.json</c> is never touched.
|
||||
///
|
||||
/// IDisposable on the test class cleans up the temp path after each
|
||||
/// test. Shares <see cref="PresetStoreCollection"/> with any other
|
||||
/// class that mutates <see cref="OperatorPresetStore.PathOverride"/> —
|
||||
/// xUnit's parallel execution would otherwise let a sibling class's
|
||||
/// ctor clobber our path mid-test.
|
||||
/// IDisposable on the test class cleans up the temp path after each test.
|
||||
/// We don't use [Collection] because each test's path is per-test-unique
|
||||
/// (Path.GetTempFileName) so parallel xUnit execution can't collide.
|
||||
/// </summary>
|
||||
[Collection(PresetStoreCollection.Name)]
|
||||
public sealed class OperatorPresetStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _tempPath;
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.App.Tests.Fakes;
|
||||
|
||||
namespace TeamsISO.App.Tests.Services;
|
||||
|
||||
// Tests for the OscBridge.DispatchAsync routing. We construct
|
||||
// OscMessage instances directly (skipping the UDP receive loop) and
|
||||
// assert that the right address resolves to the right controller call.
|
||||
//
|
||||
// The toggle / preset paths require Application.Current.Dispatcher,
|
||||
// which doesn't exist in xUnit's default execution context — those
|
||||
// paths return early on the null check, so we verify the bail rather
|
||||
// than the happy path. The full toggle path is covered in branch 11's
|
||||
// integration test that boots a real dispatcher.
|
||||
//
|
||||
// Shares NotesStateCollection with NotesServiceTests — both classes
|
||||
// mutate NotesService.DirectoryOverride and would otherwise race.
|
||||
[Collection(NotesStateCollection.Name)]
|
||||
public sealed class OscBridgeDispatchTests : IDisposable
|
||||
{
|
||||
private readonly string _tempNotesDir;
|
||||
private readonly string? _previousNotesOverride;
|
||||
|
||||
public OscBridgeDispatchTests()
|
||||
{
|
||||
_tempNotesDir = Path.Combine(Path.GetTempPath(), $"teamsiso-osc-{Guid.NewGuid():N}");
|
||||
_previousNotesOverride = NotesService.DirectoryOverride;
|
||||
NotesService.DirectoryOverride = _tempNotesDir;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
NotesService.DirectoryOverride = _previousNotesOverride;
|
||||
try { if (Directory.Exists(_tempNotesDir)) Directory.Delete(_tempNotesDir, recursive: true); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
private static (OscBridge Bridge, StubIsoController Controller) NewBridge()
|
||||
{
|
||||
var controller = new StubIsoController();
|
||||
// OscBridge takes Func<MainViewModel?> — returning null exercises
|
||||
// the "no VM yet" graceful path in handlers that need it.
|
||||
var bridge = new OscBridge(controller, () => null, logger: null);
|
||||
return (bridge, controller);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshDiscoveryAddress_CallsControllerRefreshDiscovery()
|
||||
{
|
||||
var (bridge, controller) = NewBridge();
|
||||
|
||||
await bridge.DispatchAsync(new OscMessage { Address = "/teamsiso/refresh-discovery" });
|
||||
|
||||
controller.RefreshDiscoveryCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownAddress_NoOpsCleanly()
|
||||
{
|
||||
var (bridge, controller) = NewBridge();
|
||||
|
||||
await bridge.DispatchAsync(new OscMessage { Address = "/teamsiso/nope/never" });
|
||||
|
||||
controller.RefreshDiscoveryCalled.Should().BeFalse();
|
||||
controller.EnableCalls.Should().BeEmpty();
|
||||
controller.DisableCalls.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotesAddress_AppendsViaNotesService()
|
||||
{
|
||||
var (bridge, _) = NewBridge();
|
||||
|
||||
await bridge.DispatchAsync(new OscMessage
|
||||
{
|
||||
Address = "/teamsiso/notes",
|
||||
TypeTag = ",s",
|
||||
Args = new object[] { "tracked through OSC" },
|
||||
});
|
||||
|
||||
File.Exists(NotesService.TodayPath).Should().BeTrue();
|
||||
File.ReadAllText(NotesService.TodayPath).Should().Contain("tracked through OSC");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAllAddress_NoOpsWhenViewModelIsNull()
|
||||
{
|
||||
// Without a view-model, the stop-all path returns before touching
|
||||
// the controller. The point of this test is to pin that the bail
|
||||
// is clean — no thrown exception, no controller traffic.
|
||||
var (bridge, controller) = NewBridge();
|
||||
|
||||
await bridge.DispatchAsync(new OscMessage { Address = "/teamsiso/stop-all" });
|
||||
|
||||
controller.DisableCalls.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsoByNameAddress_NoOpsWhenViewModelIsNull()
|
||||
{
|
||||
// /teamsiso/iso "Jane" 1 — verifies the bail when no VM is
|
||||
// wired; doesn't fire EnableIsoAsync. The dispatcher-equipped
|
||||
// version of this round-trip lives in branch 11.
|
||||
var (bridge, controller) = NewBridge();
|
||||
|
||||
await bridge.DispatchAsync(new OscMessage
|
||||
{
|
||||
Address = "/teamsiso/iso",
|
||||
TypeTag = ",sT",
|
||||
Args = new object[] { "Jane", true },
|
||||
});
|
||||
|
||||
controller.EnableCalls.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
using System.IO;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using TeamsISO.App.Services;
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.App.Tests.Fakes;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace TeamsISO.App.Tests.Services;
|
||||
|
||||
// PresetApplier reconciles a saved preset's per-display-name assignments
|
||||
// against the live participant view-model list. Tests pin the four
|
||||
// transitions (enable→stay, disable→stay, off→enable, on→disable) plus
|
||||
// the partial-meeting path where the preset references participants
|
||||
// who aren't currently present.
|
||||
//
|
||||
// We share a collection with OperatorPresetStoreTests because both
|
||||
// classes mutate OperatorPresetStore.PathOverride; xUnit's default
|
||||
// parallelism would otherwise let one class clobber the other's path
|
||||
// mid-run.
|
||||
[Collection(PresetStoreCollection.Name)]
|
||||
public sealed class PresetApplierTests : IDisposable
|
||||
{
|
||||
private readonly string _tempPresets;
|
||||
private readonly string? _previousPresetOverride;
|
||||
|
||||
public PresetApplierTests()
|
||||
{
|
||||
_tempPresets = Path.Combine(Path.GetTempPath(), $"teamsiso-presets-{Guid.NewGuid():N}.json");
|
||||
_previousPresetOverride = OperatorPresetStore.PathOverride;
|
||||
OperatorPresetStore.PathOverride = _tempPresets;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
OperatorPresetStore.PathOverride = _previousPresetOverride;
|
||||
try { if (File.Exists(_tempPresets)) File.Delete(_tempPresets); }
|
||||
catch { /* cleanup best-effort */ }
|
||||
}
|
||||
|
||||
private static ParticipantViewModel MakeParticipant(
|
||||
StubIsoController controller, string displayName, bool isEnabled = false)
|
||||
{
|
||||
var participant = new Participant(
|
||||
Id: Guid.NewGuid(),
|
||||
DisplayName: displayName,
|
||||
CurrentSource: null,
|
||||
FirstSeen: DateTimeOffset.UtcNow,
|
||||
LastSeen: DateTimeOffset.UtcNow);
|
||||
return new ParticipantViewModel(controller, participant) { IsEnabled = isEnabled };
|
||||
}
|
||||
|
||||
private static OperatorPresetStore.Preset Preset(params (string Name, bool Enabled, string? Custom)[] rows) =>
|
||||
new(
|
||||
Name: "test-preset",
|
||||
SavedAt: DateTimeOffset.UtcNow,
|
||||
Assignments: rows.Select(r =>
|
||||
new OperatorPresetStore.Assignment(r.Name, r.Custom, r.Enabled)).ToList());
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_EnablesParticipantsThatPresetSaysEnabled_AndAreCurrentlyOff()
|
||||
{
|
||||
var controller = new StubIsoController();
|
||||
var alice = MakeParticipant(controller, "Alice", isEnabled: false);
|
||||
var bob = MakeParticipant(controller, "Bob", isEnabled: false);
|
||||
var preset = Preset(("Alice", true, "ALICE_OUT"), ("Bob", true, null));
|
||||
|
||||
var result = await PresetApplier.ApplyAsync(preset, new[] { alice, bob }, controller, dispatcher: null);
|
||||
|
||||
result.Matched.Should().Be(2);
|
||||
result.Changed.Should().Be(2);
|
||||
result.Skipped.Should().Be(0);
|
||||
|
||||
controller.EnableCalls.Should().HaveCount(2);
|
||||
controller.EnableCalls.Should().Contain(c => c.Id == alice.Id && c.Name == "ALICE_OUT");
|
||||
controller.EnableCalls.Should().Contain(c => c.Id == bob.Id && c.Name == null);
|
||||
|
||||
alice.IsEnabled.Should().BeTrue();
|
||||
bob.IsEnabled.Should().BeTrue();
|
||||
alice.CustomName.Should().Be("ALICE_OUT");
|
||||
bob.CustomName.Should().Be(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_DisablesParticipantsThatPresetSaysOff_AndAreCurrentlyEnabled()
|
||||
{
|
||||
var controller = new StubIsoController();
|
||||
var alice = MakeParticipant(controller, "Alice", isEnabled: true);
|
||||
var preset = Preset(("Alice", false, null));
|
||||
|
||||
var result = await PresetApplier.ApplyAsync(preset, new[] { alice }, controller, dispatcher: null);
|
||||
|
||||
result.Matched.Should().Be(1);
|
||||
result.Changed.Should().Be(1);
|
||||
controller.DisableCalls.Should().ContainSingle().Which.Should().Be(alice.Id);
|
||||
alice.IsEnabled.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_NoControllerCall_WhenStateAlreadyMatchesPreset()
|
||||
{
|
||||
var controller = new StubIsoController();
|
||||
var alice = MakeParticipant(controller, "Alice", isEnabled: true);
|
||||
var preset = Preset(("Alice", true, null));
|
||||
|
||||
var result = await PresetApplier.ApplyAsync(preset, new[] { alice }, controller, dispatcher: null);
|
||||
|
||||
result.Matched.Should().Be(1);
|
||||
result.Changed.Should().Be(0,
|
||||
"the participant is already enabled; preset says enabled — no controller traffic");
|
||||
controller.EnableCalls.Should().BeEmpty();
|
||||
controller.DisableCalls.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_MatchesByDisplayName_CaseInsensitive()
|
||||
{
|
||||
// Operator typed "Alice" when saving the preset; the live
|
||||
// participant comes back as "alice". The join must be case-
|
||||
// insensitive or the preset never finds the row.
|
||||
var controller = new StubIsoController();
|
||||
var alice = MakeParticipant(controller, "alice", isEnabled: false);
|
||||
var preset = Preset(("Alice", true, null));
|
||||
|
||||
var result = await PresetApplier.ApplyAsync(preset, new[] { alice }, controller, dispatcher: null);
|
||||
|
||||
result.Matched.Should().Be(1);
|
||||
alice.IsEnabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_CountsSkipped_WhenPresetReferencesAbsentParticipants()
|
||||
{
|
||||
var controller = new StubIsoController();
|
||||
var alice = MakeParticipant(controller, "Alice", isEnabled: false);
|
||||
// Preset names Alice + a Bob who never joined.
|
||||
var preset = Preset(("Alice", true, null), ("Bob", true, null));
|
||||
|
||||
var result = await PresetApplier.ApplyAsync(preset, new[] { alice }, controller, dispatcher: null);
|
||||
|
||||
result.Matched.Should().Be(1);
|
||||
result.Skipped.Should().Be(1, "Bob is named in the preset but not in the meeting");
|
||||
result.Changed.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_IgnoresLiveParticipantsThatThePresetDoesntName()
|
||||
{
|
||||
// Carol joined the meeting but the saved preset only references
|
||||
// Alice. Carol's row must NOT be touched (no enable / disable
|
||||
// / customName change).
|
||||
var controller = new StubIsoController();
|
||||
var alice = MakeParticipant(controller, "Alice", isEnabled: false);
|
||||
var carol = MakeParticipant(controller, "Carol", isEnabled: true);
|
||||
var carolCustomBefore = carol.CustomName;
|
||||
var preset = Preset(("Alice", true, null));
|
||||
|
||||
await PresetApplier.ApplyAsync(preset, new[] { alice, carol }, controller, dispatcher: null);
|
||||
|
||||
carol.IsEnabled.Should().BeTrue("Carol wasn't named, so her state stands");
|
||||
carol.CustomName.Should().Be(carolCustomBefore);
|
||||
controller.EnableCalls.Should().ContainSingle().Which.Id.Should().Be(alice.Id);
|
||||
controller.DisableCalls.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
namespace TeamsISO.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes any test class that mutates
|
||||
/// <c>OperatorPresetStore.PathOverride</c> — without this, xUnit runs
|
||||
/// fixtures in parallel across the assembly and a sibling class can
|
||||
/// clobber the path mid-test, leading to flakes that look like data
|
||||
/// corruption.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class PresetStoreCollection
|
||||
{
|
||||
public const string Name = "PresetStore (PathOverride mutators)";
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
using FluentAssertions;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace TeamsISO.App.Tests.Services;
|
||||
|
||||
// Unit tests for ThemeManager — exercise the resolve / set / toggle
|
||||
// state machine behind the test-only constructor that takes stub seams
|
||||
// instead of touching HKCU and %LOCALAPPDATA%. Apply() and the
|
||||
// SystemEvents subscription are intentionally NOT exercised here:
|
||||
// they require Application.Current and a real WPF dispatcher, both of
|
||||
// which would couple these tests to the host runtime.
|
||||
public sealed class ThemeManagerTests
|
||||
{
|
||||
private static ThemeManager NewManager(
|
||||
bool systemDark = true,
|
||||
string? initialPreference = null,
|
||||
Action<string>? captureSave = null) =>
|
||||
new ThemeManager(
|
||||
isSystemDark: () => systemDark,
|
||||
loadPreference: () => initialPreference,
|
||||
savePreference: captureSave ?? (_ => { }),
|
||||
subscribeToSystemPreference: false);
|
||||
|
||||
[Fact]
|
||||
public void Set_DarkThenLight_RoundTripsPreferenceAndResolution()
|
||||
{
|
||||
var saves = new List<string>();
|
||||
var tm = NewManager(systemDark: false, captureSave: saves.Add);
|
||||
|
||||
tm.Set("Dark");
|
||||
tm.Preference.Should().Be("Dark");
|
||||
tm.ResolveTheme().Should().Be("Dark");
|
||||
|
||||
tm.Set("Light");
|
||||
tm.Preference.Should().Be("Light");
|
||||
tm.ResolveTheme().Should().Be("Light");
|
||||
|
||||
saves.Should().Equal("Dark", "Light");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "Dark")]
|
||||
[InlineData(false, "Light")]
|
||||
public void ResolveTheme_FollowsSystem_WhenPreferenceIsSystem(bool isSystemDark, string expected)
|
||||
{
|
||||
var tm = NewManager(systemDark: isSystemDark, initialPreference: "System");
|
||||
tm.Preference.Should().Be("System");
|
||||
tm.ResolveTheme().Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Toggle_FromSystemDark_PinsToOppositeOfCurrent()
|
||||
{
|
||||
// System currently resolves to Dark → toggle should flip
|
||||
// *preference* to Light (the opposite of the currently-displayed
|
||||
// theme), not back to System. The point of the click is a
|
||||
// visible change.
|
||||
var tm = NewManager(systemDark: true, initialPreference: "System");
|
||||
|
||||
tm.Toggle();
|
||||
|
||||
tm.Preference.Should().Be("Light");
|
||||
tm.ResolveTheme().Should().Be("Light");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Toggle_FromSystemLight_PinsToOppositeOfCurrent()
|
||||
{
|
||||
var tm = NewManager(systemDark: false, initialPreference: "System");
|
||||
|
||||
tm.Toggle();
|
||||
|
||||
tm.Preference.Should().Be("Dark");
|
||||
tm.ResolveTheme().Should().Be("Dark");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Toggle_FromDark_FlipsToLight()
|
||||
{
|
||||
var tm = NewManager(initialPreference: "Dark");
|
||||
|
||||
tm.Toggle();
|
||||
|
||||
tm.Preference.Should().Be("Light");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Toggle_FromLight_FlipsToDark()
|
||||
{
|
||||
var tm = NewManager(initialPreference: "Light");
|
||||
|
||||
tm.Toggle();
|
||||
|
||||
tm.Preference.Should().Be("Dark");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("dark")] // case-sensitive — we accept exactly Dark
|
||||
[InlineData("LIGHT")]
|
||||
[InlineData("")]
|
||||
public void Set_RejectsInvalidPreferenceWithArgumentException(string bad)
|
||||
{
|
||||
var tm = NewManager();
|
||||
|
||||
var act = () => tm.Set(bad);
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("preference");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultsToSystem_WhenLoadReturnsNull()
|
||||
{
|
||||
// Simulates a fresh install / corrupt prefs file: loadPreference
|
||||
// returns null; the manager falls back to the in-memory default
|
||||
// of "System" rather than throwing.
|
||||
var tm = NewManager(initialPreference: null);
|
||||
|
||||
tm.Preference.Should().Be("System");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultsToSystem_WhenLoadReturnsInvalidValue()
|
||||
{
|
||||
// A prefs file written by a future version with an unknown
|
||||
// value mustn't poison the in-memory state — invalid loads
|
||||
// fall back to the default, same as a missing file.
|
||||
var tm = NewManager(initialPreference: "Rainbow");
|
||||
|
||||
tm.Preference.Should().Be("System");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_HonoursPersistedPreference()
|
||||
{
|
||||
var tm = NewManager(initialPreference: "Dark");
|
||||
|
||||
tm.Preference.Should().Be("Dark");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SurvivesLoadException()
|
||||
{
|
||||
// The production singleton hits disk via UIPreferences.Load; a
|
||||
// disk fault must NOT escape the ctor or the app loses theming
|
||||
// entirely. Verify the swallow.
|
||||
var tm = new ThemeManager(
|
||||
isSystemDark: () => true,
|
||||
loadPreference: () => throw new InvalidOperationException("disk faulted"),
|
||||
savePreference: _ => { },
|
||||
subscribeToSystemPreference: false);
|
||||
|
||||
tm.Preference.Should().Be("System");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace TeamsISO.App.Tests.Services;
|
||||
|
||||
// UpdateChecker unit tests.
|
||||
//
|
||||
// We don't exercise CheckAsync (the real HTTP call against Forgejo) —
|
||||
// tests must not depend on the network. Coverage instead:
|
||||
// • TryParseSemVer: version-comparison parsing across the inputs the
|
||||
// real release stream produces.
|
||||
// • CheckIfDueAsync throttle: a recent cooldown stamp short-circuits
|
||||
// and returns null *before* CheckAsync runs (which would otherwise
|
||||
// fire an HTTP request).
|
||||
public sealed class UpdateCheckerTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly string? _previousOverride;
|
||||
|
||||
public UpdateCheckerTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"teamsiso-update-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_previousOverride = UpdateChecker.StateDirectoryOverride;
|
||||
UpdateChecker.StateDirectoryOverride = _tempDir;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
UpdateChecker.StateDirectoryOverride = _previousOverride;
|
||||
try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); }
|
||||
catch { /* cleanup best-effort */ }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("v1.2.3", "1.2.3")]
|
||||
[InlineData("V1.2.3", "1.2.3")] // case-insensitive 'v' strip
|
||||
[InlineData("1.2.3", "1.2.3")]
|
||||
[InlineData("v1.2.3.4", "1.2.3.4")] // 4-segment .NET-style versions
|
||||
[InlineData("v1.2.3-alpha", "1.2.3")] // pre-release suffix stripped
|
||||
[InlineData("v1.2.3-beta.4", "1.2.3")]
|
||||
public void TryParseSemVer_AcceptsExpectedForms(string input, string expected)
|
||||
{
|
||||
UpdateChecker.TryParseSemVer(input).Should().Be(Version.Parse(expected));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-a-version")]
|
||||
[InlineData("v.invalid")]
|
||||
[InlineData("")]
|
||||
public void TryParseSemVer_ReturnsNullOnGarbage(string input)
|
||||
{
|
||||
UpdateChecker.TryParseSemVer(input).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseSemVer_OrderingIsSemantic()
|
||||
{
|
||||
// The CheckAsync comparison is "latest > current" — pin the
|
||||
// ordering across the version arc the release process actually
|
||||
// produces.
|
||||
var older = UpdateChecker.TryParseSemVer("v0.1.0")!;
|
||||
var newer = UpdateChecker.TryParseSemVer("v0.2.0")!;
|
||||
var newest = UpdateChecker.TryParseSemVer("v1.0.0")!;
|
||||
|
||||
(newer > older).Should().BeTrue();
|
||||
(newest > newer).Should().BeTrue();
|
||||
(newest > older).Should().BeTrue();
|
||||
(older > newer).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckIfDueAsync_ReturnsNull_WhenCooldownStampIsRecent()
|
||||
{
|
||||
// Pre-write a "we just checked" stamp. The throttle should
|
||||
// short-circuit and return null without firing the HTTP call,
|
||||
// which means the test passes deterministically offline.
|
||||
File.WriteAllText(
|
||||
Path.Combine(_tempDir, "last-update-check.txt"),
|
||||
DateTimeOffset.UtcNow.ToString("o"));
|
||||
|
||||
var result = await UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
|
||||
|
||||
result.Should().BeNull("a stamp inside the cooldown window suppresses the check");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckIfDueAsync_ReturnsNull_WhenStampIsOldButCooldownIsLargerThanGap()
|
||||
{
|
||||
// Edge case: stamp 1h old, cooldown 24h → still suppressed.
|
||||
File.WriteAllText(
|
||||
Path.Combine(_tempDir, "last-update-check.txt"),
|
||||
DateTimeOffset.UtcNow.AddHours(-1).ToString("o"));
|
||||
|
||||
var result = await UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LaunchCheckEnabled_RoundTrips()
|
||||
{
|
||||
// Default (no flag file) → enabled.
|
||||
UpdateChecker.LaunchCheckEnabled.Should().BeTrue();
|
||||
|
||||
UpdateChecker.LaunchCheckEnabled = false;
|
||||
UpdateChecker.LaunchCheckEnabled.Should().BeFalse(
|
||||
"writing the opt-out flag should be visible immediately");
|
||||
File.Exists(Path.Combine(_tempDir, "no-update-check.flag"))
|
||||
.Should().BeTrue();
|
||||
|
||||
UpdateChecker.LaunchCheckEnabled = true;
|
||||
UpdateChecker.LaunchCheckEnabled.Should().BeTrue();
|
||||
File.Exists(Path.Combine(_tempDir, "no-update-check.flag"))
|
||||
.Should().BeFalse("re-enabling should remove the opt-out flag");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using FluentAssertions;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace TeamsISO.App.Tests.Services;
|
||||
|
||||
// Round-trip tests for WindowStateStore.Save / TryApply. Constructing a
|
||||
// real WPF Window inside an xUnit fact is awkward (no Application.Run,
|
||||
// no dispatcher), so we exercise the JSON layer + the placement-validity
|
||||
// rejection logic by writing snapshots directly to disk and reading
|
||||
// them back. Save is exercised by serializing a Snapshot record
|
||||
// inline and asserting JsonSerializer can round-trip it through the
|
||||
// shape WindowStateStore writes.
|
||||
//
|
||||
// The full Window.Left/Width property writes inside TryApply aren't
|
||||
// covered here — they require a WPF Window instance, which means an
|
||||
// Application.Current + dispatcher. We instead cover the bail paths
|
||||
// (file missing, too-small, off-screen) which is where regressions
|
||||
// typically land.
|
||||
public sealed class WindowStateStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _tempPath;
|
||||
private readonly string? _previousOverride;
|
||||
|
||||
public WindowStateStoreTests()
|
||||
{
|
||||
_tempPath = Path.Combine(Path.GetTempPath(), $"teamsiso-window-{Guid.NewGuid():N}.json");
|
||||
_previousOverride = WindowStateStore.PathOverride;
|
||||
WindowStateStore.PathOverride = _tempPath;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
WindowStateStore.PathOverride = _previousOverride;
|
||||
try { if (File.Exists(_tempPath)) File.Delete(_tempPath); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
private static void WriteSnapshot(string path, WindowStateStore.Snapshot snap)
|
||||
{
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(snap, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_JsonRoundTrips_CleanlyThroughTheSameSerializerShape()
|
||||
{
|
||||
// Write a Snapshot record through the same JsonSerializer.Serialize
|
||||
// call WindowStateStore.Save uses; read it back and verify all
|
||||
// five fields survive. Coverage gap (Save's own Window reads)
|
||||
// intentional — see file header.
|
||||
var snap = new WindowStateStore.Snapshot(
|
||||
Left: 120, Top: 80, Width: 1024, Height: 768, State: WindowState.Maximized);
|
||||
WriteSnapshot(_tempPath, snap);
|
||||
|
||||
var roundTripped = JsonSerializer.Deserialize<WindowStateStore.Snapshot>(File.ReadAllText(_tempPath));
|
||||
|
||||
roundTripped.Should().NotBeNull();
|
||||
roundTripped!.Left.Should().Be(120);
|
||||
roundTripped.Top.Should().Be(80);
|
||||
roundTripped.Width.Should().Be(1024);
|
||||
roundTripped.Height.Should().Be(768);
|
||||
roundTripped.State.Should().Be(WindowState.Maximized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApply_NoFile_ReturnsFalse()
|
||||
{
|
||||
File.Exists(_tempPath).Should().BeFalse();
|
||||
|
||||
// We can't construct a Window without STA; we *can* exercise
|
||||
// the bail path that returns before any Window property is
|
||||
// touched by passing null and catching the NRE through the
|
||||
// store's own try/catch — which makes TryApply return false.
|
||||
var result = WindowStateStore.TryApply(null!);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApply_TooSmallSnapshot_RejectsBeforeTouchingWindow()
|
||||
{
|
||||
// 100×100 is below the 320×240 floor. TryApply should return
|
||||
// false without throwing on the null window.
|
||||
WriteSnapshot(_tempPath, new WindowStateStore.Snapshot(0, 0, 100, 100, WindowState.Normal));
|
||||
|
||||
var result = WindowStateStore.TryApply(null!);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApply_AbsurdlyLargeSnapshot_RejectsBeforeTouchingWindow()
|
||||
{
|
||||
// 20000×20000 is above the safety ceiling. Again no throw.
|
||||
WriteSnapshot(_tempPath, new WindowStateStore.Snapshot(0, 0, 20000, 20000, WindowState.Normal));
|
||||
|
||||
var result = WindowStateStore.TryApply(null!);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApply_FullyOffScreenSnapshot_RejectsBeforeTouchingWindow()
|
||||
{
|
||||
// Way off the virtual screen — no corner falls inside any
|
||||
// monitor's working area.
|
||||
WriteSnapshot(_tempPath, new WindowStateStore.Snapshot(
|
||||
Left: -99999, Top: -99999, Width: 800, Height: 600, State: WindowState.Normal));
|
||||
|
||||
var result = WindowStateStore.TryApply(null!);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApply_GarbageJson_ReturnsFalseRatherThanThrowing()
|
||||
{
|
||||
File.WriteAllText(_tempPath, "{ this is not valid json");
|
||||
|
||||
var result = WindowStateStore.TryApply(null!);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,17 +6,15 @@
|
|||
because TeamsISO.App is net8.0-windows + WinExe — a pure net8.0 test
|
||||
project can't reference it.
|
||||
|
||||
Tests cover services that are mostly framework-free, but
|
||||
ControlSurfaceServer transitively references System.Windows.Threading
|
||||
(DispatcherTimer) and System.Windows.Application — UseWPF=true pulls
|
||||
in those types so test code compiles against the App's project
|
||||
reference without "could not load type" errors at run time.
|
||||
We DON'T reference WPF or System.Windows here — the tests cover services
|
||||
that are intentionally framework-free even though they live in the host
|
||||
assembly. Future test cases that touch WPF types (e.g. WriteableBitmap)
|
||||
would need <UseWPF>true</UseWPF> added.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWPF>true</UseWPF>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
|
|
@ -31,7 +29,6 @@
|
|||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
<PackageReference Include="Xunit.StaFact" Version="1.1.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
using FluentAssertions;
|
||||
using TeamsISO.App.ViewModels;
|
||||
|
||||
namespace TeamsISO.App.Tests.ViewModels;
|
||||
|
||||
// Unit tests for the CommandPaletteViewModel.Matches predicate — the
|
||||
// case-insensitive Contains check across Label / Category / Keywords
|
||||
// that powers the v2 Ctrl+K filter.
|
||||
//
|
||||
// We don't build a full CommandPaletteViewModel here (that requires a
|
||||
// MainViewModel + IIsoController fake — out of scope). Matches is the
|
||||
// behaviorally-relevant unit; pinning it across a representative
|
||||
// query set guards against accidental regressions when someone adds a
|
||||
// scoring algorithm or swaps Contains for StartsWith.
|
||||
public sealed class CommandPaletteMatchesTests
|
||||
{
|
||||
private static PaletteCommand Cmd(string category, string label, string? keywords = null) =>
|
||||
new(category, label, keywords, Shortcut: null, Invoke: () => { });
|
||||
|
||||
[Theory]
|
||||
// Label substrings — the dominant match path
|
||||
[InlineData("Quick", "Stop all ISOs", null, "stop", true)]
|
||||
[InlineData("Quick", "Stop all ISOs", null, "STOP", true)] // case-insensitive
|
||||
[InlineData("Quick", "Stop all ISOs", null, "all", true)]
|
||||
[InlineData("Quick", "Stop all ISOs", null, "ISO", true)]
|
||||
// Category match — operator types the section name
|
||||
[InlineData("Teams", "Mute / unmute", null, "teams", true)]
|
||||
[InlineData("App", "Help", null, "app", true)]
|
||||
// Keywords match — synonym path. The Network/topology command has
|
||||
// "ndi groups isolate" in its keywords blob.
|
||||
[InlineData("Network", "Apply transcoder topology", "ndi groups isolate", "ndi", true)]
|
||||
[InlineData("Network", "Apply transcoder topology", "ndi groups isolate", "isolate", true)]
|
||||
// No-match — none of label/category/keywords contain the query
|
||||
[InlineData("Quick", "Stop all ISOs", null, "espresso", false)]
|
||||
[InlineData("Teams", "Mute / unmute", "microphone audio toggle", "monitor", false)]
|
||||
public void Matches_Predicate(string category, string label, string? keywords, string query, bool expected)
|
||||
{
|
||||
CommandPaletteViewModel.Matches(Cmd(category, label, keywords), query)
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_OperatorTypingShortToken_HitsExpectedCategorySpread()
|
||||
{
|
||||
// "mute" should match the Teams command but not the App theme
|
||||
// commands — pins the cross-category selectivity that makes
|
||||
// the palette useful at all. If a future change makes Matches
|
||||
// too permissive (e.g. by indexing the Invoke delegate's
|
||||
// method name), the second assertion catches it.
|
||||
var muteCmd = Cmd("Teams", "Mute / unmute", keywords: "microphone audio silence toggle");
|
||||
var themeCmd = Cmd("App", "Theme: dark", keywords: "appearance night mode");
|
||||
|
||||
CommandPaletteViewModel.Matches(muteCmd, "mute").Should().BeTrue();
|
||||
CommandPaletteViewModel.Matches(themeCmd, "mute").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_AcrossTheFullPaletteVocabulary_StaysDeterministic()
|
||||
{
|
||||
// Sanity check: a representative slice of the palette's real
|
||||
// commands gives stable matches for the most common operator
|
||||
// queries. Pin the count of hits for each query so a careless
|
||||
// refactor that flips the predicate's polarity blows up here
|
||||
// instead of in production.
|
||||
var commands = new[]
|
||||
{
|
||||
Cmd("Quick", "Enable all online", "ISOs enable everyone start everything live"),
|
||||
Cmd("Quick", "Stop all ISOs", "panic stop everything kill disable"),
|
||||
Cmd("Quick", "Refresh discovery", "rediscover sources rebuild finder NDI"),
|
||||
Cmd("Teams", "Mute / unmute", "microphone audio silence toggle"),
|
||||
Cmd("Teams", "Toggle camera", "video webcam on off"),
|
||||
Cmd("Teams", "Leave call", "exit end disconnect quit"),
|
||||
Cmd("Network", "Apply transcoder topology", "ndi groups isolate teamsiso-input private"),
|
||||
Cmd("App", "Theme: dark", "appearance night mode"),
|
||||
Cmd("App", "Theme: light", "appearance day mode bright"),
|
||||
Cmd("App", "Theme: follow Windows", "system auto"),
|
||||
Cmd("App", "Help", "shortcuts cheatsheet f1"),
|
||||
};
|
||||
|
||||
int Hits(string q) => commands.Count(c => CommandPaletteViewModel.Matches(c, q));
|
||||
|
||||
Hits("theme").Should().Be(3, "three App theme commands carry 'Theme' in the label");
|
||||
Hits("stop").Should().Be(1);
|
||||
Hits("ndi").Should().Be(2, "Refresh discovery (NDI in keywords) + Apply transcoder topology");
|
||||
// "App" matches case-insensitively against the four App-category
|
||||
// commands AND substring-matches inside "Apply transcoder topology" —
|
||||
// a real operator typing "app" would see five rows, which is
|
||||
// exactly what Contains delivers. Pinning this so a future move
|
||||
// to a stricter (StartsWith / token-boundary) algorithm has to
|
||||
// re-decide that affordance deliberately.
|
||||
Hits("App").Should().Be(5, "four App-category commands + 'Apply' transcoder topology");
|
||||
Hits("xyzzy").Should().Be(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -182,72 +182,6 @@ public class IsoControllerTests : IDisposable
|
|||
alerts.Should().Contain(a => a is EngineAlert.NdiRuntimeMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetRecording_TogglesEnabledAndStoresDirectory()
|
||||
{
|
||||
await using var controller = NewController();
|
||||
|
||||
controller.RecordingEnabled.Should().BeFalse();
|
||||
controller.RecordingDirectory.Should().BeNull();
|
||||
|
||||
controller.SetRecording(enabled: true, outputDirectory: @"D:\Recordings\Show1");
|
||||
|
||||
controller.RecordingEnabled.Should().BeTrue();
|
||||
controller.RecordingDirectory.Should().Be(@"D:\Recordings\Show1");
|
||||
|
||||
controller.SetRecording(enabled: false, outputDirectory: null);
|
||||
|
||||
controller.RecordingEnabled.Should().BeFalse();
|
||||
controller.RecordingDirectory.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddRecordingMarker_NoOpsCleanly_WhenNoActiveRecorders()
|
||||
{
|
||||
// No pipelines have ever started → no recorders are attached.
|
||||
// AddRecordingMarker must not throw on the empty-recorder path
|
||||
// (the UI Ctrl+M binding fires regardless of recording state).
|
||||
await using var controller = NewController();
|
||||
|
||||
var act = () => controller.AddRecordingMarker("test marker");
|
||||
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshDiscovery_SetsRefreshFlagOnDiscoveryService()
|
||||
{
|
||||
// RefreshDiscovery is a fire-and-forget that just sets a flag
|
||||
// the discovery loop honours on its next tick. We exercise it
|
||||
// and verify the loop subsequently re-emits the current source
|
||||
// set as freshly-added (which is the observable contract).
|
||||
await using var controller = NewController();
|
||||
var seenLists = new List<IReadOnlyList<Participant>>();
|
||||
using var sub = controller.Participants.Subscribe(p => seenLists.Add(p));
|
||||
|
||||
await controller.StartAsync(CancellationToken.None);
|
||||
_interop.Sources.Add("PC1 (Teams - Jane)");
|
||||
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (seenLists.LastOrDefault()?.Any() != true && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
seenLists.Last().Should().HaveCount(1);
|
||||
|
||||
var emitsBefore = seenLists.Count;
|
||||
|
||||
// Trigger a refresh — the discovery loop should re-emit. We
|
||||
// don't care exactly how many emissions land, just that the
|
||||
// observable kept producing rather than stalling.
|
||||
controller.RefreshDiscovery();
|
||||
|
||||
var refreshDeadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (seenLists.Count <= emitsBefore && DateTime.UtcNow < refreshDeadline)
|
||||
await Task.Delay(20);
|
||||
|
||||
seenLists.Count.Should().BeGreaterThan(emitsBefore,
|
||||
"the refresh flag should drive a re-emission within the discovery interval");
|
||||
}
|
||||
|
||||
private static async Task<Guid> WaitForFirstParticipantAsync(IsoController controller)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<Guid>();
|
||||
|
|
|
|||
10
src/tests/TeamsISO.Engine.Tests/SmokeTest.cs
Normal file
10
src/tests/TeamsISO.Engine.Tests/SmokeTest.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace TeamsISO.Engine.Tests;
|
||||
|
||||
public class SmokeTest
|
||||
{
|
||||
[Fact]
|
||||
public void TestProjectIsWired()
|
||||
{
|
||||
Assert.True(true);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue