Compare commits
11 commits
150dd3f029
...
84861dafa5
| Author | SHA1 | Date | |
|---|---|---|---|
| 84861dafa5 | |||
| 6505a3cab0 | |||
| d91f95379b | |||
| fbcc56289e | |||
| e96a30b76f | |||
| 1f07992100 | |||
| 2640739bfc | |||
| e67c02c2ff | |||
| d02a2c059b | |||
| 33fca8e955 | |||
| 37390026b3 |
60 changed files with 3773 additions and 2103 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -28,3 +28,6 @@ publish/
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local Claude session metadata
|
||||||
|
.claude/
|
||||||
|
|
|
||||||
85
CHANGELOG.md
85
CHANGELOG.md
|
|
@ -6,55 +6,46 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added — Ground-up GUI redesign (started 2026-05-12)
|
### Added — v2 "Studio Terminal" GUI (2026-05-13)
|
||||||
|
|
||||||
After greenlighting a from-the-scratch redesign and an explicit WinUI 3
|
The May 2026 ground-up redesign — explicit anti-reference to "the v1
|
||||||
replatform target, the May 2026 batch is followed by a major
|
GUI screamed AI made it" — landed on the WPF host
|
||||||
restructuring of the host UI. Highlights:
|
(`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/`.
|
||||||
|
|
||||||
- **PRODUCT.md** + **DESIGN.md**: impeccable context for the redesign.
|
- **PRODUCT.md** + **DESIGN.md**: impeccable context for the redesign.
|
||||||
Solo broadcast operator at 1:50am is the canonical persona; "vibe-coded
|
Solo broadcast operator at 1:50am is the canonical persona; "vibe-coded
|
||||||
GUI" is the explicit anti-reference. Tokens cover dark + light palettes
|
GUI" is the explicit anti-reference. Tokens cover dark + light palettes
|
||||||
with context-aware accent split (cyan surface fill stays bright in
|
with context-aware accent split (cyan surface fill stays bright in
|
||||||
both modes; cyan-as-text darkens to #0E7C82 on light for AA contrast).
|
both modes; cyan-as-text darkens to `#0E7C82` on light for AA contrast).
|
||||||
- **WinUI 3 host scaffold** as `src/TeamsISO.App.WinUI/` coexisting with
|
- **Theme system** (`Themes/Theme.Dark.xaml`, `Theme.Light.xaml`,
|
||||||
the existing WPF host. WindowsAppSDK 1.6 LTS, unpackaged mode, win-x64
|
`WildDragonTheme.xaml`) + `Services/ThemeManager.cs` singleton that
|
||||||
pinned RID, custom Bootstrap-aware `Program.Main`, post-build runtimeconfig
|
swaps the merged dictionary at runtime, reads
|
||||||
patch to drop the .NET-SDK-implicit `Microsoft.WindowsDesktop.App`
|
`HKCU\…\AppsUseLightTheme` for System mode, subscribes to
|
||||||
framework reference (WinUI 3 doesn't use it).
|
`SystemEvents.UserPreferenceChanged`, persists via
|
||||||
- **Redesigned MainWindow**: 64px rail with brand mark + nav +
|
`UIPreferences.Theme`. `Ctrl+T` toggles dark ↔ light.
|
||||||
engine-status puck; 44px custom title bar absorbing live pills
|
- **v2 main window shell**: default system title bar; 32px header (Wild
|
||||||
(session timer · REC count · disk free) + theme toggle; section header
|
Dragon mark + wordmark left, ⌘K / theme / settings icons right); 40px
|
||||||
with single Primary CTA + Secondary actions; participants list
|
transport strip (`● 02:14:32 PART 4 · LIVE 2 CTRL :9755`); body with
|
||||||
(ItemsRepeater stub pending DataGrid migration) at 64px row height
|
alert banner + update banner + action toolbar + participants
|
||||||
with cyan-left-border active-speaker treatment; conditional slim
|
DataGrid; conditional `IN CALL` meeting bar at bottom; slide-over
|
||||||
in-call control bar; 32px status bar.
|
settings drawer (420px from right) with OUTPUT / NETWORK / APP tabs.
|
||||||
- **ThemeManager service** holds the user theme preference
|
The v1 72px rail, the 380px permanent settings panel, and the
|
||||||
(System / Dark / Light), resolves via UISettings.GetColorValue when
|
six-column footer are gone.
|
||||||
System, broadcasts changes so the AppWindow title-bar buttons stay
|
- **Task 39 — participants table v2**: five columns (24px state LED,
|
||||||
in sync with the visual tree.
|
name + codec caption, 110px audio meter, 130px mono output name, 100px
|
||||||
- **Settings drawer** that slides in from the right (220ms ease-out-quart)
|
ISO pill), 52px rows, full-row active-speaker tint (replaces the v1
|
||||||
with five tabs (Appearance / Routing / Display / Control / Advanced).
|
left-edge stripe).
|
||||||
Appearance tab includes the theme tri-state picker + an accent palette
|
- **Task 40 — Ctrl+K command palette**: `Views/CommandPaletteWindow.xaml`
|
||||||
peek. Replaces the WPF host's 380px permanent settings panel.
|
+ `ViewModels/CommandPaletteViewModel.cs`. Centered 560×360 floating
|
||||||
- **Help / About / Onboarding** as ContentDialog-based surfaces. Help
|
window with fuzzy search across Quick / Teams / Presets / Output /
|
||||||
is the keyboard shortcut cheat sheet; About has Wild Dragon mark +
|
Network / App categories. ↑/↓ navigates, Enter invokes, Esc closes.
|
||||||
version + quick-access folder shortcuts; Onboarding is three numbered
|
- **Interactive HTML preview** at `docs/preview/redesigned-mainwindow.html`
|
||||||
steps for first launch (Install NDI Runtime / Enable Teams NDI / Pick
|
for stakeholders to see the v2 shell.
|
||||||
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
|
### Added — May 2026 feature batch
|
||||||
|
|
||||||
|
|
@ -252,14 +243,6 @@ For operators who want to launch TeamsISO and never look at the Teams UI:
|
||||||
- **Recording badge in footer shows elapsed duration** alongside the count
|
- **Recording badge in footer shows elapsed duration** alongside the count
|
||||||
(`REC 3 · 12:45`). Separate timer from the session timer because
|
(`REC 3 · 12:45`). Separate timer from the session timer because
|
||||||
recording can start AFTER the meeting begins.
|
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
|
- **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.
|
local user is muted or has their camera off, surfaces as coral pills.
|
||||||
Operator with auto-hide knows the local state without restoring Teams.
|
Operator with auto-hide knows the local state without restoring Teams.
|
||||||
|
|
|
||||||
13
DESIGN.md
13
DESIGN.md
|
|
@ -219,9 +219,10 @@ the same job with less visual noise.
|
||||||
|
|
||||||
**Single icon system, one stroke width, one optical size.** The previous GUI
|
**Single icon system, one stroke width, one optical size.** The previous GUI
|
||||||
inlined ~12 bespoke `<Path Data="...">` icons with stroke widths varying
|
inlined ~12 bespoke `<Path Data="...">` icons with stroke widths varying
|
||||||
between 1.2 and 1.6. The redesign uses **WinUI 3's bundled Segoe Fluent Icons
|
between 1.2 and 1.6. The redesign uses **Segoe Fluent Icons font** (shipped
|
||||||
font** as the baseline, with a custom subset added only where a broadcast
|
with Windows 11; falls back to Segoe MDL2 Assets on Windows 10) as the
|
||||||
concept isn't covered (e.g. NDI signal lock, ISO routing state).
|
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).
|
Sizes: 16 (inline), 20 (button), 24 (rail / hero).
|
||||||
Stroke: inherited from font; no hand-stroked paths.
|
Stroke: inherited from font; no hand-stroked paths.
|
||||||
|
|
@ -233,8 +234,8 @@ Stroke: inherited from font; no hand-stroked paths.
|
||||||
- Durations: 120ms for affordance feedback, 200ms for panel transitions,
|
- Durations: 120ms for affordance feedback, 200ms for panel transitions,
|
||||||
280ms hero (rarely used).
|
280ms hero (rarely used).
|
||||||
- No bounce. No elastic. No spring overshoots.
|
- No bounce. No elastic. No spring overshoots.
|
||||||
- **Never animate** layout properties. Animate `Translation` and `Opacity`
|
- **Never animate** layout properties. Animate `RenderTransform` and
|
||||||
(WinUI 3's composition layer handles these GPU-cheaply).
|
`Opacity` (WPF's composition layer handles these GPU-cheaply).
|
||||||
|
|
||||||
## Component decisions
|
## Component decisions
|
||||||
|
|
||||||
|
|
@ -329,7 +330,7 @@ no side-stripe borders, no glassmorphism). It does have:
|
||||||
## Migration boundary
|
## Migration boundary
|
||||||
|
|
||||||
The view-model surface in `src/TeamsISO.App/ViewModels/` is the contract.
|
The view-model surface in `src/TeamsISO.App/ViewModels/` is the contract.
|
||||||
The redesign rewrites everything in `Views/` (WinUI 3) but leaves view-model
|
The redesign rewrites `MainWindow.xaml` and `Themes/*` but leaves view-model
|
||||||
properties and commands untouched. Any place where the redesign needs a new
|
properties and commands untouched. Any place where the redesign needs a new
|
||||||
piece of view-model state, the contract widens via additive properties —
|
piece of view-model state, the contract widens via additive properties —
|
||||||
existing bindings keep working until the new view stops needing the old shape.
|
existing bindings keep working until the new view stops needing the old shape.
|
||||||
|
|
|
||||||
140
NEXT_STEPS.md
140
NEXT_STEPS.md
|
|
@ -1,89 +1,81 @@
|
||||||
# Where we left off — v2 "Studio Terminal" shell landed (2026-05-13 night)
|
# Where we left off — v2 "Studio Terminal" shell complete (2026-05-15)
|
||||||
|
|
||||||
## What's done (uncommitted on local main)
|
## What's done on main
|
||||||
|
|
||||||
**v2 redesign shape:** Approved brief at `docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`. Aesthetic register is "broadcast-engineering instrument" — Linear's keyboard-first density × Avid console legibility. Goes hard against the "screams AI" failure mode.
|
**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.
|
||||||
|
|
||||||
**PRODUCT.md + DESIGN.md** updated to reflect WPF as the host (WinUI 3 lines removed), v2 IA decisions absorbed, recording references softened, theme implementation rewritten for WPF DynamicResource swap.
|
**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.
|
||||||
|
|
||||||
**Theme system split:**
|
**Shell:**
|
||||||
- `src/TeamsISO.App/Themes/Theme.Dark.xaml` — color brushes only, dark variant
|
- Default Windows title bar (no custom chromeless caption buttons).
|
||||||
- `src/TeamsISO.App/Themes/Theme.Light.xaml` — color brushes only, light variant
|
- 32px header — Wild Dragon mark + "TeamsISO" wordmark left; three icon
|
||||||
- `src/TeamsISO.App/Themes/WildDragonTheme.xaml` — styles + control templates (no color brushes anymore; uses `DynamicResource` for every brush ref)
|
buttons right (⌘K command palette, theme toggle, settings gear).
|
||||||
- `src/TeamsISO.App/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`.
|
- 40px transport strip — single mono line:
|
||||||
- `App.xaml` merges Theme.Dark.xaml + WildDragonTheme.xaml by default; `App.xaml.cs.OnStartup` calls `ThemeManager.Current.Apply()` before MainWindow shows.
|
`● 02:14:32 PART 4 · LIVE 2 CTRL :9755`. Cyan dot + timer only when
|
||||||
- `UIPreferences.Prefs` record gets a new `Theme = "System"` field (forward-compatible — old json files still load).
|
at least one ISO live.
|
||||||
- New brush key: `Wd.Accent.CyanText` (Dark `#97EDF0` / Light `#0E7C82`) for cyan-as-text contrast on light canvas. The existing `Wd.Accent.Cyan` stays bright in both modes for fill use.
|
- 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.
|
||||||
|
|
||||||
**v2 main window shell:**
|
**Theme system:**
|
||||||
- Default Windows title bar (no more custom chromeless caption buttons — they looked generic and broke on DPI scaling).
|
- `Themes/Theme.Dark.xaml` + `Themes/Theme.Light.xaml` — color brushes
|
||||||
- 32px header: Wild Dragon mark + "TeamsISO" wordmark left; three icon buttons right (⌘K command palette, theme toggle, settings gear). The mark is small (20px) as a quality cue — click opens About.
|
only.
|
||||||
- 40px transport strip: mono-typed single line. `● 02:14:32 PART 4 · LIVE 2 CTRL :9755`. Cyan dot + timer only when at least one ISO live. CTRL cluster right-aligned in a Grid column.
|
- `Themes/WildDragonTheme.xaml` — styles + control templates (no color
|
||||||
- Body: alert banner + update banner + action toolbar (Enable / Refresh / Presets / Stop all + Teams launch/hide icons) + participants DataGrid.
|
brushes; every brush ref is `DynamicResource`).
|
||||||
- Conditional meeting bar at bottom — appears only when `IsTeamsInCall == true`, with Mute / Cam / Leave.
|
- `Services/ThemeManager.cs` — swaps the merged dictionary at runtime;
|
||||||
- 72px left rail: **gone**.
|
reads `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme`
|
||||||
- 380px permanent settings panel: **gone**.
|
for System mode; subscribes to `SystemEvents.UserPreferenceChanged`;
|
||||||
- Six-column footer: **gone**.
|
persists via `UIPreferences.Theme`.
|
||||||
- Settings: slide-over drawer overlay (420px from right) triggered by the header gear; OUTPUT / NETWORK / APP tabs (DISPLAY renamed to APP); same bindings as v1. Scrim click dismisses; Esc dismisses.
|
|
||||||
|
|
||||||
**Hotkeys preserved + new:**
|
**Task 39 — participants table v2 (LANDED).**
|
||||||
- F1 help, Ctrl+R refresh, Ctrl+Shift+S panic stop, 1–9 / NumPad 1–9 toggle Nth participant — all preserved.
|
Five columns: 24px state LED, name + codec caption, 110px audio meter,
|
||||||
- **Ctrl+T toggle theme (NEW)** — cycles dark ↔ light. Hooked through `MainViewModel.ToggleThemeCommand` → `ThemeManager.Toggle()`.
|
130px mono output name, 100px ISO pill. 52px rows. Full-row
|
||||||
- **Ctrl+K command palette (placeholder)** — currently opens the help dialog. Task 40 replaces this with a real fuzzy-search palette window.
|
active-speaker tint (replaces the v1 left-stripe).
|
||||||
|
|
||||||
**View-model additions (MainViewModel):**
|
**Task 40 — Ctrl+K command palette (LANDED).**
|
||||||
- `ParticipantCount` and `LiveCount` — feed the transport strip's "PART N · LIVE N" readout. Updated on the 1Hz stats tick.
|
`Views/CommandPaletteWindow.xaml` + `ViewModels/CommandPaletteViewModel.cs`
|
||||||
- `ToggleThemeCommand` — wraps `ThemeManager.Current.Toggle()`.
|
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.
|
||||||
|
|
||||||
**MainWindow code-behind cleanup:**
|
**Hotkeys:**
|
||||||
- Removed `OnMinimize`, `OnMaximizeRestore`, `OnClose`, `OnWindowStateChanged`, the maximize-icon swap logic (no more custom title bar).
|
- `F1` — help / cheat sheet
|
||||||
- Added `OnCommandPaletteClick`, `OnSettingsScrimClick`, `OnPreviewKeyDown` (Esc to close drawer).
|
- `Ctrl+K` (also `Ctrl+P`) — command palette
|
||||||
- `OnSettingsToggleClick` now toggles `SettingsDrawerOverlay.Visibility` (the slide-over) instead of toggling the v1 right-column width.
|
- `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
|
||||||
|
|
||||||
## To build, push, and demo
|
## 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
|
```powershell
|
||||||
cd "C:\Users\zacga\Documents\Claude\Projects\Teams ISO"
|
dotnet build TeamsISO.Windows.slnf -c Release
|
||||||
|
|
||||||
# Clear the corrupt local index from the earlier session
|
|
||||||
Remove-Item .git\index -Force -ErrorAction SilentlyContinue
|
|
||||||
git reset
|
|
||||||
|
|
||||||
# Build the WPF host
|
|
||||||
dotnet build src\TeamsISO.App\TeamsISO.App.csproj -c Release
|
|
||||||
|
|
||||||
# If the build is clean:
|
|
||||||
.\src\TeamsISO.App\bin\Release\net8.0-windows\TeamsISO.exe
|
.\src\TeamsISO.App\bin\Release\net8.0-windows\TeamsISO.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
In the running app, test:
|
The shipped helpers `build-and-test.ps1` and `commit-and-push.ps1`
|
||||||
1. **Theme toggle** — press `Ctrl+T`. Should swap dark ↔ light without restart. Header colors, surfaces, and text foregrounds all repaint.
|
wrap the build + test + push flow.
|
||||||
2. **System theme follow** — open Windows Settings → Personalization → Colors → "Choose your mode" → flip between Dark and Light. TeamsISO should track the OS automatically (default preference is `System`).
|
|
||||||
3. **Settings drawer** — click the header gear icon. 420px drawer slides in from the right with OUTPUT / NETWORK / APP tabs. Esc or click-scrim dismisses.
|
|
||||||
4. **Transport strip** — should show the session timer when at least one ISO goes live, and the PART/LIVE counts always.
|
|
||||||
5. **Conditional meeting bar** — only appears when Teams is in a call.
|
|
||||||
|
|
||||||
If anything regresses, the v1 shell is preserved in git history at `1d1ce6a` — easy rollback with `git reset --hard 1d1ce6a` then publish.
|
If something regresses, `1d1ce6a` is the rollback point for the WPF v1
|
||||||
|
shell (recording was axed at that commit), and `c271303` is the v2
|
||||||
## Commit + push when ready
|
shell-without-table-redesign rollback point.
|
||||||
|
|
||||||
```powershell
|
|
||||||
git add -A src/TeamsISO.App/ docs/ PRODUCT.md DESIGN.md
|
|
||||||
git commit -m "feat(wpf): v2 'Studio Terminal' shell — theme system, header, transport strip, drawer
|
|
||||||
|
|
||||||
- Theme split: Theme.Dark.xaml + Theme.Light.xaml + ThemeManager
|
|
||||||
- New shell: 32px header (mark + wordmark + 3 icons), 40px transport strip,
|
|
||||||
conditional meeting bar, slide-over settings drawer
|
|
||||||
- Removed: 72px rail, 380px permanent settings panel, 6-column footer,
|
|
||||||
custom chromeless title bar buttons
|
|
||||||
- Ctrl+T toggles theme; follows Windows app-mode by default
|
|
||||||
- Shape doc at docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md"
|
|
||||||
git push origin HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
## What's still queued (tasks 39 + 40)
|
|
||||||
|
|
||||||
- **Task 39** — Participants table redesign. The DataGrid columns in the v2 shell are still v1-style (Name / Source / ISO toggle). v2 wants: 5 columns (state LED, name+codec, audio meter, output name mono, ISO pill), 52px rows, full-row active-speaker tint instead of left stripe, hard-edged 8px state LED.
|
|
||||||
- **Task 40** — Real Ctrl+K command palette window. Floating 560×360 dialog, fuzzy search across categories (Quick / Teams / Presets / Output / Network / App). Replaces the current placeholder that opens the help dialog.
|
|
||||||
|
|
||||||
Both are scoped commits and depend on the v2 shell being confirmed working first. After this lands and you've verified the theme swap + drawer + transport strip render correctly, ping me and we'll knock those out.
|
|
||||||
|
|
|
||||||
33
README.md
33
README.md
|
|
@ -44,22 +44,21 @@ 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.
|
code-signing the MSI and a smoke pass against a real Teams meeting.
|
||||||
See `CHANGELOG.md` for the [Unreleased] entry.
|
See `CHANGELOG.md` for the [Unreleased] entry.
|
||||||
|
|
||||||
A ground-up GUI redesign is in flight on `main` (see
|
The May 2026 ground-up redesign — the v2 "Studio Terminal" shell — has
|
||||||
`docs/superpowers/plans/2026-05-12-winui3-migration.md`). The WPF host
|
landed on the WPF host (`src/TeamsISO.App/`). A WinUI 3 replatform was
|
||||||
(`src/TeamsISO.App/`) remains the shipping build; a parallel WinUI 3 host
|
explored in early May 2026 and abandoned (activation blockers + redundant
|
||||||
(`src/TeamsISO.App.WinUI/`) is scaffolded with the redesigned MainWindow,
|
work given the redesign is purely XAML / view-layer); the brief lives at
|
||||||
theme system (dark + light), and secondary surfaces. Activation of the
|
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`, and the
|
||||||
unpackaged WinUI 3 .exe is the current blocker — diagnostics in the
|
abandoned migration plan + bootstrap probe are archived under
|
||||||
migration plan's Phase 3.
|
`docs/archive/`.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
Requires .NET 8 SDK on Windows. The repo has two hosts:
|
Requires .NET 8 SDK on Windows. WPF is the only host:
|
||||||
|
|
||||||
- `src/TeamsISO.App` — WPF, `net8.0-windows`, current shipping build
|
- `src/TeamsISO.App` — WPF, `net8.0-windows`, the shipping build
|
||||||
- `src/TeamsISO.App.WinUI` — WinUI 3, `net8.0-windows10.0.19041.0`, in-flight
|
|
||||||
|
|
||||||
Both build together from the solution filter:
|
Build from the solution filter:
|
||||||
|
|
||||||
dotnet restore TeamsISO.Windows.slnf
|
dotnet restore TeamsISO.Windows.slnf
|
||||||
dotnet build TeamsISO.Windows.slnf -c Release
|
dotnet build TeamsISO.Windows.slnf -c Release
|
||||||
|
|
@ -80,21 +79,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)
|
- [Embedded Teams orchestration spec](docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md)
|
||||||
— Phase E roadmap.
|
— Phase E roadmap.
|
||||||
- [Redesign brief](PRODUCT.md) + [design system](DESIGN.md) — token-level
|
- [Redesign brief](PRODUCT.md) + [design system](DESIGN.md) — token-level
|
||||||
spec for the in-flight WinUI 3 redesign.
|
spec for the v2 "Studio Terminal" redesign.
|
||||||
- [WinUI 3 migration plan](docs/superpowers/plans/2026-05-12-winui3-migration.md)
|
- [v2 shape brief](docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md) —
|
||||||
— nine-phase plan covering scaffold through retiring the WPF host.
|
approved aesthetic + IA for the May 2026 WPF rebuild.
|
||||||
- [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
|
## Keyboard shortcuts
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `F1` | Open help / cheat sheet |
|
| `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 + M` | Drop a timestamped marker into every active recording |
|
||||||
| `Ctrl + Shift + S` | Stop every running ISO (emergency) |
|
| `Ctrl + Shift + S` | Stop every running ISO (emergency) |
|
||||||
| `Ctrl + R` | Refresh NDI discovery (rebuild finder) |
|
| `Ctrl + R` | Refresh NDI discovery (rebuild finder) |
|
||||||
|
| `1`–`9` / `NumPad 1`–`9` | Toggle the Nth visible participant's ISO |
|
||||||
|
|
||||||
## File locations
|
## File locations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
"src\\TeamsISO.Engine.NdiInterop\\TeamsISO.Engine.NdiInterop.csproj",
|
"src\\TeamsISO.Engine.NdiInterop\\TeamsISO.Engine.NdiInterop.csproj",
|
||||||
"src\\TeamsISO.Console\\TeamsISO.Console.csproj",
|
"src\\TeamsISO.Console\\TeamsISO.Console.csproj",
|
||||||
"src\\TeamsISO.App\\TeamsISO.App.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.Tests\\TeamsISO.Engine.Tests.csproj",
|
||||||
"src\\tests\\TeamsISO.Engine.IntegrationTests\\TeamsISO.Engine.IntegrationTests.csproj",
|
"src\\tests\\TeamsISO.Engine.IntegrationTests\\TeamsISO.Engine.IntegrationTests.csproj",
|
||||||
"src\\tests\\TeamsISO.App.Tests\\TeamsISO.App.Tests.csproj"
|
"src\\tests\\TeamsISO.App.Tests\\TeamsISO.App.Tests.csproj"
|
||||||
|
|
|
||||||
|
|
@ -1,443 +1,45 @@
|
||||||
# Commit + push the May 2026 polish batch to forge.wilddragon.net.
|
# Build + test verification, then push the current branch to origin.
|
||||||
#
|
#
|
||||||
# Run from the repo root:
|
# Run from the repo root:
|
||||||
# pwsh -ExecutionPolicy Bypass -File .\commit-and-push.ps1
|
# pwsh -ExecutionPolicy Bypass -File .\commit-and-push.ps1
|
||||||
#
|
#
|
||||||
# Splits into 8 atomic commits (#59, #61, #64-#69), then pushes origin/main.
|
# This is the operator's "I'm done with this branch, ship it" helper. It
|
||||||
# Stops on first error so you can resolve and re-run.
|
# 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.
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
# Ensure we're at repo root.
|
|
||||||
if (-not (Test-Path '.git') -or -not (Test-Path 'TeamsISO.sln')) {
|
if (-not (Test-Path '.git') -or -not (Test-Path 'TeamsISO.sln')) {
|
||||||
throw "Run from the TeamsISO repo root."
|
throw "Run from the TeamsISO repo root."
|
||||||
}
|
}
|
||||||
|
|
||||||
# Tidy up the diagnostic artifact I left while probing the sandbox.
|
# Step 1 — build + tests must be green before anything ships.
|
||||||
if (Test-Path '.claude-bash-test.txt') {
|
Write-Host "──── Build + test ────" -ForegroundColor Cyan
|
||||||
Remove-Item '.claude-bash-test.txt' -Force
|
pwsh -NoProfile -ExecutionPolicy Bypass -File '.\build-and-test.ps1'
|
||||||
Write-Host "Removed sandbox diagnostic file." -ForegroundColor DarkGray
|
if ($LASTEXITCODE -ne 0) { throw "build-and-test.ps1 failed; aborting." }
|
||||||
}
|
|
||||||
|
|
||||||
# ─── helper ─────────────────────────────────────────────────────────────
|
# Step 2 — what are we pushing? Surface the branch + commit summary so
|
||||||
function Stage-AndCommit($message, [string[]]$paths) {
|
# the operator sees the exact thing about to land on the remote.
|
||||||
Write-Host ""
|
$branch = (git rev-parse --abbrev-ref HEAD).Trim()
|
||||||
Write-Host "──── $message ────" -ForegroundColor Cyan
|
if ($branch -eq 'HEAD') { throw "Detached HEAD; check out a branch before running this script." }
|
||||||
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 ""
|
||||||
Write-Host "Done. Commits pushed to forge.wilddragon.net/zgaetano/teamsiso." -ForegroundColor Green
|
Write-Host "──── Pushing $branch to origin ────" -ForegroundColor Cyan
|
||||||
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
|
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
|
||||||
|
|
|
||||||
250
src/TeamsISO.App/App.Bootstrap.cs
Normal file
250
src/TeamsISO.App/App.Bootstrap.cs
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/TeamsISO.App/App.CrashHandlers.cs
Normal file
93
src/TeamsISO.App/App.CrashHandlers.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/TeamsISO.App/App.UpdateCheckBootstrap.cs
Normal file
42
src/TeamsISO.App/App.UpdateCheckBootstrap.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
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,22 +1,29 @@
|
||||||
using System.IO;
|
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Interop;
|
using System.Windows.Interop;
|
||||||
using System.Windows.Threading;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using TeamsISO.App.ViewModels;
|
using TeamsISO.App.ViewModels;
|
||||||
using TeamsISO.Engine.Controller;
|
using TeamsISO.Engine.Controller;
|
||||||
using TeamsISO.Engine.Interop;
|
|
||||||
using TeamsISO.Engine.Logging;
|
using TeamsISO.Engine.Logging;
|
||||||
using TeamsISO.Engine.NdiInterop;
|
using TeamsISO.Engine.NdiInterop;
|
||||||
using TeamsISO.Engine.Persistence;
|
|
||||||
using TeamsISO.Engine.Pipeline;
|
|
||||||
|
|
||||||
// Application + MessageBox aliases live in GlobalUsings.cs (project-wide).
|
// Application + MessageBox aliases live in GlobalUsings.cs (project-wide).
|
||||||
// Don't redeclare here — Roslyn errors with CS1537 on duplicate alias.
|
// Don't redeclare here — Roslyn errors with CS1537 on duplicate alias.
|
||||||
|
|
||||||
namespace TeamsISO.App;
|
namespace 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
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -82,45 +89,20 @@ public partial class App : Application
|
||||||
// in place; DynamicResource refs in WildDragonTheme.xaml re-bind.
|
// in place; DynamicResource refs in WildDragonTheme.xaml re-bind.
|
||||||
TeamsISO.App.Services.ThemeManager.Current.Apply();
|
TeamsISO.App.Services.ThemeManager.Current.Apply();
|
||||||
|
|
||||||
// Single-instance gate: if another TeamsISO is already running for this user,
|
// Single-instance gate. Implementation in App.Bootstrap.cs; we
|
||||||
// broadcast the bring-to-front message and exit silently. This prevents the
|
// bail silently if another instance already owns the mutex (the
|
||||||
// NDI/config contention seen during testing where two finders, two senders
|
// existing instance gets surfaced via the bring-to-front broadcast).
|
||||||
// with the same default name, and two writers to config.json all raced.
|
if (!TryAcquireSingleInstance())
|
||||||
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);
|
Shutdown(0);
|
||||||
return;
|
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
|
try
|
||||||
{
|
{
|
||||||
// WPF host: write to both console (visible if attached) and a rolling daily
|
// WPF host: write to both console (visible if attached) and a
|
||||||
// file under %LOCALAPPDATA%\TeamsISO\Logs so users have something to grab when
|
// rolling daily file under %LOCALAPPDATA%\TeamsISO\Logs so users
|
||||||
// they file an issue.
|
// have something to grab when they file an issue.
|
||||||
_loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
|
_loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
|
||||||
var logger = _loggerFactory.CreateLogger<App>();
|
var logger = _loggerFactory.CreateLogger<App>();
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
|
|
@ -128,180 +110,26 @@ public partial class App : Application
|
||||||
typeof(App).Assembly.GetName().Version,
|
typeof(App).Assembly.GetName().Version,
|
||||||
Environment.ProcessId);
|
Environment.ProcessId);
|
||||||
|
|
||||||
// ---- Preflight: NDI runtime ----
|
if (!TryBootstrapNdiInterop())
|
||||||
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);
|
Shutdown(2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Engine wiring ----
|
BootstrapEngine();
|
||||||
var configPath = Path.Combine(
|
var window = ConstructAndShowMainWindow();
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
BootstrapControlSurfaceServices();
|
||||||
"TeamsISO", "config.json");
|
BootstrapTrayIcon(window);
|
||||||
var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger<ConfigStore>());
|
TryShowOnboarding(window);
|
||||||
|
|
||||||
var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix);
|
// Parse CLI args BEFORE InitializeAsync so any --apply-preset
|
||||||
var scaler = new ManagedNearestNeighborFrameScaler();
|
// request overrides the persisted auto-apply preference cleanly.
|
||||||
|
|
||||||
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);
|
ApplyCommandLineArgs(e.Args);
|
||||||
|
|
||||||
await _viewModel.InitializeAsync(CancellationToken.None);
|
await _viewModel!.InitializeAsync(CancellationToken.None);
|
||||||
|
|
||||||
// Auto-launch Teams in the background if the operator has opted in.
|
TryAutoLaunchTeams(logger);
|
||||||
// Combined with AutoHideTeamsWindows this gives the "I only see
|
StartBackgroundUpdateCheck(logger);
|
||||||
// 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -321,15 +149,6 @@ 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>
|
/// <summary>
|
||||||
/// Parse the supported CLI flags. Currently:
|
/// Parse the supported CLI flags. Currently:
|
||||||
/// <c>--apply-preset NAME</c> — apply the named preset once participants
|
/// <c>--apply-preset NAME</c> — apply the named preset once participants
|
||||||
|
|
@ -356,70 +175,9 @@ public partial class App : Application
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnAppDomainUnhandled(object sender, UnhandledExceptionEventArgs e)
|
// Crash handlers (OnAppDomainUnhandled / OnDispatcherUnhandled /
|
||||||
{
|
// OnUnobservedTaskException / TryLogFatal / TryShowCrashDialog / LogDirectory)
|
||||||
// IsTerminating is almost always true here — finalizers and managed-thread
|
// live in App.CrashHandlers.cs.
|
||||||
// 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)
|
protected override async void OnExit(ExitEventArgs e)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
FalseValue="Visible"/>
|
FalseValue="Visible"/>
|
||||||
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
|
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
|
||||||
<conv:LevelThresholdConverter x:Key="LevelGate"/>
|
<conv:LevelThresholdConverter x:Key="LevelGate"/>
|
||||||
|
<conv:CountToVisibilityConverter x:Key="CountToVis"/>
|
||||||
</Window.Resources>
|
</Window.Resources>
|
||||||
|
|
||||||
<Window.InputBindings>
|
<Window.InputBindings>
|
||||||
|
|
@ -138,7 +139,7 @@
|
||||||
Command="{Binding ToggleThemeCommand}"
|
Command="{Binding ToggleThemeCommand}"
|
||||||
Padding="6,4"
|
Padding="6,4"
|
||||||
Margin="0,0,2,0"
|
Margin="0,0,2,0"
|
||||||
ToolTip="Toggle theme (Ctrl+T)">
|
ToolTip="Theme (System / Dark / Light)">
|
||||||
<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"
|
<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}"
|
Stroke="{DynamicResource Wd.Text.Secondary}"
|
||||||
StrokeThickness="1.4"
|
StrokeThickness="1.4"
|
||||||
|
|
@ -410,7 +411,7 @@
|
||||||
<!--
|
<!--
|
||||||
Participants table — v2 "Studio Terminal" layout.
|
Participants table — v2 "Studio Terminal" layout.
|
||||||
|
|
||||||
Five columns:
|
Spec columns (from docs/shapes/2026-05-13-…studio-terminal.md):
|
||||||
1. State LED 24px — 8×8 hard-edged square. Filled cyan
|
1. State LED 24px — 8×8 hard-edged square. Filled cyan
|
||||||
when LIVE; filled coral on ERROR;
|
when LIVE; filled coral on ERROR;
|
||||||
filled amber on NO SIGNAL / STARTING;
|
filled amber on NO SIGNAL / STARTING;
|
||||||
|
|
@ -422,12 +423,22 @@
|
||||||
each lit when DisplayedAudioLevel
|
each lit when DisplayedAudioLevel
|
||||||
crosses its threshold (0.2, 0.4,
|
crosses its threshold (0.2, 0.4,
|
||||||
0.6, 0.8, 1.0). No averaging.
|
0.6, 0.8, 1.0). No averaging.
|
||||||
4. Output name 150px — JetBrains Mono 12 — the NDI source
|
4. Output name 130px — JetBrains Mono 12 — the NDI source
|
||||||
name TeamsISO broadcasts as.
|
name TeamsISO broadcasts as.
|
||||||
5. ISO toggle pill 110px — LIVE = cyan-muted fill with cyan
|
5. ISO toggle pill 100px — LIVE = cyan-muted fill with cyan
|
||||||
text; OFF = hollow neutral; ERROR
|
text; OFF = hollow neutral; ERROR
|
||||||
gets the existing trigger swap.
|
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.
|
||||||
|
|
||||||
Row height 52 (down from 56). Active speaker = full-row
|
Row height 52 (down from 56). Active speaker = full-row
|
||||||
bg.active-speaker tint set by the global DataGridRow style
|
bg.active-speaker tint set by the global DataGridRow style
|
||||||
(avoids the impeccable side-stripe-border ban).
|
(avoids the impeccable side-stripe-border ban).
|
||||||
|
|
@ -438,6 +449,7 @@
|
||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
CornerRadius="{StaticResource Radius.M}"
|
CornerRadius="{StaticResource Radius.M}"
|
||||||
Background="{DynamicResource Wd.Surface}">
|
Background="{DynamicResource Wd.Surface}">
|
||||||
|
<Grid>
|
||||||
<DataGrid x:Name="ParticipantsGrid"
|
<DataGrid x:Name="ParticipantsGrid"
|
||||||
ItemsSource="{Binding ParticipantsView}"
|
ItemsSource="{Binding ParticipantsView}"
|
||||||
AutoGenerateColumns="False"
|
AutoGenerateColumns="False"
|
||||||
|
|
@ -451,7 +463,8 @@
|
||||||
CanUserResizeRows="False"
|
CanUserResizeRows="False"
|
||||||
SelectionMode="Single"
|
SelectionMode="Single"
|
||||||
SelectionUnit="FullRow"
|
SelectionUnit="FullRow"
|
||||||
RowHeight="52">
|
RowHeight="52"
|
||||||
|
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}}">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<!-- Col 1 — State LED. 8×8 hard-edged Rectangle.
|
<!-- Col 1 — State LED. 8×8 hard-edged Rectangle.
|
||||||
Default fill is hollow (transparent with stroke). DataTriggers
|
Default fill is hollow (transparent with stroke). DataTriggers
|
||||||
|
|
@ -601,7 +614,7 @@
|
||||||
|
|
||||||
<!-- Col 4 — Output name (mono). The NDI source name TeamsISO
|
<!-- Col 4 — Output name (mono). The NDI source name TeamsISO
|
||||||
will broadcast this participant as. -->
|
will broadcast this participant as. -->
|
||||||
<DataGridTemplateColumn Header="Output" Width="150" IsReadOnly="True">
|
<DataGridTemplateColumn Header="Output" Width="130" IsReadOnly="True">
|
||||||
<DataGridTemplateColumn.CellTemplate>
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<TextBlock Text="{Binding OutputName}"
|
<TextBlock Text="{Binding OutputName}"
|
||||||
|
|
@ -639,7 +652,7 @@
|
||||||
|
|
||||||
<!-- Col 5 — ISO toggle pill. LIVE = cyan-muted fill + cyan border + cyan text.
|
<!-- Col 5 — ISO toggle pill. LIVE = cyan-muted fill + cyan border + cyan text.
|
||||||
OFF = hollow neutral. Error states use the existing IsoToggle style. -->
|
OFF = hollow neutral. Error states use the existing IsoToggle style. -->
|
||||||
<DataGridTemplateColumn Header="ISO" Width="110">
|
<DataGridTemplateColumn Header="ISO" Width="100">
|
||||||
<DataGridTemplateColumn.CellTemplate>
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Button Command="{Binding ToggleIsoCommand}"
|
<Button Command="{Binding ToggleIsoCommand}"
|
||||||
|
|
@ -665,6 +678,30 @@
|
||||||
</DataGridTemplateColumn>
|
</DataGridTemplateColumn>
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
</DataGrid>
|
</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>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,12 @@ public partial class MainWindow : Window
|
||||||
/// <summary>Persist the placement on close so next launch lands in the same spot.</summary>
|
/// <summary>Persist the placement on close so next launch lands in the same spot.</summary>
|
||||||
private void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
|
private void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||||
{
|
{
|
||||||
WindowStateStore.Save(this);
|
// 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 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary>
|
/// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary>
|
||||||
|
|
@ -87,8 +92,8 @@ public partial class MainWindow : Window
|
||||||
if (!TeamsLauncher.IsRunning())
|
if (!TeamsLauncher.IsRunning())
|
||||||
{
|
{
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
"Microsoft Teams isn't running. Click the camera icon above to launch it first.",
|
Properties.Strings.HideShowTeams_NotRunning_Message,
|
||||||
"TeamsISO — Hide / show Teams",
|
Properties.Strings.HideShowTeams_Title,
|
||||||
MessageBoxButton.OK,
|
MessageBoxButton.OK,
|
||||||
MessageBoxImage.Information);
|
MessageBoxImage.Information);
|
||||||
return;
|
return;
|
||||||
|
|
@ -125,8 +130,8 @@ public partial class MainWindow : Window
|
||||||
if (!TeamsLauncher.TryLaunch(out var error))
|
if (!TeamsLauncher.TryLaunch(out var error))
|
||||||
{
|
{
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
$"Could not launch Microsoft Teams.\n\n{error}",
|
string.Format(Properties.Strings.LaunchTeams_Failed_MessageFormat, error),
|
||||||
"TeamsISO — Launch Teams",
|
Properties.Strings.LaunchTeams_Title,
|
||||||
MessageBoxButton.OK,
|
MessageBoxButton.OK,
|
||||||
MessageBoxImage.Warning);
|
MessageBoxImage.Warning);
|
||||||
}
|
}
|
||||||
|
|
@ -159,15 +164,19 @@ public partial class MainWindow : Window
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Right-click on the Launch button asks to stop Teams. Split out from the
|
/// 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
|
/// left-click so a normal click is "open / surface" rather than the previous
|
||||||
/// "open OR ambush you with a stop dialog".
|
/// "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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void OnLaunchTeamsRightClick(object sender, MouseButtonEventArgs e)
|
private void OnLaunchTeamsRightClick(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
if (!TeamsLauncher.IsRunning()) return;
|
if (!TeamsLauncher.IsRunning()) return;
|
||||||
|
|
||||||
var confirm = MessageBox.Show(
|
var confirm = MessageBox.Show(
|
||||||
"Microsoft Teams is currently running.\n\nClose all Teams windows now?",
|
Properties.Strings.StopTeams_Confirm_Message,
|
||||||
"TeamsISO — Stop Teams",
|
Properties.Strings.StopTeams_Title,
|
||||||
MessageBoxButton.YesNo,
|
MessageBoxButton.YesNo,
|
||||||
MessageBoxImage.Question);
|
MessageBoxImage.Question);
|
||||||
if (confirm != MessageBoxResult.Yes) return;
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
|
|
@ -177,9 +186,9 @@ public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
asked == 0
|
asked == 0
|
||||||
? "No Teams windows responded to close."
|
? Properties.Strings.StopTeams_NoneResponded
|
||||||
: $"Sent close to {asked} Teams window(s); some may still be exiting.",
|
: string.Format(Properties.Strings.StopTeams_AskedFormat, asked),
|
||||||
"TeamsISO — Stop Teams",
|
Properties.Strings.StopTeams_Title,
|
||||||
MessageBoxButton.OK,
|
MessageBoxButton.OK,
|
||||||
MessageBoxImage.Information);
|
MessageBoxImage.Information);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
src/TeamsISO.App/Properties/Strings.Designer.cs
generated
Normal file
36
src/TeamsISO.App/Properties/Strings.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
75
src/TeamsISO.App/Properties/Strings.resx
Normal file
75
src/TeamsISO.App/Properties/Strings.resx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?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>
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
// /teams/* route handlers — UIAutomation-driven in-call controls.
|
||||||
|
//
|
||||||
|
// POST /teams/mute → InvokeTeams(ToggleMute, "mute")
|
||||||
|
// POST /teams/camera → InvokeTeams(ToggleCamera, "camera")
|
||||||
|
// POST /teams/leave → InvokeTeams(LeaveCall, "leave")
|
||||||
|
// POST /teams/share → InvokeTeams(OpenShareTray, "share")
|
||||||
|
// POST /teams/raise-hand → InvokeTeams(ToggleRaiseHand, "raise-hand")
|
||||||
|
public sealed partial class ControlSurfaceServer
|
||||||
|
{
|
||||||
|
private object InvokeTeams(Func<TeamsControlBridge.InvokeResult> invoke, string action)
|
||||||
|
{
|
||||||
|
var result = invoke();
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
ok = result == TeamsControlBridge.InvokeResult.Invoked,
|
||||||
|
action,
|
||||||
|
result = result.ToString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
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.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/TeamsISO.App/Services/ControlSurface/WebSocketHub.cs
Normal file
147
src/TeamsISO.App/Services/ControlSurface/WebSocketHub.cs
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
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,7 +44,10 @@ namespace TeamsISO.App.Services;
|
||||||
/// either via JSON body or via query string (?enabled=true&customName=Host).
|
/// either via JSON body or via query string (?enabled=true&customName=Host).
|
||||||
/// This is friendly to Companion's "URL with query string" mode.
|
/// This is friendly to Companion's "URL with query string" mode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ControlSurfaceServer : IAsyncDisposable
|
// 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 const int DefaultPort = 9755;
|
public const int DefaultPort = 9755;
|
||||||
|
|
||||||
|
|
@ -340,680 +343,16 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── handlers ───────────────────────────────────────────────────────
|
// ─── handlers ───────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
private object GetServerInfo()
|
// Endpoint bodies live in Services/ControlSurface/Endpoints/*.cs as
|
||||||
{
|
// partials of this class. See HomeEndpoints, ParticipantsEndpoints,
|
||||||
// Best-effort engine snapshot — wrapped in try/catch so a transient
|
// PresetsEndpoints, TeamsEndpoints, TopologyEndpoints, NotesEndpoints,
|
||||||
// controller error doesn't 500 the homepage poll.
|
// and ThumbnailEndpoint. The WebSocket push surface is at
|
||||||
var settings = TryRead(() => _controller.GlobalSettings);
|
// Services/ControlSurface/WebSocketHub.cs.
|
||||||
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.")]
|
[SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")]
|
||||||
private object NotFound() => new { error = "not found" };
|
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 ────────────────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static async Task<JsonElement> ReadBodyAsync(HttpListenerRequest req)
|
private static async Task<JsonElement> ReadBodyAsync(HttpListenerRequest req)
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,16 @@ public static class NotesService
|
||||||
{
|
{
|
||||||
private static readonly object _gate = new();
|
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 =>
|
private static string NotesDirectory =>
|
||||||
Path.Combine(
|
DirectoryOverride ?? Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
"TeamsISO", "Notes");
|
"TeamsISO", "Notes");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,10 @@ public sealed class OscBridge : IAsyncDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DispatchAsync(OscMessage msg)
|
// 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)
|
||||||
{
|
{
|
||||||
var addr = msg.Address;
|
var addr = msg.Address;
|
||||||
switch (addr)
|
switch (addr)
|
||||||
|
|
|
||||||
177
src/TeamsISO.App/Services/TeamsEmbedHost.cs
Normal file
177
src/TeamsISO.App/Services/TeamsEmbedHost.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
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,168 +271,6 @@ public static class TeamsLauncher
|
||||||
private static extern int GetWindowTextLengthW(IntPtr hWnd);
|
private static extern int GetWindowTextLengthW(IntPtr hWnd);
|
||||||
|
|
||||||
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
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>
|
/// <summary>
|
||||||
/// Returns the title bar text of Teams' most-recently-used top-level
|
/// Returns the title bar text of Teams' most-recently-used top-level
|
||||||
/// window, or empty string if Teams isn't running. Modern Teams puts
|
/// window, or empty string if Teams isn't running. Modern Teams puts
|
||||||
|
|
@ -482,7 +320,14 @@ public static class TeamsLauncher
|
||||||
/// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is
|
/// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is
|
||||||
/// not a tooltip or popup of another). Used by Hide/Show.
|
/// not a tooltip or popup of another). Used by Hide/Show.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<IntPtr> FindTeamsTopLevelWindows()
|
/// <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()
|
||||||
{
|
{
|
||||||
var teamsPids = new HashSet<uint>(
|
var teamsPids = new HashSet<uint>(
|
||||||
TeamsProcessNames
|
TeamsProcessNames
|
||||||
|
|
@ -509,7 +354,7 @@ public static class TeamsLauncher
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static int HideWindows()
|
public static int HideWindows()
|
||||||
{
|
{
|
||||||
var windows = FindTeamsTopLevelWindows();
|
var windows = EnumerateTopLevelTeamsWindows();
|
||||||
foreach (var w in windows) ShowWindow(w, SW_HIDE);
|
foreach (var w in windows) ShowWindow(w, SW_HIDE);
|
||||||
return windows.Count;
|
return windows.Count;
|
||||||
}
|
}
|
||||||
|
|
@ -610,7 +455,7 @@ public static class TeamsLauncher
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool SendShortcut(ShortcutModifiers modifiers, int virtualKey)
|
public static bool SendShortcut(ShortcutModifiers modifiers, int virtualKey)
|
||||||
{
|
{
|
||||||
var windows = FindTeamsTopLevelWindows();
|
var windows = EnumerateTopLevelTeamsWindows();
|
||||||
if (windows.Count == 0) return false;
|
if (windows.Count == 0) return false;
|
||||||
var hwnd = windows[^1];
|
var hwnd = windows[^1];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,35 +25,60 @@ namespace TeamsISO.App.Services;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ThemeManager
|
public sealed class ThemeManager
|
||||||
{
|
{
|
||||||
public static ThemeManager Current { get; } = new();
|
public static ThemeManager Current { get; } = new(
|
||||||
|
isSystemDark: ReadSystemDarkFromRegistry,
|
||||||
|
loadPreference: TryLoadPreferenceFromDisk,
|
||||||
|
savePreference: TrySavePreferenceToDisk,
|
||||||
|
subscribeToSystemPreference: true);
|
||||||
|
|
||||||
private const string DarkUri = "/Themes/Theme.Dark.xaml";
|
// Pack URIs (rather than relative "/Themes/…") so the resolution
|
||||||
private const string LightUri = "/Themes/Theme.Light.xaml";
|
// 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 PreferenceKeySystem = "System";
|
private const string PreferenceKeySystem = "System";
|
||||||
private const string PreferenceKeyDark = "Dark";
|
private const string PreferenceKeyDark = "Dark";
|
||||||
private const string PreferenceKeyLight = "Light";
|
private const string PreferenceKeyLight = "Light";
|
||||||
|
|
||||||
private ThemeManager()
|
// 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)
|
||||||
{
|
{
|
||||||
// Hydrate preference from disk on first access. UIPreferences.Load()
|
_isSystemDark = isSystemDark;
|
||||||
// is best-effort — disk failures fall back to defaults so the app
|
_savePreference = savePreference;
|
||||||
// always boots into a deterministic theme.
|
|
||||||
|
// Hydrate preference from the seam on first access. Disk / load
|
||||||
|
// failures fall back to defaults so the app always boots into a
|
||||||
|
// deterministic theme.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var prefs = UIPreferences.Load();
|
var loaded = loadPreference();
|
||||||
if (IsValidPreference(prefs.Theme))
|
if (IsValidPreference(loaded))
|
||||||
{
|
{
|
||||||
_preference = prefs.Theme;
|
_preference = loaded!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Defensive — singleton ctor must not throw or the app loses theming.
|
// Defensive — ctor must not throw or the app loses theming.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-evaluate when Windows app-mode flips, but only when the
|
// Re-evaluate when Windows app-mode flips, but only when the
|
||||||
// operator hasn't pinned a preference. The explicit choice wins.
|
// operator hasn't pinned a preference. The explicit choice wins.
|
||||||
SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged;
|
// Tests opt out so they don't latch into a process-wide event.
|
||||||
|
if (subscribeToSystemPreference)
|
||||||
|
{
|
||||||
|
SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string _preference = PreferenceKeySystem;
|
private string _preference = PreferenceKeySystem;
|
||||||
|
|
@ -73,7 +98,7 @@ public sealed class ThemeManager
|
||||||
{
|
{
|
||||||
PreferenceKeyDark => PreferenceKeyDark,
|
PreferenceKeyDark => PreferenceKeyDark,
|
||||||
PreferenceKeyLight => PreferenceKeyLight,
|
PreferenceKeyLight => PreferenceKeyLight,
|
||||||
_ => IsSystemDark() ? PreferenceKeyDark : PreferenceKeyLight,
|
_ => _isSystemDark() ? PreferenceKeyDark : PreferenceKeyLight,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -89,7 +114,7 @@ public sealed class ThemeManager
|
||||||
}
|
}
|
||||||
|
|
||||||
_preference = preference;
|
_preference = preference;
|
||||||
try { UIPreferences.SetTheme(preference); }
|
try { _savePreference(preference); }
|
||||||
catch { /* persistence is best-effort */ }
|
catch { /* persistence is best-effort */ }
|
||||||
Apply();
|
Apply();
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +169,7 @@ public sealed class ThemeManager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Relative) };
|
var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Absolute) };
|
||||||
if (old is null)
|
if (old is null)
|
||||||
{
|
{
|
||||||
dicts.Insert(0, fresh);
|
dicts.Insert(0, fresh);
|
||||||
|
|
@ -160,9 +185,10 @@ public sealed class ThemeManager
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read Windows' AppsUseLightTheme registry value. 1 = light, 0 = dark.
|
/// Read Windows' AppsUseLightTheme registry value. 1 = light, 0 = dark.
|
||||||
/// Returns true (dark) on any read failure — the dark scene is the
|
/// Returns true (dark) on any read failure — the dark scene is the
|
||||||
/// default per DESIGN.md so a missing value still lands somewhere sensible.
|
/// default per DESIGN.md so a missing value still lands somewhere
|
||||||
|
/// sensible. Backs the singleton's _isSystemDark seam.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool IsSystemDark()
|
private static bool ReadSystemDarkFromRegistry()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -180,6 +206,31 @@ public sealed class ThemeManager
|
||||||
return true;
|
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)
|
private void OnSystemPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Category != UserPreferenceCategory.General) return;
|
if (e.Category != UserPreferenceCategory.General) return;
|
||||||
|
|
|
||||||
|
|
@ -164,15 +164,26 @@ public static class UpdateChecker
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CooldownPath =>
|
/// <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 ??
|
||||||
Path.Combine(
|
Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
"TeamsISO", "last-update-check.txt");
|
"TeamsISO");
|
||||||
|
|
||||||
|
private static string CooldownPath =>
|
||||||
|
Path.Combine(StateDirectory, "last-update-check.txt");
|
||||||
|
|
||||||
private static string OptOutPath =>
|
private static string OptOutPath =>
|
||||||
Path.Combine(
|
Path.Combine(StateDirectory, "no-update-check.flag");
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
||||||
"TeamsISO", "no-update-check.flag");
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether launch-time update checks are enabled. Inverted-flag-file storage:
|
/// Whether launch-time update checks are enabled. Inverted-flag-file storage:
|
||||||
|
|
@ -219,9 +230,10 @@ public static class UpdateChecker
|
||||||
/// Parse a "vX.Y.Z" or "X.Y.Z(.N)" string into a Version. Strips any
|
/// 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
|
/// pre-release suffix ("-alpha", "-beta") so the comparison is on
|
||||||
/// numeric components only — pre-release vs. release ordering is a
|
/// numeric components only — pre-release vs. release ordering is a
|
||||||
/// follow-up if we need it.
|
/// follow-up if we need it. Internal so tests can pin parsing
|
||||||
|
/// behaviour without HTTP.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static Version? TryParseSemVer(string s)
|
internal static Version? TryParseSemVer(string s)
|
||||||
{
|
{
|
||||||
var trimmed = s.TrimStart('v', 'V');
|
var trimmed = s.TrimStart('v', 'V');
|
||||||
var dash = trimmed.IndexOf('-');
|
var dash = trimmed.IndexOf('-');
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,15 @@ namespace TeamsISO.App.Services;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class WindowStateStore
|
public static class WindowStateStore
|
||||||
{
|
{
|
||||||
private static readonly string Path =
|
/// <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 ??
|
||||||
System.IO.Path.Combine(
|
System.IO.Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
"TeamsISO",
|
"TeamsISO",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ namespace TeamsISO.App;
|
||||||
/// instead of leaving the host blank.
|
/// instead of leaving the host blank.
|
||||||
/// • Restore-on-close runs in a finally block so a crash mid-host
|
/// • Restore-on-close runs in a finally block so a crash mid-host
|
||||||
/// can't leave Teams orphaned with stripped window styles.
|
/// can't leave Teams orphaned with stripped window styles.
|
||||||
/// • TeamsLauncher.RestoreEmbed is idempotent — safe to call even if
|
/// • TeamsEmbedHost.RestoreEmbed is idempotent — safe to call even if
|
||||||
/// embedding never succeeded.
|
/// embedding never succeeded.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class TeamsEmbedWindow : Window
|
public partial class TeamsEmbedWindow : Window
|
||||||
|
|
@ -43,7 +43,7 @@ public partial class TeamsEmbedWindow : Window
|
||||||
|
|
||||||
var w = (int)EmbedHost.ActualWidth;
|
var w = (int)EmbedHost.ActualWidth;
|
||||||
var h = (int)EmbedHost.ActualHeight;
|
var h = (int)EmbedHost.ActualHeight;
|
||||||
if (!TeamsLauncher.EmbedTeamsInto(src.Handle, w, h))
|
if (!TeamsEmbedHost.EmbedTeamsInto(src.Handle, w, h))
|
||||||
{
|
{
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
"Couldn't find a Microsoft Teams window to embed. " +
|
"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.
|
// Keep Teams sized to match the host as the embed window resizes.
|
||||||
// No-op when nothing is embedded.
|
// No-op when nothing is embedded.
|
||||||
TeamsLauncher.ResizeEmbedded((int)e.NewSize.Width, (int)e.NewSize.Height);
|
TeamsEmbedHost.ResizeEmbedded((int)e.NewSize.Width, (int)e.NewSize.Height);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnWindowClosed(object? sender, EventArgs e)
|
private void OnWindowClosed(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
// ALWAYS restore Teams to top-level state when this window closes,
|
// ALWAYS restore Teams to top-level state when this window closes,
|
||||||
// even if the embed never succeeded. Idempotent.
|
// even if the embed never succeeded. Idempotent.
|
||||||
try { TeamsLauncher.RestoreEmbed(); }
|
try { TeamsEmbedHost.RestoreEmbed(); }
|
||||||
catch { /* defensive — restore is best-effort */ }
|
catch { /* defensive — restore is best-effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,17 @@
|
||||||
</AssemblyAttribute>
|
</AssemblyAttribute>
|
||||||
</ItemGroup>
|
</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. -->
|
<!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Resource Include="Assets\dragon-mark.png" />
|
<Resource Include="Assets\dragon-mark.png" />
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ public sealed class CommandPaletteViewModel : ObservableObject
|
||||||
: Visible.FirstOrDefault();
|
: Visible.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool Matches(PaletteCommand c, string query)
|
internal static bool Matches(PaletteCommand c, string query)
|
||||||
{
|
{
|
||||||
if (c.Label.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
|
if (c.Label.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
if (c.Category.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
|
if (c.Category.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
|
|
||||||
149
src/TeamsISO.App/ViewModels/MainViewModel.BulkCommands.cs
Normal file
149
src/TeamsISO.App/ViewModels/MainViewModel.BulkCommands.cs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
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)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/TeamsISO.App/ViewModels/MainViewModel.PresetCommands.cs
Normal file
108
src/TeamsISO.App/ViewModels/MainViewModel.PresetCommands.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
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)"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/TeamsISO.App/ViewModels/MainViewModel.TeamsCommands.cs
Normal file
130
src/TeamsISO.App/ViewModels/MainViewModel.TeamsCommands.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
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,8 +14,14 @@ namespace TeamsISO.App.ViewModels;
|
||||||
/// Top-level view model for the main window. Owns the live collection of <see cref="ParticipantViewModel"/>,
|
/// 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
|
/// the global settings panel, and the alert banner. Subscribes to <see cref="IIsoController"/>'s observables
|
||||||
/// and marshals updates onto the UI dispatcher.
|
/// 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>
|
/// </summary>
|
||||||
public sealed class MainViewModel : ObservableObject, IDisposable
|
public sealed partial class MainViewModel : ObservableObject, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IIsoController _controller;
|
private readonly IIsoController _controller;
|
||||||
private readonly Dispatcher _dispatcher;
|
private readonly Dispatcher _dispatcher;
|
||||||
|
|
@ -25,15 +31,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
|
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
|
||||||
private string _statusText = "Starting…";
|
private string _statusText = "Starting…";
|
||||||
|
|
||||||
// Auto-apply-last-preset bookkeeping. Set on InitializeAsync from disk;
|
// _pendingPresetName / Deadline / Applied + the auto-apply path
|
||||||
// cleared once we successfully apply (so we don't re-apply when the
|
// moved to MainViewModel.PresetCommands.cs.
|
||||||
// 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();
|
public ObservableCollection<ParticipantViewModel> Participants { get; } = new();
|
||||||
|
|
||||||
|
|
@ -431,25 +430,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
JoinMeetingCommand = new RelayCommand(() =>
|
JoinMeetingCommand = new RelayCommand(JoinPastedMeeting);
|
||||||
{
|
|
||||||
// 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(
|
ToggleMuteCommand = MakeTeamsCommand(
|
||||||
label: "Mute",
|
label: "Mute",
|
||||||
|
|
@ -469,202 +450,13 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
successMessage: "Opened share tray");
|
successMessage: "Opened share tray");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// Body methods extracted to themed partial files:
|
||||||
/// Wraps a <see cref="TeamsControlBridge"/> invocation in a RelayCommand that
|
// MainViewModel.BulkCommands.cs — EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll
|
||||||
/// translates the result to a user-visible toast. Centralizes the toast wording
|
// MainViewModel.TeamsCommands.cs — MakeTeamsCommand, JoinPastedMeeting,
|
||||||
/// so the four control commands stay consistent.
|
// ExtractMeetingTitle, PollTeamsMeetingState
|
||||||
/// </summary>
|
// MainViewModel.PresetCommands.cs — RequestApplyPresetOnStartup,
|
||||||
private RelayCommand MakeTeamsCommand(string label, Func<TeamsControlBridge.InvokeResult> invoke, string successMessage)
|
// LoadPendingPresetFromPreferences,
|
||||||
{
|
// TryAutoApplyPendingPreset
|
||||||
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)
|
private void OnStatsTick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
|
@ -777,52 +569,10 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
|
StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Teams meeting state — UIA traversal at 1Hz. We probe by looking
|
// Teams meeting state — UIA traversal at 1Hz; off-thread so a slow
|
||||||
// for the Leave button in Teams' automation tree (present iff in a
|
// UIA call doesn't stall the UI tick. Implementation in
|
||||||
// call) and surface the result as a status pill in the IN-CALL bar.
|
// MainViewModel.TeamsCommands.cs.
|
||||||
// Offloaded to a Task so a slow UIA call doesn't stall the UI tick;
|
PollTeamsMeetingState();
|
||||||
// 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.
|
// Control-surface state — peek at App's owned services.
|
||||||
var app = System.Windows.Application.Current as App;
|
var app = System.Windows.Application.Current as App;
|
||||||
|
|
@ -853,36 +603,12 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
await _controller.StartAsync(cancellationToken);
|
await _controller.StartAsync(cancellationToken);
|
||||||
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
|
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
|
||||||
|
|
||||||
// Auto-apply last preset bookkeeping. We don't apply here — participants
|
// Auto-apply last preset bookkeeping. We don't apply here —
|
||||||
// haven't been discovered yet — instead we record the intent and let
|
// participants haven't been discovered yet — instead we record
|
||||||
// OnParticipantsChanged trigger the apply once the meeting has populated.
|
// the intent and let OnParticipantsChanged trigger the apply
|
||||||
try
|
// once the meeting has populated. Implementation in
|
||||||
{
|
// MainViewModel.PresetCommands.cs.
|
||||||
var pref = Services.OperatorPresetStore.GetStartupPreference();
|
LoadPendingPresetFromPreferences();
|
||||||
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)
|
private void OnParticipantsChanged(IReadOnlyList<Participant> incoming)
|
||||||
|
|
@ -960,50 +686,6 @@ public sealed 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) =>
|
private static bool IsLocalSelf(Participant p) =>
|
||||||
string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);
|
string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
|
|
||||||
107
src/tests/TeamsISO.App.Tests/Fakes/StubIsoController.cs
Normal file
107
src/tests/TeamsISO.App.Tests/Fakes/StubIsoController.cs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
200
src/tests/TeamsISO.App.Tests/Integration/IntegrationTests.cs
Normal file
200
src/tests/TeamsISO.App.Tests/Integration/IntegrationTests.cs
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/tests/TeamsISO.App.Tests/Integration/WpfHostFixture.cs
Normal file
87
src/tests/TeamsISO.App.Tests/Integration/WpfHostFixture.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
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)";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/tests/TeamsISO.App.Tests/Services/NotesServiceTests.cs
Normal file
104
src/tests/TeamsISO.App.Tests/Services/NotesServiceTests.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
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,15 +5,18 @@ using TeamsISO.App.Services;
|
||||||
namespace TeamsISO.App.Tests.Services;
|
namespace TeamsISO.App.Tests.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unit tests for <see cref="OperatorPresetStore"/>. Each test redirects the
|
/// Unit tests for <see cref="OperatorPresetStore"/>. Each test redirects
|
||||||
/// store's file path to a per-test temp path via the internal
|
/// the store's file path to a per-test temp path via the internal
|
||||||
/// <c>PathOverride</c> hook so the operator's real
|
/// <c>PathOverride</c> hook so the operator's real
|
||||||
/// <c>%LOCALAPPDATA%\TeamsISO\presets.json</c> is never touched.
|
/// <c>%LOCALAPPDATA%\TeamsISO\presets.json</c> is never touched.
|
||||||
///
|
///
|
||||||
/// IDisposable on the test class cleans up the temp path after each test.
|
/// IDisposable on the test class cleans up the temp path after each
|
||||||
/// We don't use [Collection] because each test's path is per-test-unique
|
/// test. Shares <see cref="PresetStoreCollection"/> with any other
|
||||||
/// (Path.GetTempFileName) so parallel xUnit execution can't collide.
|
/// class that mutates <see cref="OperatorPresetStore.PathOverride"/> —
|
||||||
|
/// xUnit's parallel execution would otherwise let a sibling class's
|
||||||
|
/// ctor clobber our path mid-test.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Collection(PresetStoreCollection.Name)]
|
||||||
public sealed class OperatorPresetStoreTests : IDisposable
|
public sealed class OperatorPresetStoreTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly string _tempPath;
|
private readonly string _tempPath;
|
||||||
|
|
|
||||||
117
src/tests/TeamsISO.App.Tests/Services/OscBridgeDispatchTests.cs
Normal file
117
src/tests/TeamsISO.App.Tests/Services/OscBridgeDispatchTests.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
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,3 +1,4 @@
|
||||||
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using TeamsISO.App.Services;
|
using TeamsISO.App.Services;
|
||||||
|
|
|
||||||
164
src/tests/TeamsISO.App.Tests/Services/PresetApplierTests.cs
Normal file
164
src/tests/TeamsISO.App.Tests/Services/PresetApplierTests.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
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)";
|
||||||
|
}
|
||||||
156
src/tests/TeamsISO.App.Tests/Services/ThemeManagerTests.cs
Normal file
156
src/tests/TeamsISO.App.Tests/Services/ThemeManagerTests.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/tests/TeamsISO.App.Tests/Services/UpdateCheckerTests.cs
Normal file
118
src/tests/TeamsISO.App.Tests/Services/UpdateCheckerTests.cs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/tests/TeamsISO.App.Tests/Services/WindowStateStoreTests.cs
Normal file
126
src/tests/TeamsISO.App.Tests/Services/WindowStateStoreTests.cs
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
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,15 +6,17 @@
|
||||||
because TeamsISO.App is net8.0-windows + WinExe — a pure net8.0 test
|
because TeamsISO.App is net8.0-windows + WinExe — a pure net8.0 test
|
||||||
project can't reference it.
|
project can't reference it.
|
||||||
|
|
||||||
We DON'T reference WPF or System.Windows here — the tests cover services
|
Tests cover services that are mostly framework-free, but
|
||||||
that are intentionally framework-free even though they live in the host
|
ControlSurfaceServer transitively references System.Windows.Threading
|
||||||
assembly. Future test cases that touch WPF types (e.g. WriteableBitmap)
|
(DispatcherTimer) and System.Windows.Application — UseWPF=true pulls
|
||||||
would need <UseWPF>true</UseWPF> added.
|
in those types so test code compiles against the App's project
|
||||||
|
reference without "could not load type" errors at run time.
|
||||||
-->
|
-->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
|
|
@ -29,6 +31,7 @@
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
<PackageReference Include="xunit" Version="2.5.3" />
|
<PackageReference Include="xunit" Version="2.5.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||||
|
<PackageReference Include="Xunit.StaFact" Version="1.1.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
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,6 +182,72 @@ public class IsoControllerTests : IDisposable
|
||||||
alerts.Should().Contain(a => a is EngineAlert.NdiRuntimeMismatch);
|
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)
|
private static async Task<Guid> WaitForFirstParticipantAsync(IsoController controller)
|
||||||
{
|
{
|
||||||
var tcs = new TaskCompletionSource<Guid>();
|
var tcs = new TaskCompletionSource<Guid>();
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
namespace TeamsISO.Engine.Tests;
|
|
||||||
|
|
||||||
public class SmokeTest
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void TestProjectIsWired()
|
|
||||||
{
|
|
||||||
Assert.True(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue