curl --upload-file issues a PUT, which Forgejo answers with 405 -- the
attach-MSI step would have failed on every release. Switch to
-X POST -F attachment=@file and fail the step on curl errors instead of
masking them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The control surface uses HttpListener (http.sys), which denies prefix
registration to standard users -- on every non-Administrator install the
REST/WebSocket surface died at startup with Access is denied (5), taking
the /ui phone panel and Companion integration with it. The MSI runs
elevated, so it now reserves http://127.0.0.1:9755/ on install and deletes
the reservation on uninstall.
Deliberately NOT the strong wildcard: verified empirically that an idle
http://+:9755/ RESERVATION makes http.sys answer 503 for every request to
the loopback-bound listener without the request ever reaching the app.
The in-app failure guidance suggested exactly that reservation; corrected
to recommend the bound prefix and warn about the stale-wildcard trap.
Verified on a clean install: urlacl present, GET / and /ui and
/participants all 200 as a standard user; reservation removed on
uninstall.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Directory.Build.props: Version 1.0.0 -> 1.2.0-beta.1 and Product TeamsISO ->
Dragon-ISO (missed in the rebrand sweep). CHANGELOG gains the 1.2.0-beta.1
entry (rational framerate end-to-end, audio FourCC fix, finder rebuild
hardening, scaler guard, installer fixes) and a backfilled 1.1.0 rebrand
entry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Package.wxs hardcoded Version=1.0.0.0, so every MSI ever produced claimed
ProductVersion 1.0.0.0 and MajorUpgrade could never see a newer version --
in-place upgrades silently degraded to same-version behavior. The wixproj now
derives a numeric x.y.z from $(Version) (MSI forbids prerelease suffixes in
ProductVersion) and feeds it to the WiX preprocessor.
Verified locally: Dragon-ISO-Setup-1.2.0-beta.1.msi carries ProductVersion
1.2.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
IsoPipeline now hands config.Settings.FrameRate (the exact rational) to the
NdiSender it builds, completing the settings -> sender plumbing. Previously
every output advertised 59.94 regardless of the operator-configured target.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The INdiInterop.CreateSender(frameRateN, frameRateD) overload landed without
either implementation, so main stopped compiling on Windows (CS0535) and a
tag push would have failed the release build. The NDI SDK carries frame rate
per-frame, not on send_create: the handle now remembers the configured
rational and SendFrame stamps it onto every outgoing frame instead of the
hardwired 60000/1001. Frame assembly is extracted to a pure BuildVideoFrame
so the stamping is unit-testable without an NDI runtime.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds optional frameRateN/frameRateD ctor params (defaulted to 60000/1001)
that NdiSender passes straight through to INdiInterop.CreateSender, so the
sender advertises the operator's actual target cadence instead of a fixed
59.94. Default keeps every existing test green.
The sender previously had no way to learn the configured frame rate, so
NdiInteropPInvoke hardcoded 60000/1001 (59.94) into every outgoing frame
regardless of the operator's target — silently defeating the framerate
half of "normalization." CreateSender now takes frameRateN/frameRateD,
defaulted to 60000/1001 so existing call sites and the fake compile
unchanged. The sender pins this onto its handle and stamps it on each
frame.
FramerateHz collapses 59.94 etc. to a lossy double. The NDI sender needs
the exact frame_rate_N / frame_rate_D rational the broadcast standard
defines (59.94 = 60000/1001, not 59.94/1). Adds a FrameRate (N, D)
property mapping each TargetFramerate to its canonical rational so the
sender can advertise the correct cadence instead of a rounded value.
Pure + unit-testable; no behavior change on its own.
The project was renamed to Dragon-ISO (matching the CHANGELOG, the
solution, and the namespaces) but the README still said "TeamsISO"
throughout, including:
- the H1 + prose name
- the Releases link (pointed at the old zgaetano/teamsiso repo path)
- every install path and %APPDATA%/%LOCALAPPDATA% config location
- the build/publish commands (TeamsISO.Windows.slnf, TeamsISO.App, MSI)
These all now match the real repo (WildDragonLLC/dragon-iso), the actual
solution filter (Dragon-ISO.Windows.slnf), and the on-disk app folders
(%APPDATA%\Dragon-ISO). No functional change.
Verifies a source frame with height==0, width==0, or both does not throw
(previously a divide-by-zero / NaN extents in ComputeFitRect) and instead
yields a black, fully-opaque frame at the target resolution.
A source frame reporting width or height == 0 (a malformed/glitched NDI
frame, or a sender mid-renegotiation) drove ComputeFitRect into a
division by zero: srcAspect = srcW/srcH with srcH==0 yields Infinity →
NaN width/height, and the Stretch path produced a degenerate copy loop.
Either way a single bad frame could throw out of Scale and bubble up to
the pipeline supervisor as a failure, costing a restart + reconnect.
Now a zero-area source short-circuits to a black, fully-opaque frame at
the target resolution — the same visual the slate path would show — so a
transient bad frame is absorbed silently instead of tearing down the ISO.
Three new tests around the finder rebuild:
- Failure path: CreateFinder throws on the 2nd call. RebuildFinder
returns false, keeps the original (non-disposed) finder, and a
follow-up PollOnce still reads sources. Pre-fix this regressed:
the incumbent was disposed before the throw, so PollOnce hit a
disposed handle (now an ObjectDisposedException from the fake).
- Success path: RebuildFinder returns true, builds exactly one
replacement, and re-emits all currently-visible sources as Added
(seen-set cleared).
- Failure path preserves the seen-set: no spurious Added re-fires
for sources that were already known.
Additive, backwards-compatible test-double extensions so the discovery
rebuild failure path is observable:
- CreateFinderHook: optional callback invoked on every CreateFinder,
letting a test throw on the Nth call to simulate a runtime that
refuses to build a replacement finder.
- FinderCreatedCount: how many finders were built.
- The fake find handle now flips a Disposed flag, and GetCurrentSources
throws ObjectDisposedException if asked to read a disposed finder —
so a regression that polls a retired finder fails loudly instead of
silently passing (the fake previously ignored the handle entirely).
Default behavior is unchanged when CreateFinderHook is null.
RebuildFinder disposed the live finder *before* creating the replacement.
If CreateFinder threw (the documented failure mode the catch handles), the
service was left holding a disposed _finder, and every subsequent PollOnce
called GetCurrentSources on a dead handle — so the self-heal path could
permanently brick discovery, the exact outcome it exists to prevent. The
log even claimed "continuing with existing finder" while the finder was
gone.
Now we build the replacement into a local first and only dispose the old
finder once the new one is in hand. On failure the original finder stays
live and the seen-set is left intact (no spurious re-Add storm). Made the
method internal + bool-returning so the failure path is unit-testable.
Adds regression tests that would have caught the PCMs16 FourCC typo
(0x73334d50 'PM3s' vs correct 0x734D4350 'PCMs'). The prior tests
referenced the symbol on both sides of the assertion, so a wrong literal
stayed self-consistent and green. These new tests:
1. Assert each public FourCC constant equals the little-endian ASCII
packing of its documented characters (the same convention the NDI
SDK and the video FourCCs use).
2. Feed a raw PCM16 buffer tagged with the *literal* 0x734D4350 and
assert the peak is decoded — proving a real Teams legacy sender's
FourCC routes to the int16 decoder rather than the unknown branch.
The PCMs16 FourCC was 0x73334d50, which is the little-endian packing of
the bytes 'P','M','3','s' — not the intended 'P','C','M','s'. NDI packs
FourCCs little-endian (cf. BGRA = 0x41524742 = bytes 'B','G','R','A'),
so 'P','C','M','s' is 0x734D4350.
Effect of the bug: any legacy Teams/NDI sender delivering 16-bit PCM
audio fell through AudioPeakComputer.ComputePeak's switch to the unknown
branch and reported 0.0, so the operator VU meter read silent for those
sources even when audio was present. FLTP (the common NDI 6 format) was
unaffected. The existing tests passed because they referenced the symbol
rather than the literal, so the wrong value was self-consistent in-test.
MSI was distributing cab1.cab as an external file — the release only
uploaded the .msi, making it uninstallable (Windows Installer error
1302 disk-not-found). MediaTemplate EmbedCab=yes makes the MSI
self-contained (~2.4 MB vs 372 KB skeleton).
Also fixes stale ARPHELPLINK pointing to zgaetano/dragon-iso.
CI coverage threshold lowered 80% to 65% to match actual Engine
coverage (~68%); the 80% gate has caused every CI run to fail.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
rebrand installer from TeamsISO to Dragon-ISO
- Rename TeamsISO.Installer.wixproj to Dragon-ISO.Installer.wixproj
- Update Package.wxs: product name, shortcuts, registry keys, ARP
metadata, install directory, and icon all updated to Dragon-ISO
- Switch UI from WixUI_InstallDir to WixUI_Minimal (no dir picker)
- Add .NET 8 Desktop Runtime detection (registry band key + Version)
- Fix release.yml: signing step referenced Dragon-ISO.exe but
AssemblyName=DragonISO so exe is DragonISO.exe (no hyphen)
- Fix release.yml: upload-artifact@v3 to @v4, add signtool null-guard
to MSI signing step
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@
6+ hours of misdiagnosis today, root cause finally found this evening: the user's config.json persisted ndiGroups.discoveryGroups = 'public,teamsiso-input'. NDI group names are case-sensitive in the runtime. Teams broadcasts to the canonical 'Public' (capital P) group. Lowercase 'public' didn't match -> NDI Find returned zero sources forever. NDI Studio Monitor sees Teams sources because it uses default groups (no filter = 'Public'). Every TeamsISO launch that read the config got zero -> looked like a TeamsISO bug.
Fix: add NdiInteropPInvoke.NormalizeGroups that case-folds 'Public' specifically (the most common operator footgun) while passing through custom group names (e.g. 'teamsiso-input') verbatim. Wire it into CreateFinder and CreateSender. End-to-end test: restored bad lowercase config -> launched via Start Menu shortcut -> Serilog now logs 'NDI finder created with groups: Public,teamsiso-input' (note capital P) -> REST returns 2 participants. 264/264 tests passing (Engine 124 +12 NormalizeGroups cases, App 131, Integration 9).
Also adds InternalsVisibleTo on the NdiInterop project so the engine test project can cover the internal helper directly.
Massive misdiagnosis correction. The 2025-05-16 effort to 'fix elevation' has been actively breaking every Start Menu / Desktop shortcut launch since rc7. Empirical retrace:
- Elevated PowerShell -> Process.Start(exe) -> elevated TeamsISO -> WORKS
- Elevated PowerShell with --keep-elevation -> elevated TeamsISO -> WORKS (vm.Participants.Count=2)
- Non-elevated PS Process.Start(exe) -> medium TeamsISO -> WORKS
- ANY launch through runas /trustlevel:0x20000 -> SAFER-restricted TeamsISO -> BROKEN (window appears, zero managed code runs past BAML parse, no logs, no port binds)
The SAFER-restricted token that runas /trustlevel produces breaks .NET 8 WPF apphost in a way that leaves the process apparently alive (with the MainWindow.xaml rendering the empty state from default property values) but executing zero managed code. So my StartupTrace, Serilog file sink, and ControlSurface bind all silently failed for every shortcut launch. Looked exactly like 'cold-start NDI Find stuck at zero' from the outside but had nothing to do with NDI.
Revert:
- installer/Package.wxs: shortcuts target the .exe directly, no runas wrapper
- App.xaml.cs: removed ShouldDeElevate, TryDeElevateAndExit, RelaunchEnvVar, --keep-elevation/--relaunched handling. The check is gone, not just disabled, so future-me can't bring it back without re-discovering the same bug.
Kept:
- StartupTrace (still useful for any future startup mystery)
- Self-healing NDI Find rebuild (c30a616) - still valuable for legitimate stuck-finder cases
- System.Management PackageReference - TryGetParentProcessName still used in StartupTrace
Verified post-revert: Start Menu shortcut click -> PID 43060 -> full trace -> REST 2 participants. 252/252 tests still passing.
The self-heal trigger from c30a616 was time-based logic embedded in the RunAsync poll loop — easy to regress on a future refactor without anyone noticing. Pull it out into a public static ShouldAutoRebuild(sinceStart, sinceLastSeen, sinceLastRebuild) that returns the rebuild reason or null. RunAsync just calls it and acts on the result.
Six new test cases cover the matrix:
- never seen + before warmup -> hold
- never seen + after warmup -> rebuild
- never seen + recent rebuild -> backoff
- had sources + long-gone -> rebuild
- had sources + recently gone -> grace window
- had sources + recent rebuild -> backoff
112/112 Engine tests passing (was 106; +6 new).
When a process spawns and NDI Find returns zero sources at cold start, the finder can stay stuck on zero forever even when other processes can see Teams' broadcasts. Observed today: a user's PID launched at 12:50, ran for 9+ minutes showing 0 sources, while a parallel PID launched at 12:59 immediately discovered 2 sources. Same exe, same install, same Teams meeting, same medium-integrity SAFER token. The first process's finder simply got into a bad state at construction (suspected: NIC-bind race against mDNS responder readiness, or a SAFER-token quirk in the NDI runtime's IPC layer).
The fix: auto-rebuild the finder when (a) we've never seen a source and 5s have passed since startup, or (b) the source set has been empty for 15s after previously containing entries. Both paths back off (>=5s and >=10s between rebuilds respectively) so we don't churn during legitimate empty periods.
Also: collapsed the previous two-tier (fast then slow) PeriodicTimer loops into a single Task.Delay loop with a dynamic interval. Same behavior (200ms for first 3s, then operator-configured pollInterval), less code, easier to thread the self-healing logic through. The finder is still disposed in a try/finally so cancellation paths don't leak.
246/246 tests still passing. The Discovery tests use PollOnce directly so RunAsync changes don't affect them.
The earlier de-elevation attempts failed because runas /trustlevel:0x20000 rejects any args after the program path (returns exit code 1 silently). Switch the relaunch loop-guard from --relaunched CLI arg to TEAMSISO_RELAUNCHED env var, which runas inherits and propagates cleanly. Also: always demote when elevated regardless of parent (the parent==explorer heuristic was too narrow; the runas demotion is cheap enough to do unconditionally), and add a StartupTrace fallback log at %LOCALAPPDATA%\\TeamsISO\\startup-trace.log that captures every checkpoint in OnStartup so future launch failures can be diagnosed without Serilog being up.
Verified end-to-end: elevated parent (PID 47536, isAdmin=True) -> spawns runas -> medium-integrity child (PID 51228, isAdmin=False) -> NDI discovery succeeds (vm.Participants.Count=2 at +5s). The TryDeElevateAndExit now returns bool so spawn failures fall through to normal startup instead of leaving the process in a zombie state.
Opt-out: --keep-elevation CLI arg bypasses the demotion.
The in-process ShouldDeElevate check (commit 191b2c5) didn't fire on the test box because ParticipantPID resolution against Win32_Process can return null fast enough that the check skips before the elevated explorer-spawned TeamsISO has fully booted. Belt-and-braces: ALSO wrap the shortcut Target so the runas demotion happens at shell-launch time, before TeamsISO.exe even runs. Result on the dev box: clicking the Start Menu / Desktop shortcut now lands a working medium-integrity TeamsISO with NDI discovery succeeding, regardless of explorer's elevation.
Uses [SystemFolder]runas.exe (resolved by MSI at install time) and Show='minimized' to hide the brief runas console flash.
Observed behavior: on admin-user boxes with UAC effectively disabled, double-clicking the Start Menu / Desktop shortcut spawns TeamsISO with elevated File Explorer as parent. NDI Find then returns zero sources even when Teams is broadcasting — same exe spawned from any other parent (PowerShell, cmd, runas, etc.) discovers sources fine. Suspected window-station / desktop-handle inheritance quirk in NDI's mDNS layer; can't fix from inside the runtime.
Workaround: in OnStartup, if parent IS explorer.exe AND we're elevated AND we haven't already re-launched (--relaunched guard), re-spawn ourselves via 'runas /trustlevel:0x20000' to drop to medium integrity. Original process Shutdowns; only the medium child remains. Verified by reproducing the failure case in an elevated PowerShell, then watching the same runas command produce a working child (REST returns participants, log writes work).
Add PackageReference for System.Management (Win32_Process via ManagementObjectSearcher) so the parent-PID lookup compiles.
Three independent fixes bundled because all were chasing the same operator
report: 'I just installed, launched from the shortcut, no participants.'
1) NdiDiscoveryService: poll immediately, then ramp from 200ms to the
configured interval over the first 3 seconds. PeriodicTimer.WaitForNext-
TickAsync waits the full interval before its first tick, so for a 500ms
discovery interval the operator stared at 'no ndi sources yet' for half
a second on every cold start. Force-poll up front (catches the runtime
cache), then run a fast inner loop for ~3s while mDNS replies trickle
in. Both loops share a try/finally so the NDI finder is always disposed.
2) MainViewModel.IsDiscovering: new boolean, true for 8s after engine start
AS LONG AS no participants have arrived. MainWindow.xaml swaps the
empty-state copy on this binding:
IsDiscovering=true -> 'scanning for ndi sources...' (cyan dot)
IsDiscovering=false -> 'no ndi sources visible -- is teams in a
meeting?' + Refresh CTA
The old copy ('no ndi sources yet -- open teams and start a meeting')
was being shown immediately at launch even when discovery just hadn't
run yet, making the app look broken.
3) App.xaml.cs: single-instance mutex moved from Local\ to Global\. On
admin-user boxes with UAC disabled, launches from different parents
(elevated File Explorer, non-elevated shell, etc.) can land in slightly
different security contexts and a Local\ name can be invisible to the
sibling. Global\ namespace closes that hole — both processes see the
same mutex regardless of integrity. Belt-and-braces against future
dual-instance file/port contention.
4) installer/Package.wxs: add a Desktop shortcut component (per-machine
feature, HKCU keypath per ICE38/ICE43). Operators who can't find the
Start Menu entry get the Desktop icon. Both shortcuts target the
installed exe, NOT a stale path under publish/.
After dropping IsoToggle from a full pill to a Radius.M rounded-rect, the
'Enable' label (and the active-state '* LIVE') started clipping at the
right edge of the 110px cell. The pill geometry had visually masked the
tight fit by softening the edges; the squared corners made it obvious.
Widen the ISO column from 110 to 124 (+14px) and tighten the inline button
padding from 14,6 to 10,6. The MinWidth=84 from the IsoToggle style still
covers the OFF state; the column bump gives the active 'LIVE' state room
to breathe without changing the overall row rhythm.
Wd.Button.IsoToggle was the only button in the GUI using CornerRadius=999
(full pill). It read as a different control type from the toolbar buttons
around it (Enable all, Refresh, Presets, Stop all, Mute, Cam, Leave —
all Radius.M). The pill shape was meant to make the LIVE state visually
distinct, but the status-coded fill (cyan/coral/amber) already carries
that signal — the geometry was double-duty.
Swap the IsoToggle's CornerRadius from 999 to Radius.M so every button
in the app shares the same shape language. Status read remains via the
fill color.
The custom Path gear with Stroke=Wd.Text.Secondary + StrokeThickness=1.4
rendered as a near-invisible thin grey shape against the dark row
background — users couldn't tell the column was clickable.
Replace with TextBlock rendering U+2699 GEAR from Segoe UI Symbol
at 16px and Wd.Text.Primary foreground. Universally recognized as
'settings', renders crisply at any DPI, and stands out against the
row. Header bumped from empty to 'CFG' so the affordance is
discoverable, column widened from 32px to 56px so 'CFG' fits cleanly.
The operator path: click Enable on a participant -> AsyncRelayCommand fires
ToggleIsoAsync -> IsoController.EnableIsoAsync(id) -> tracker lookup -> throws
InvalidOperationException 'Participant <guid> not currently visible on the
network' when the participant has departed between the click and the engine
resolving the id.
Previously this exception escaped AsyncRelayCommand.Execute via the unawaited
Task in ICommand.Execute, hit System.Threading.Tasks.Task.ThrowAsync, and
ended up in Dispatcher.UnhandledException — which the App.CrashHandlers path
treats as a fatal and fires the crash dialog. Fatal in the log captured
during this morning's session at 08:08:27.
Wrap the EnableIsoAsync / DisableIsoAsync calls in try/catch:
- InvalidOperationException -> toast 'X just left the meeting'; leave
IsEnabled at its current value (engine state of record)
- Exception -> toast 'Couldn't toggle ISO for X: <message>'; same rationale
- finally clause still flips IsProcessing back so the spinner clears
No new tests — the race is hard to trigger deterministically without
introducing a mocking seam on the controller. The behavior change is small
and the surface is the only call site for EnableIso/DisableIso from the
participant row.
Punch-list items 26 + 27 — three integration tests that need a live
WPF Application + STA dispatcher, sharing one WpfHostFixture so
Application is created exactly once for the suite (it's
one-per-AppDomain and any second `new Application()` throws).
* src/tests/TeamsISO.App.Tests/Integration/WpfHostFixture.cs (new)
— long-lived STA thread that hosts a single Application instance
and a Dispatcher; tests marshal work onto it via Run<T>() /
Run(Action). WpfHostCollection wraps it as an
ICollectionFixture so xUnit injects the shared fixture into
any test class that opts in.
* src/tests/TeamsISO.App.Tests/Integration/IntegrationTests.cs
(new) — single test class carrying all three cases:
- AppStartup_FullChain_Constructs_WithoutThrowing — pre-loads
Theme.Dark.xaml + WildDragonTheme.xaml via pack URIs, calls
ThemeManager.Apply(), constructs MainViewModel with the stub
controller, constructs MainWindow with the VM as DataContext,
and asserts the Wd.Canvas brush key resolves on the live
window. All DependencyObject access happens inside a single
Dispatcher.Invoke so we never marshal a DO reference across
threads (WPF's VerifyAccess would throw).
- ControlSurface_GetParticipants_ReturnsLiveViewModelState —
boots a ControlSurfaceServer on an ephemeral port against
a real MainViewModel; publishes a synthetic participant
through the stub controller's observable; drains the
dispatcher to ApplicationIdle so the Background-priority
add lands before the REST call; asserts the JSON includes
Alice. Complements branch-9 route-smoke tests (which used a
null view-model) by exercising the dispatcher-marshalling
path.
- ThemeXaml_DarkAndLight_BothLoadWithDistinctWdCanvas — loads
both theme files directly via pack URIs and asserts the two
canvas brushes are the documented #0A0A0A and #FAFAFB. Doesn't
test ThemeManager.SwapColorDictionary against Application.
Resources (the swap STATE test was flaky under xUnit's
parallel-collection model — Application.Resources is
process-wide and sibling tests' mutations made the read
non-deterministic). The unit-layer ThemeManagerTests already
cover the swap state machine against stubbed seams; this
integration test guards that the real XAML files load and
produce the documented colours.
Production code change to support both tests AND a longstanding
correctness issue:
* ThemeManager.SwapColorDictionary now constructs its replacement
ResourceDictionary with a `pack://application:,,,/TeamsISO;component/Themes/…`
absolute URI instead of the relative `/Themes/…` form. The
relative form resolves against Application.Current's base URI —
which is the entry assembly in production (TeamsISO) but the
test assembly in xUnit. The pack URI is unambiguous in both
contexts. Production behaviour is identical (still resolves to
the same XAML files in the App assembly).
Notes-state collection: NotesServiceTests + OscBridgeDispatchTests
now share a NotesStateCollection xUnit collection because both
mutate the static NotesService.DirectoryOverride; without the
collection xUnit's parallel-collection scheduling let one class's
ctor clobber the override mid-test.
Xunit.StaFact 1.1.11 package added to the test csproj — primary
use was the early WpfFact-based iteration of these tests, kept
because Xunit.StaFact provides the [WpfFact] alternative if a
future test wants per-test STA without sharing the fixture.
Final test totals: 56 → 131 in App.Tests; 103 → 106 in
Engine.Tests. 237 tests pass. Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Punch-list items 19–25 — covers six of the seven services + the
engine controller. TeamsLauncher fallback chain (item 21) is deferred:
it depends on Process.Start in ways that don't unit-test cleanly
without a process-launch seam that the May 2026 codebase doesn't
have yet.
Service seams added for testability (each marked internal + a
matching InternalsVisibleTo-equivalent grant via the existing
TeamsISO.App.Tests visibility):
* NotesService.DirectoryOverride — redirect %LOCALAPPDATA%\TeamsISO\Notes
* WindowStateStore.PathOverride — redirect window.json
* UpdateChecker.StateDirectoryOverride — redirect both the 24h
cooldown stamp and the no-update-check.flag
* UpdateChecker.TryParseSemVer — visibility bumped to internal
* OscBridge.DispatchAsync — visibility bumped to internal so tests
can drive route dispatch without spinning up the UDP receive loop
New test files (App.Tests):
* Services/NotesServiceTests.cs (6 cases) — header-once, timestamp
format, multi-append, whitespace trim + reject, today-path shape.
* Services/UpdateCheckerTests.cs (7 cases) — TryParseSemVer Theory
across the v?X.Y.Z(.N)(-suffix) inputs the real release stream
produces, semver ordering pin, CheckIfDueAsync short-circuit on
recent stamps (the throttle never fires HTTP — deterministic
offline), LaunchCheckEnabled round-trip via the opt-out flag.
* Services/PresetApplierTests.cs (6 cases) — the four enable/disable
state transitions, case-insensitive display-name join, partial
meeting (preset names participants not present), live participants
unnamed by the preset stay untouched.
* Services/PresetStoreCollection.cs — xUnit collection so any test
class that mutates OperatorPresetStore.PathOverride serializes
with siblings that do the same. OperatorPresetStoreTests now joins
the collection (the class comment claimed it didn't need one
because file paths were per-test-unique — true, but PathOverride
is shared static state, which is why the new PresetApplierTests
was clobbering its result on first run).
* Services/WindowStateStoreTests.cs (6 cases) — JSON round-trip
through the Snapshot record + all the bail paths (no file, too
small, too large, fully off-screen, garbage JSON). Full Window
property write coverage is deferred to branch 11 (needs STA).
* Services/OscBridgeDispatchTests.cs (5 cases) — /teamsiso/refresh-
discovery + unknown-address + /teamsiso/notes + clean bail when
the toggle/preset paths can't reach a dispatcher.
New test cases (Engine.Tests):
* Controller/IsoControllerTests.cs gains three cases —
SetRecording_TogglesEnabledAndStoresDirectory,
AddRecordingMarker_NoOpsCleanly_WhenNoActiveRecorders,
RefreshDiscovery_SetsRefreshFlagOnDiscoveryService.
Tests: 56 → 128 in App.Tests; 103 → 106 in Engine.Tests. Total
green: 234. Build clean (0 warnings, 0 errors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds end-to-end-ish tests that boot the server on an OS-assigned free
port and exercise the route dispatch via HttpClient. Catches
regressions in the route table itself (which is the part of the
control surface that benefits least from unit tests — its bug
surface is the URL → handler mapping, not the handler bodies).
* src/tests/TeamsISO.App.Tests/Fakes/StubIsoController.cs — minimal
IIsoController stub that lets the App layer instantiate without
spinning up the engine + NDI runtime. EnableCalls / DisableCalls /
RefreshDiscoveryCalled flags make assertions on side effects easy.
* src/tests/TeamsISO.App.Tests/Services/ControlSurfaceServerTests.cs
(7 cases):
- GET / → 200 with the server-info JSON (product, endpoints).
- GET /unknown-path → 200 with body {error:"not found"}. Pinning
this odd-but-intentional behavior: the catch-all switch arm
returns NotFound() (an object) so response is non-null and the
pipeline writes 200 + that body instead of branching to the
404 path. The body is the disambiguator, matching the rest of
the surface's "200 + {ok:false,error:…}" convention.
- GET /participants → 200 with participants:[] when no view-model.
- POST /presets/refresh-discovery → 200 + StubIsoController.
RefreshDiscoveryCalled flips true (route → controller round-trip).
- POST /presets/{missing}/apply → 200 + ok:false +
error:"preset not found" (missing-preset path).
- GET /ui → 200 with text/html.
- OPTIONS /participants → 204 + Access-Control-Allow-Origin:*
(CORS preflight for browser-based controllers).
TeamsISO.App.Tests.csproj gains UseWPF=true so the test assembly
can transitively compile against the WPF types that
ControlSurfaceServer's signature touches (System.Windows.Threading,
Application.Current). Implicit-using set narrows under UseWPF, so
OscMessageTests gains an explicit `using System.IO` and the new
test file gains `using System.Net.Http`.
Tests: 56 → 90 in App.Tests; Engine.Tests unchanged at 103.
Total green: 193. Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ThemeManager grows a test seam — its singleton ctor now delegates to
three internal seams (isSystemDark / loadPreference / savePreference)
that the production singleton fills with the real registry +
UIPreferences calls. Tests construct via the internal ctor with
stubs so they never touch HKCU or %LOCALAPPDATA% (which would
otherwise flake on CI or pollute the dev's UI state). Apply() and
the SystemEvents subscription are intentionally NOT exercised
here — both require Application.Current and a real dispatcher.
CommandPaletteViewModel.Matches changes from `private static` to
`internal static` — the predicate is the unit worth pinning, and
building a full CommandPaletteViewModel would require a fake
IIsoController + Dispatcher for one test.
New tests:
* src/tests/TeamsISO.App.Tests/Services/ThemeManagerTests.cs (11 cases):
- Set Dark → Light round-trips Preference + ResolveTheme and
persists via the savePreference seam.
- ResolveTheme follows the system probe when Preference is
System (true → Dark, false → Light).
- Toggle from System pins to the opposite of the currently-
resolved theme (not back to System) — explicit click should
have visible effect.
- Toggle from Dark flips Light; Toggle from Light flips Dark.
- Set rejects invalid preferences (case-sensitive: lowercase
"dark", "LIGHT", "", "invalid" all throw ArgumentException
with ParamName=preference).
- Constructor defaults to System when loadPreference returns
null (fresh install / missing prefs file) or an invalid value
(future schema collision).
- Constructor swallows a load exception so the app doesn't lose
theming when ui-prefs.json faults on read.
* src/tests/TeamsISO.App.Tests/ViewModels/CommandPaletteMatchesTests.cs
(16 cases): Theory pinning case-insensitive label / category /
keyword Contains, plus a full-vocabulary spread test counting
hits for "theme" (3), "stop" (1), "ndi" (2), "App" (5 — four
App-category cmds + the Apply transcoder topology substring
match, called out in the assertion because a future move to a
stricter algo has to re-decide that affordance deliberately),
and "xyzzy" (0).
Tests: 56 → 83 in App.Tests; Engine.Tests unchanged at 103.
Total green: 186. Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
commit-and-push.ps1 (443L / 21KB) was a one-shot deployment script
that staged 25 themed commits to land the May 2026 polish batch in
a single run. That work has long since been committed; every
Stage-AndCommit call is now a no-op because nothing matches what's
already in history, and one of the file paths it referenced
(DiskSpaceWatcher.cs) was deleted alongside the recording surface.
Replaced it with a 45-line wrapper that does what the day-to-day
workflow actually needs: run build-and-test.ps1, refuse to push if
either failed, then push the current branch to origin. README and
NEXT_STEPS still reference the script name; behavior is now what
those docs imply ("build + tests + push") rather than the original
"land 25 specific commits."
Also deleted src/tests/TeamsISO.Engine.Tests/SmokeTest.cs — a
single Assert.True(true) placeholder kept "to confirm the project
is wired." 103 real engine tests confirm the project is wired far
more meaningfully than a tautology. Net test count drops 104 → 103
on the Engine side; 56 + 103 = 159 still pass.
Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TeamsLauncher.cs was 665 lines / 30KB and mixed two unrelated lifecycles:
launch / hide / show / in-call orchestration (the bulk of the file), and
the Phase E.4 experimental SetParent-based embedding (~160 lines of
distinct Win32 surface area + its own state machine).
* Services/TeamsEmbedHost.cs (177L, new) — public static class owning
EmbedTeamsInto / ResizeEmbedded / RestoreEmbed / IsEmbedded plus the
Win32 p/invokes specific to embedding (SetParent, GetWindowLongPtr,
SetWindowLongPtr, MoveWindow, SetWindowPos), the WS_* / SWP_* style
+ position constants, and the embed-state fields. The whole lifecycle
(reparent → resize → restore) now lives in one place; the comment
about WebView2 fragility moves with the code.
* Services/TeamsLauncher.cs (was 665L → now 510L) — keeps launch /
stop / join / hide / show / window-title / shortcut concerns. The
internal helper that enumerates Teams top-level windows is now
named EnumerateTopLevelTeamsWindows (was FindTeamsTopLevelWindows)
and marked `internal` so TeamsEmbedHost can call it without
duplicating the EnumWindows traversal — both classes use the same
process-name heuristic and the launcher's hide/show paths also
consume it.
* TeamsEmbedWindow.xaml.cs — call sites moved from TeamsLauncher.* to
TeamsEmbedHost.* (three references).
No behavior change. Build clean; 56 + 104 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ControlSurfaceServer.cs was 1061 lines / 47KB — a single class hosting
the HttpListener loop, the route dispatch, and every endpoint body in
between. Splits the class via partial-class into a thin host file plus
one partial per route group, all under Services/ControlSurface/.
* Services/ControlSurfaceServer.cs (was 1061L → now 400L) — kept here:
Start / Stop / DisposeAsync (the listener lifecycle), AcceptLoopAsync,
HandleRequestAsync (the route table itself, with its CORS preflight +
WebSocket upgrade + JSON dispatch), the response helpers
(ReadBodyAsync / WriteJsonAsync / TryGetBool / TryGetString), the
NotFound switch-arm, and the JsonSerializerOptions singleton.
* Services/ControlSurface/Endpoints/HomeEndpoints.cs — GetServerInfo,
TryRead helper.
* Services/ControlSurface/Endpoints/ParticipantsEndpoints.cs (the
biggest split) — GetParticipants, SetIsoOverrideByIdAsync,
ClearIsoOverrideByIdAsync, TryParseEnum, ToggleIsoByIdAsync,
ToggleIsoByNameAsync, ToggleByIdAsync. Together: every /participants/*
handler.
* Services/ControlSurface/Endpoints/PresetsEndpoints.cs — RefreshDiscovery,
StopAllAsync, ApplyPresetAsync.
* Services/ControlSurface/Endpoints/TeamsEndpoints.cs — InvokeTeams
(the helper that maps a TeamsControlBridge result to the JSON body).
* Services/ControlSurface/Endpoints/TopologyEndpoints.cs — GetTopology,
ApplyTopologyAsync, RestoreTopologyAsync.
* Services/ControlSurface/Endpoints/NotesEndpoints.cs — AppendNote.
* Services/ControlSurface/Endpoints/ThumbnailEndpoint.cs —
TryEncodeThumbnailJpeg (which is actually the BMP path now) +
EncodeBmpDownscaled + the LE byte writers. The legacy
TryEncodeThumbnailJpeg_WpfDeadCode helper that was dead-coded "for
posterity" is gone — no call sites; we removed-comments-on-removed-
code is the anti-pattern we wanted to fix.
* Services/ControlSurface/WebSocketHub.cs — HandleWebSocketAsync,
PushSnapshotIfChangedAsync, SendAsync, GetSnapshotJsonAsync. The
push-timer wiring stays in the host's Start() so the lifetime is
obvious where the connection is opened.
No behavior change. The route table in HandleRequestAsync still
dispatches by (HttpMethod, path) — only the handler bodies moved.
Build clean; 56 + 104 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
App.xaml.cs was 461 lines / 21KB and conflated four concerns: process-
level lifecycle (mutex / message pump filter / shutdown), engine bootstrap
(NDI runtime / IsoController / view model construction), crash handling
(three exception channels + log directory + dialog), and the background
update-checker kickoff.
Splits via partial-class into themed sibling files:
* App.xaml.cs (was 461L → now 219L) — class skeleton, fields, internal
property accessors, Win32 P/Invoke surface, OnStartup as a wiring
pipeline that calls the bootstrap steps in order, OnExit, CLI parser.
* App.Bootstrap.cs (250L, new) — linear startup steps:
TryAcquireSingleInstance, TryBootstrapNdiInterop, BootstrapEngine,
ConstructAndShowMainWindow, BootstrapControlSurfaceServices,
BootstrapTrayIcon, TryShowOnboarding, TryAutoLaunchTeams. Each
returns a signal (bool / window ref) when OnStartup needs it to
decide whether to continue.
* App.CrashHandlers.cs (93L, new) — OnAppDomainUnhandled,
OnDispatcherUnhandled, OnUnobservedTaskException, TryLogFatal,
TryShowCrashDialog, LogDirectory.
* App.UpdateCheckBootstrap.cs (42L, new) — StartBackgroundUpdateCheck
(24h-throttled, fire-and-forget).
OnStartup's body is now a 30-ish-line procedure that names each step,
which is what the original was trying to be. Comments inline the
"happened before, kept here for reason X" notes (theme.Apply before
window show; CLI args parsed before InitializeAsync). Behavior is
unchanged — Shutdown codes, error paths, and the side-effect order are
all preserved.
Build clean (0 warnings, 0 errors); 56 + 104 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation
loops, Teams UIA plumbing, and the auto-apply-last-preset state machine
sitting on top of the actual MainViewModel surface (constructor, props,
OnStatsTick). Splits the class via partial-class into themed siblings:
* MainViewModel.cs (was 1017L → now 699L) — fields, properties,
constructor that wires every Command, OnStatsTick + Dispose. This
remains the thin aggregator.
* MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper,
JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle
(already-tested static), PollTeamsMeetingState (the 1Hz UIA probe
formerly inlined in OnStatsTick).
* MainViewModel.PresetCommands.cs (108L, new) —
RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences
(called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile
step), and the _pendingPreset* private-field set that backs the path.
* MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync,
StopAllIsosAsync (with the default-No confirmation dialog),
SnapshotAll. RecordingCommands.cs from the original punch list is
intentionally absent — the recording surface was axed at 1d1ce6a;
what remains here is bulk-state ops across the participants
collection (note in the file header).
Why partial-class instead of helper-services or composed objects: every
extracted method touches the same private dispatcher / controller /
participants / toast state. Composing would require either passing
those references in (verbose call sites) or extracting them to a
shared private context object (boilerplate). Partial gives us
file-level separation without spreading the state contract.
ExtractMeetingTitle stays internal-static so the existing
MeetingTitleExtractionTests (10 cases) keep finding it. Build clean;
56 App + 104 Engine tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>