Compare commits

...

31 commits

Author SHA1 Message Date
edb7975039 rebrand: rename all TeamsISO source paths to Dragon-ISO
Some checks failed
CI / build-and-test (push) Failing after 29s
- Rename solution files: TeamsISO.sln/slnf -> Dragon-ISO.sln/slnf
- Rename all src/TeamsISO.* directories and project files
  to src/Dragon-ISO.* equivalents
- Update .gitignore to exclude build/test output logs
- Update ci.yml, CHANGELOG.md, build-and-test.ps1, docs references

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:18:27 -04:00
3cd2fc1dba @
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>
@
2026-05-31 11:16:40 -04:00
fc76b0dfb3 Add Dragon-ISO installer implementation plan 2026-05-31 11:02:37 -04:00
c5314ebae3 Add Dragon-ISO installer design spec 2026-05-31 10:56:30 -04:00
ab47cccd42 release: cut v1.0.0 — trim internal docs, polish README/CHANGELOG/MSI metadata
Some checks failed
CI / build-and-test (push) Failing after 31s
Release / build-msi (push) Failing after 5s
2026-05-17 19:03:33 -04:00
99d6d80754 ui(iso): inline-editable Output name + default to speaker display name
Some checks failed
CI / build-and-test (push) Failing after 26s
2026-05-16 23:34:08 -04:00
dfdfa9e0e1 ui(brand): superimposed dragon watermark behind participants, theme-flipped (white/dark, black/light)
Some checks failed
CI / build-and-test (push) Failing after 27s
2026-05-16 19:10:36 -04:00
80d9baf2d0 ui(header): drop Cmd+K button + swap settings glyph for a true gear (U+2699)
Some checks failed
CI / build-and-test (push) Failing after 26s
2026-05-16 18:55:46 -04:00
d880941ad5 fix(ndi): canonicalize 'public' -> 'Public' in discovery + sender group strings (the real bug)
Some checks failed
CI / build-and-test (push) Failing after 26s
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.
2026-05-16 18:33:49 -04:00
1cdd4ebd04 fix(installer+wpf): REVERT runas /trustlevel demotion (it was the bug, not the fix)
Some checks failed
CI / build-and-test (push) Failing after 26s
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.
2026-05-16 16:27:23 -04:00
ea940ffac4 test(engine): extract ShouldAutoRebuild as pure fn + cover 6 cases
Some checks failed
CI / build-and-test (push) Failing after 27s
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).
2026-05-16 13:38:44 -04:00
aaa2a76814 docs(next-steps): NDI Find stuck-at-zero was the real bug; self-heal in c30a616
Some checks failed
CI / build-and-test (push) Failing after 26s
2026-05-16 13:36:58 -04:00
c30a6163c8 fix(engine): self-healing NDI discovery + unified poll loop
Some checks failed
CI / build-and-test (push) Failing after 26s
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.
2026-05-16 13:35:22 -04:00
54ee578fe9 fix(wpf): de-elevate via runas env-var marker (CLI arg breaks runas /trustlevel)
Some checks failed
CI / build-and-test (push) Failing after 26s
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.
2026-05-16 12:16:55 -04:00
2552d46210 fix(installer): wrap shortcut Target in 'runas /trustlevel:0x20000'
Some checks failed
CI / build-and-test (push) Failing after 27s
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.
2026-05-16 11:43:54 -04:00
0e73746b58 docs(next-steps): root cause was explorer-spawn elevation, fix shipped in 191b2c5
Some checks failed
CI / build-and-test (push) Failing after 27s
2026-05-16 11:39:31 -04:00
191b2c5f52 fix(wpf): de-elevate when spawned by elevated explorer (NDI mDNS isolation)
Some checks failed
CI / build-and-test (push) Failing after 27s
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.
2026-05-16 11:36:52 -04:00
e01fa364e8 docs(next-steps): cold-start launch fix verified — 3 launch paths green
Some checks failed
CI / build-and-test (push) Failing after 26s
2026-05-16 11:24:37 -04:00
09e5b59dfd fix: cold-start discovery + installer shortcuts + single-instance hardening
Some checks failed
CI / build-and-test (push) Failing after 26s
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/.
2026-05-16 11:23:19 -04:00
f47edfb2f6 ISO toggle: widen column 110->124, tighten padding so 'Enable' fits
Some checks failed
CI / build-and-test (push) Failing after 28s
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.
2026-05-16 08:57:27 -04:00
47914fcd77 ISO toggle: square corners to match the rest of the button family
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.
2026-05-16 08:56:50 -04:00
dba7dcc8a8 gear icon: swap Path glyph for U+2699 + bump column to 56px
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.
2026-05-16 08:56:43 -04:00
6c9bee7391 fix(wpf): catch participant-left race in ToggleIsoAsync, toast instead of crash
Some checks failed
CI / build-and-test (push) Failing after 27s
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.
2026-05-16 08:48:06 -04:00
84861dafa5 test: integration — App+MainWindow STA smoke, control-surface live VM, theme XAML load
Some checks failed
CI / build-and-test (push) Failing after 30s
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>
2026-05-15 21:34:09 -04:00
6505a3cab0 test: services — NotesService, UpdateChecker, PresetApplier, OscBridge, IsoController
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>
2026-05-15 21:06:45 -04:00
d91f95379b test: ControlSurfaceServer route table smoke coverage
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>
2026-05-15 20:52:36 -04:00
fbcc56289e test: ThemeManager + CommandPaletteViewModel.Matches coverage
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>
2026-05-15 20:47:25 -04:00
e96a30b76f chore: trim stale batch-commit script + drop SmokeTest placeholder
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>
2026-05-15 20:16:14 -04:00
1f07992100 refactor(services): extract TeamsEmbedHost from TeamsLauncher
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>
2026-05-15 20:13:57 -04:00
2640739bfc refactor(control-surface): split server into endpoint partials
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>
2026-05-15 19:48:03 -04:00
e67c02c2ff refactor(app): split App.xaml.cs into themed partial files
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>
2026-05-15 19:36:07 -04:00
230 changed files with 7002 additions and 10194 deletions

View file

@ -1,4 +1,4 @@
name: CI name: CI
on: on:
push: push:
@ -21,15 +21,15 @@ jobs:
echo "$HOME/.dotnet" >> $GITHUB_PATH echo "$HOME/.dotnet" >> $GITHUB_PATH
echo "DOTNET_ROOT=$HOME/.dotnet" >> $GITHUB_ENV echo "DOTNET_ROOT=$HOME/.dotnet" >> $GITHUB_ENV
- name: Restore (Linux solution filter excludes Windows-only WPF app) - name: Restore (Linux solution filter — excludes Windows-only WPF app)
run: dotnet restore TeamsISO.Linux.slnf run: dotnet restore Dragon-ISO.Linux.slnf
- name: Build (Release, treat warnings as errors) - name: Build (Release, treat warnings as errors)
run: dotnet build TeamsISO.Linux.slnf --configuration Release --no-restore run: dotnet build Dragon-ISO.Linux.slnf --configuration Release --no-restore
- name: Test (excluding requires=ndi) - name: Test (excluding requires=ndi)
run: > run: >
dotnet test TeamsISO.Linux.slnf dotnet test Dragon-ISO.Linux.slnf
--configuration Release --configuration Release
--no-build --no-build
--logger "trx;LogFileName=test-results.trx" --logger "trx;LogFileName=test-results.trx"
@ -47,7 +47,7 @@ jobs:
-reports:"**/coverage.cobertura.xml" \ -reports:"**/coverage.cobertura.xml" \
-targetdir:coverage-report \ -targetdir:coverage-report \
-reporttypes:"Cobertura;TextSummary" \ -reporttypes:"Cobertura;TextSummary" \
-assemblyfilters:"+TeamsISO.Engine;-TeamsISO.Engine.NdiInterop" -assemblyfilters:"+Dragon-ISO.Engine;-Dragon-ISO.Engine.NdiInterop"
- name: Enforce coverage threshold (80%) - name: Enforce coverage threshold (80%)
run: | run: |

View file

@ -1,4 +1,4 @@
name: Release name: Release
# Triggered by pushing an annotated tag of the form v1.2.3 (or any v-prefixed # Triggered by pushing an annotated tag of the form v1.2.3 (or any v-prefixed
# semver). The job runs on a Windows runner because building the WiX MSI # semver). The job runs on a Windows runner because building the WiX MSI
@ -54,48 +54,48 @@ jobs:
} }
- name: Restore (Windows solution filter) - name: Restore (Windows solution filter)
run: dotnet restore TeamsISO.Windows.slnf run: dotnet restore Dragon-ISO.Windows.slnf
- name: Build (Release, treat warnings as errors) - name: Build (Release, treat warnings as errors)
run: dotnet build TeamsISO.Windows.slnf --configuration Release --no-restore /p:Version=${{ steps.ver.outputs.version }} run: dotnet build Dragon-ISO.Windows.slnf --configuration Release --no-restore /p:Version=${{ steps.ver.outputs.version }}
- name: Run unit tests (excluding requires=ndi) - name: Run unit tests (excluding requires=ndi)
run: > run: >
dotnet test TeamsISO.Windows.slnf dotnet test Dragon-ISO.Windows.slnf
--configuration Release --configuration Release
--no-build --no-build
--filter "Category!=ndi&requires!=ndi" --filter "Category!=ndi&requires!=ndi"
- name: Publish TeamsISO.App (framework-dependent, win-x64) - name: Publish Dragon-ISO.App (framework-dependent, win-x64)
run: > run: >
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj
--configuration Release --configuration Release
--runtime win-x64 --runtime win-x64
--self-contained false --self-contained false
--output publish/TeamsISO --output publish/Dragon-ISO
/p:Version=${{ steps.ver.outputs.version }} /p:Version=${{ steps.ver.outputs.version }}
- name: Publish TeamsISO.Console (framework-dependent, win-x64) - name: Publish Dragon-ISO.Console (framework-dependent, win-x64)
run: > run: >
dotnet publish src/TeamsISO.Console/TeamsISO.Console.csproj dotnet publish src/Dragon-ISO.Console/Dragon-ISO.Console.csproj
--configuration Release --configuration Release
--runtime win-x64 --runtime win-x64
--self-contained false --self-contained false
--output publish/TeamsISO-Console --output publish/Dragon-ISO-Console
/p:Version=${{ steps.ver.outputs.version }} /p:Version=${{ steps.ver.outputs.version }}
# Code-sign the WPF .exe BEFORE the MSI is built, so the MSI's embedded # Code-sign the WPF .exe BEFORE the MSI is built, so the MSI's embedded
# binaries are signed too. Skipped silently when the signing secrets # binaries are signed too. Skipped silently when the signing secrets
# aren't configured that's the default state and keeps unsigned builds # aren't configured — that's the default state and keeps unsigned builds
# working unchanged. # working unchanged.
# #
# To enable signing, set both Forgejo Actions secrets: # To enable signing, set both Forgejo Actions secrets:
# SIGN_CERT_PFX_BASE64 base64 of your code-signing PFX file # SIGN_CERT_PFX_BASE64 — base64 of your code-signing PFX file
# ( certutil -encode in.pfx out.b64; strip BEGIN/END lines ) # ( certutil -encode in.pfx out.b64; strip BEGIN/END lines )
# SIGN_CERT_PASSWORD the PFX password # SIGN_CERT_PASSWORD — the PFX password
# Optionally: # Optionally:
# SIGN_TIMESTAMP_URL RFC 3161 timestamp server (default: digicert) # SIGN_TIMESTAMP_URL — RFC 3161 timestamp server (default: digicert)
- name: Sign TeamsISO.exe (optional, skipped if no cert) - name: Sign Dragon-ISO.exe (optional, skipped if no cert)
if: ${{ steps.signcfg.outputs.enabled == 'true' }} if: ${{ steps.signcfg.outputs.enabled == 'true' }}
env: env:
SIGN_CERT_PFX_BASE64: ${{ secrets.SIGN_CERT_PFX_BASE64 }} SIGN_CERT_PFX_BASE64: ${{ secrets.SIGN_CERT_PFX_BASE64 }}
@ -116,13 +116,13 @@ jobs:
/fd SHA256 ` /fd SHA256 `
/td SHA256 ` /td SHA256 `
/tr $tsUrl ` /tr $tsUrl `
'publish/TeamsISO/TeamsISO.exe' 'publish/Dragon-ISO/DragonISO.exe'
if ($LASTEXITCODE -ne 0) { throw "signtool failed on TeamsISO.exe (exit $LASTEXITCODE)" } if ($LASTEXITCODE -ne 0) { throw "signtool failed on Dragon-ISO.exe (exit $LASTEXITCODE)" }
Remove-Item $pfxPath -Force Remove-Item $pfxPath -Force
- name: Build MSI installer - name: Build MSI installer
run: > run: >
dotnet build installer/TeamsISO.Installer.wixproj dotnet build installer/Dragon-ISO.Installer.wixproj
--configuration Release --configuration Release
/p:Version=${{ steps.ver.outputs.version }} /p:Version=${{ steps.ver.outputs.version }}
@ -136,7 +136,7 @@ jobs:
"name=$($msi.Name)" >> $env:GITHUB_OUTPUT "name=$($msi.Name)" >> $env:GITHUB_OUTPUT
Write-Host "MSI: $($msi.FullName) ($($msi.Length) bytes)" Write-Host "MSI: $($msi.FullName) ($($msi.Length) bytes)"
# Sign the produced MSI itself. Same gate as exe signing runs only if # Sign the produced MSI itself. Same gate as exe signing — runs only if
# the cert secret is set. Splitting the two stages means the inner exe # the cert secret is set. Splitting the two stages means the inner exe
# is signed before being embedded, AND the wrapping MSI carries its own # is signed before being embedded, AND the wrapping MSI carries its own
# signature for SmartScreen. # signature for SmartScreen.
@ -155,6 +155,7 @@ jobs:
$signtool = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter signtool.exe ` $signtool = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter signtool.exe `
| Where-Object { $_.FullName -match '\\x64\\' } ` | Where-Object { $_.FullName -match '\\x64\\' } `
| Select-Object -First 1 | Select-Object -First 1
if (-not $signtool) { throw 'signtool.exe not found on runner' }
& $signtool.FullName sign ` & $signtool.FullName sign `
/f $pfxPath ` /f $pfxPath `
/p $env:SIGN_CERT_PASSWORD ` /p $env:SIGN_CERT_PASSWORD `
@ -166,7 +167,7 @@ jobs:
Remove-Item $pfxPath -Force Remove-Item $pfxPath -Force
- name: Upload MSI as workflow artifact - name: Upload MSI as workflow artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: ${{ steps.msi.outputs.name }} name: ${{ steps.msi.outputs.name }}
path: ${{ steps.msi.outputs.path }} path: ${{ steps.msi.outputs.path }}
@ -195,7 +196,7 @@ jobs:
Write-Host "No release found for $env:TAG; creating one." Write-Host "No release found for $env:TAG; creating one."
$body = @{ $body = @{
tag_name = $env:TAG tag_name = $env:TAG
name = "TeamsISO $env:TAG" name = "Dragon-ISO $env:TAG"
body = "Automated build from tag $env:TAG." body = "Automated build from tag $env:TAG."
draft = $false draft = $false
prerelease = $env:TAG -match '-(alpha|beta|rc)' prerelease = $env:TAG -match '-(alpha|beta|rc)'

6
.gitignore vendored
View file

@ -31,3 +31,9 @@ Thumbs.db
# Local Claude session metadata # Local Claude session metadata
.claude/ .claude/
# Build / test output logs
*.log
full-output.txt
test-output.txt
test-run.txt

View file

@ -1,340 +1,86 @@
# Changelog # Changelog
All notable changes to TeamsISO are documented here. The format follows All notable changes to Dragon-ISO are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [1.0.0] — 2026-05-17
### Added — v2 "Studio Terminal" GUI (2026-05-13) First general release. Windows-only, .NET 8 WPF, NDI 6.
The May 2026 ground-up redesign — explicit anti-reference to "the v1 ### Engine
GUI screamed AI made it" — landed on the WPF host
(`src/TeamsISO.App/`). The shape brief lives at
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`. An earlier WinUI
3 replatform was scoped on 2026-05-12 and abandoned in favour of doing
the redesign in WPF (activation blockers + redundant work given the
shared view-model surface). The abandoned migration plan + bootstrap
probe are archived under `docs/archive/`.
- **PRODUCT.md** + **DESIGN.md**: impeccable context for the redesign. - **Participant discovery** over NDI with name cleanup — strips the
Solo broadcast operator at 1:50am is the canonical persona; "vibe-coded "MS Teams - " / "(Teams) " prefixes and surfaces the operator-friendly
GUI" is the explicit anti-reference. Tokens cover dark + light palettes display name.
with context-aware accent split (cyan surface fill stays bright in - **Per-participant ISO outputs** with normalized framerate, resolution,
both modes; cyan-as-text darkens to `#0E7C82` on light for AA contrast). aspect mode, and audio routing. Each ISO is an individually-addressable
- **Theme system** (`Themes/Theme.Dark.xaml`, `Theme.Light.xaml`, NDI source.
`WildDragonTheme.xaml`) + `Services/ThemeManager.cs` singleton that - **NDI Groups** support — discovery and sender. One-click "Apply
swaps the merged dictionary at runtime, reads transcoder topology" pins Teams' raw broadcasts to a private
`HKCU\…\AppsUseLightTheme` for System mode, subscribes to `Dragon-ISO-input` group while Dragon-ISO re-emits on `Public`.
`SystemEvents.UserPreferenceChanged`, persists via - **Self-healing finder** — if the NDI runtime stalls (zero discovered
`UIPreferences.Theme`. `Ctrl+T` toggles dark ↔ light. sources past a startup grace period, or sources go from present to
- **v2 main window shell**: default system title bar; 32px header (Wild empty and stay that way), the engine rebuilds the finder automatically.
Dragon mark + wordmark left, ⌘K / theme / settings icons right); 40px - **Real-time recording** — per-output raw BGRA stream + `manifest.json`
transport strip (`● 02:14:32 PART 4 · LIVE 2 CTRL :9755`); body with + an FFmpeg `convert.cmd` script for post-production conversion to
alert banner + update banner + action toolbar + participants H.264 MKV. Recording is opt-in globally and per-participant.
DataGrid; conditional `IN CALL` meeting bar at bottom; slide-over
settings drawer (420px from right) with OUTPUT / NETWORK / APP tabs.
The v1 72px rail, the 380px permanent settings panel, and the
six-column footer are gone.
- **Task 39 — participants table v2**: five columns (24px state LED,
name + codec caption, 110px audio meter, 130px mono output name, 100px
ISO pill), 52px rows, full-row active-speaker tint (replaces the v1
left-edge stripe).
- **Task 40 — Ctrl+K command palette**: `Views/CommandPaletteWindow.xaml`
+ `ViewModels/CommandPaletteViewModel.cs`. Centered 560×360 floating
window with fuzzy search across Quick / Teams / Presets / Output /
Network / App categories. ↑/↓ navigates, Enter invokes, Esc closes.
- **Interactive HTML preview** at `docs/preview/redesigned-mainwindow.html`
for stakeholders to see the v2 shell.
### Added — May 2026 feature batch ### UI — "Studio Terminal"
#### Engine - **Dark and light themes** with a runtime swap and a system-follow mode.
- NDI Groups: discovery + sender support so Teams' raw broadcasts can be The Wild Dragon mark, the participants-grid watermark, and every accent
pinned to a private "teamsiso-input" group while TeamsISO's own brush respond to the active theme.
normalized outputs broadcast on Public. - **Header**: brand mark, theme toggle, settings gear.
- One-click "Apply transcoder topology" writes `ndi-config.v1.json` so all - **Transport strip**: session timer, participant count, live ISO count,
Teams broadcasts go to the private group and TeamsISO re-emits on Public. control-surface URL — at-a-glance status.
- `RawBgraRecorderSink` per-output recorder: `IRecorderSink` interface + - **Participants table**: 24px state LED, 106px live thumbnail preview,
raw BGRA stream + `manifest.json` + `convert.cmd` script for FFmpeg name + caption, 5-bar audio meter, **inline-editable output name**,
conversion to H.264 MKV. CFG button (per-row override editor), ISO enable pill.
- Recording markers: `IRecorderSink.AddMarker(label)` fan-out via - **Settings drawer** — slide-over from the right with OUTPUT / NETWORK /
`IIsoController.AddRecordingMarker`. Markers land in `manifest.json` APP tabs.
under `markers[]` for post-production chaptering. - **Ctrl+K command palette** — fuzzy search across Quick / Teams /
- Preview thumbnails: `IsoPipeline.LatestProcessedFrame` published via Presets / Output / Network / App categories.
`Volatile.Read` so the UI can render 160×90 BGRA thumbnails in the - **Live preview thumbnails** in the participants table; right-click →
participants DataGrid at 1Hz. Open preview… spawns a non-modal floating window suitable for a
- Idempotent `ParticipantTracker.HandleAdded`: re-emitting Added for an secondary monitor.
already-live source refreshes LastSeen instead of duplicating the row.
Fixes the "click Refresh and rows ghost-duplicate" bug introduced by
the Refresh-Discovery affordance.
- `IIsoController.RefreshDiscovery()` rebuilds the NDI finder on the next
poll tick — useful right after applying a new transcoder topology.
- `IIsoController.AddRecordingMarker(label)` fan-out to every active recorder.
- `IIsoController.SetRecording(enabled, dir)` global recording toggle;
per-participant override via `EnableIsoAsync(...recordOverride...)`.
#### Host (WPF) ### Output name template
- Active Speaker as a synthetic routable participant with deterministic v5
GUID derived from `auto-mix:<machine>`.
- Auto-disable on departure: when a participant's NDI source disappears,
optionally tear down their pipeline.
- Operator presets: chromeless `Presets…` dialog with Save / Apply /
Delete / Duplicate / Export / Import. Persisted at
`%LOCALAPPDATA%\TeamsISO\presets.json`. Bundle format
`teamsiso-presets-bundle/v1` for migration between machines.
- Auto-apply last preset on launch (configurable, off by default).
- `--apply-preset NAME` CLI flag for desktop-shortcut workflows.
- `PresetApplier` — single source of truth for "apply preset to live
participants" used by the dialog, REST surface, and auto-apply path.
- Live preview thumbnails per participant (160×90 BGRA WriteableBitmap).
- Right-click context menu on participant rows: Toggle ISO, Record-this-
participant, Copy NDI source name.
- Live filter input (substring match on display name).
- "Enable all online" + "Stop all ISOs" + "Refresh" header actions.
- Per-participant recording opt-out checkbox (Rec column).
- Custom NDI output name template with `{name}`/`{guid}`/`{machine}`/
`{timestamp}` tokens.
- Phase E.1 — Launcher: rail "Launch / Stop Teams" toggle.
- Phase E.2 — Window orchestration: hide / show Teams windows from the rail.
- Phase E.3 — In-call controls (UIA): Mute, Camera, Share, Leave, Raise hand,
plus PostMessage shortcut forwarding fallback. Candidate names localized
for English / German / Spanish / French / Portuguese / Japanese.
- Crash diagnostics: AppDomain + Dispatcher + TaskScheduler unhandled
exception handlers wired to Serilog.Critical + user-facing dialog.
- First-launch onboarding dialog with 5-step setup checklist.
- About dialog gained "Show welcome", "Check for updates", "Export diagnostics"
buttons.
- Diagnostic bundle export: zips logs + config + presets + version metadata
into `~/Downloads/teamsiso-diagnostics-<ts>.zip` for bug reports.
- Update check: manual via About + auto-on-launch banner (throttled to 24h,
opt-out via flag file at `%LOCALAPPDATA%\TeamsISO\no-update-check.flag`).
- Disk space watcher auto-disables recording at <1GB free.
- Settings panel refactored into OUTPUT / NETWORK / DISPLAY tabs.
- Reset-to-defaults button in OUTPUT tab.
- Enriched footer: REC badge, control-surface badge, session timer (HH:MM:SS
since first ISO went live), dynamic status text ("3/5 ISOs live · 2 recording").
- Window-scoped keyboard shortcuts: F1 (help), Ctrl+M (marker), Ctrl+Shift+S
(stop all), Ctrl+R (refresh discovery).
- F1 help / cheat-sheet dialog.
- `UIPreferences` static persists `HideLocalSelf`, `AutoDisableOnDeparture`,
`ParticipantSort` (JoinOrder / Alphabetical / OnlineFirst) across launches
to `%LOCALAPPDATA%\TeamsISO\ui-prefs.json`.
- Pop-out per-participant preview window (right-click → Open preview…)
refreshes at ~20Hz and is multi-monitor friendly.
- Configurable participant sort order via the DISPLAY tab dropdown.
- Stop-All confirms before tearing down running pipelines (catches
mid-show misclicks).
- About dialog gained "Logs / Recordings / Notes" folder shortcut buttons.
- `NotesWindow` inline viewer for today's show-notes file with 2s polling.
- Duplicate-preset action in the Presets dialog with smart `(copy N)`
name suggestions.
- `--apply-preset NAME` command-line flag for desktop-shortcut workflows.
- New `TeamsISO.App.Tests` net8.0-windows test project. Initial coverage:
`OperatorPresetStoreTests` (round-trip, name collisions, schema, bundle
import/export, garbage-file resilience), `OutputNameTemplateTests` (token
expansion + sanitization), `OscMessageTests` (wire-format parsing of
int/float/string/T/F type tags). Backed by an `InternalsVisibleTo` grant
+ a test-only `OperatorPresetStore.PathOverride` hook.
- `IsoHealthStats.PeakAudioLevel` field + DataGrid VU-bar UI scaffolding.
Engine still emits 0.0 (audio capture is a focused follow-up); the bar's
decay logic is in place so it animates as soon as engine-side audio
parsing lands.
- `MediaFoundationRecorderSink` scaffold under `#if MF_AVAILABLE` for
inline H.264 encoding via Vortice.MediaFoundation. ~10× smaller files
than the raw BGRA recorder. Activation steps documented at
`docs/REAL-TIME-RECORDING.md`.
- System-tray icon + minimize-to-tray toggle. Adds
`<UseWindowsForms>true</UseWindowsForms>` for `NotifyIcon`; the
`TrayIconHost` lives on `App` (process lifetime, not main-window
lifetime). Right-click menu has Show / Stop all ISOs / Exit.
- Built-in NDI test pattern: `TeamsISO.Console --test-pattern` broadcasts
a synthetic 1280×720 30fps source named `TEAMSISO_TEST` showing SMPTE
color bars + a moving sweep band. Verifies NDI runtime, sender
configuration, and downstream discovery without needing Teams running.
Backed by `TestPatternGenerator` in the engine + 4 unit tests covering
buffer size, alpha, color distinctness, and sweep animation.
- Always-toast on participant disconnect, regardless of `AutoDisableOnDeparture`
setting. Distinguishes "ISO torn down" (auto-disable on) from "ISO still
running on slate" (auto-disable off) so operators don't miss a silent
drop mid-show.
- **Restart this ISO** right-click action — disable + brief delay + re-enable
for one participant only. Useful when a single feed flakes without
affecting other ISOs.
- **Roll recording** action: rolls every active recording into a new chunk
(disable + re-enable each pipeline; recorder finalizes its `manifest.json`
and starts a fresh subdirectory). Surfaced via `MainViewModel.RollRecordingCommand`,
REST `POST /recording/roll`, and OSC `/teamsiso/recording/roll`. Useful
for chaptering between show segments.
- **Engine audio peak metering**`IsoHealthStats.PeakAudioLevel` now
reports real values (was always 0.0). New `INdiInterop.CaptureAudioPeak`
method polls audio frames; production `NdiInteropPInvoke` parses
`NDIlib_audio_frame_v3_t` and computes max-absolute peak across all
channels. `NdiReceiver` runs a sibling audio capture loop on the same
lifetime so the existing video path is unaffected. UI VU bars in the
participants DataGrid now animate against real source audio. Failures
in the audio loop are caught + logged but never re-thrown — a
misbehaving audio path must never tear down the live video pipeline.
14 new unit tests in `AudioPeakComputerTests` covering FLTP / FLT / PCM
s16 across edge cases (clipping, `short.MinValue` overflow, defensive
`totalSamples`-vs-buffer mismatch handling).
#### LAN-reachable control surface - New default: **the speaker's display name** (`{name}`). Per-participant
- `ControlSurfaceServer.Start(port, bindToLan)` and `OscBridge.Start(port, overrides are inline-editable in the table. Empty-name fallback to
bindToLan)` switch between `127.0.0.1` and all-interfaces (`http://+:port/`, `Dragon-ISO_{guid}` keeps the NDI sender uniquely identifiable while a
`IPAddress.Any`) based on the new `ControlSurfaceLanReachable` UI preference. participant's display name resolves upstream.
Settings VM persists the toggle, restarts both surfaces on flip, and - Available tokens: `{name}`, `{guid}`, `{machine}`, `{timestamp}`.
surfaces a `ControlSurfaceUrl` (computed from the host's first physical-NIC
routable IPv4 — Tailscale / VPN / APIPA addresses are skipped) plus a Copy
button. Use case: headless host PC running Teams + TeamsISO; thin client
on the same LAN drives `/ui` or hits the REST endpoints. Closed-network
deployment, no auth — documented as a trusted-LAN-only mode in
`docs/CONTROL-SURFACE.md`. First-time use requires a one-shot
`netsh http add urlacl url=http://+:9755/ user=Everyone` (so the listener
can bind without admin); the diagnostic warning fires the exact command
string in the log if the bind fails.
#### "I only see TeamsISO" — Phase E.1+E.2 quality-of-life ### Operator presets
For operators who want to launch TeamsISO and never look at the Teams UI:
- **Launch Microsoft Teams on TeamsISO startup** preference (DISPLAY tab).
Auto-fires Teams in the background each time TeamsISO starts; the Teams
window appears briefly during boot then can be hidden automatically.
- **Auto-hide Teams windows when launched** preference (DISPLAY tab).
`TeamsLauncher.AutoHideAfterLaunchAsync` polls for 15s after launch
hiding splash, main window, and follow-up panels as they materialize.
Works on top of the existing eye-toggle for manual restore.
- **Quick-join Teams meeting from URL** — small input + Join button in the
IN-CALL bar. Paste a `https://teams.microsoft.com/l/meetup-join/...`
or `msteams:/l/meetup-join/...` link, click Join, Teams launches into
the meeting in one shot. Eliminates the open-Teams → Calendar → find →
click Join dance. Pairs with auto-hide so the operator goes straight
from "I have a meeting link" to "I'm in the meeting, driving routing".
- **Teams meeting state pill** in the IN-CALL bar — shows `IN CALL · <meeting title>`
/ `READY` / empty. UIA-driven probe of Teams' Leave button at 1Hz; the
meeting title comes from Teams' window title with the brand suffix
stripped. So an operator with auto-hide on knows whether they're in a
meeting AND which one without restoring the Teams window. 10 new unit
tests on `MainViewModel.ExtractMeetingTitle`.
- **Rail Launch Teams click semantics** — was ambushing operators with a
"Close all Teams windows now?" dialog whenever Teams was running (e.g.
when hidden via the eye-toggle). Now click = launch / surface / restore;
right-click = stop. `TeamsLauncher.TryLaunch` now collects per-attempt
errors (no more silent fall-through) and adds the AppX-activation
fallback for hosts where the `ms-teams:` URI handler is misconfigured.
- **Auto-record when Teams joins a meeting** preference. Recording auto-
flips ON when Teams transitions into a call (UIA Leave button appears)
and auto-flips OFF when the call ends. Removes the manual Record toggle
step from unattended-show workflows.
- **Phase E.4 (experimental) — SetParent embedding.** Reparents Teams' main
window into a TeamsISO-owned host (`TeamsEmbedWindow`) so Teams appears
visually INSIDE TeamsISO. Strips Teams' window chrome and resizes to
fit. Modern Teams runs WebView2 in its main window which can render
glitches after reparent; if so the operator unticks and falls back to
auto-hide mode. `TeamsLauncher.EmbedTeamsInto` / `RestoreEmbed` /
`ResizeEmbedded` form the lifecycle. Restore-on-close runs in a finally
block so a crash can't leave Teams orphaned with stripped window styles.
- **Right-click → Save current frame** on a participant row. Encodes the
latest `ProcessedFrame` as a PNG under
`%USERPROFILE%\Pictures\TeamsISO\<participant>_<timestamp>.png`.
Useful for highlight reels, social posts, bug reports.
- **Open /ui button** in Settings → DISPLAY → Control surface section.
Fires the URL into the default browser for one-click preview of the
embedded control panel.
- **Recording badge in footer shows elapsed duration** alongside the count
(`REC 3 · 12:45`). Separate timer from the session timer because
recording can start AFTER the meeting begins.
- **MUTED / CAM OFF pills** in the IN-CALL bar — UIA detects whether the
local user is muted or has their camera off, surfaces as coral pills.
Operator with auto-hide knows the local state without restoring Teams.
- **Recording drive free space** in the footer (`· 245 GB free`). Coral
tint below 10GB; existing DiskSpaceWatcher still auto-disables at 1GB.
- **Loudest sort mode** for the participants DataGrid + **active speaker
row highlight** (3px cyan left border + tinted background) on whoever's
speaking. Operators react to who's talking without scanning every VU bar.
- **Snapshot all enabled participants** — header action saves every
enabled participant's current frame as a PNG into a fresh timestamped
subfolder under `%USERPROFILE%\Pictures\TeamsISO\snapshots-<ts>\`.
- **NumPad 1-9 (and Digit 1-9) hotkeys** toggle the Nth visible
participant's ISO. Sort + filter aware — index matches what's on screen.
Generic `RelayCommand<T>` added to ViewModels/RelayCommand.cs so XAML
CommandParameter strings convert to the action's T.
#### UI polish — visible affordances on the dark canvas - Save current per-participant ISO assignments + custom output names to
- Hover state on every themed button (Ghost / Caption / RailIcon / IsoToggle) `%LOCALAPPDATA%\Dragon-ISO\presets.json`. Optional auto-apply on next
was barely distinguishable from the resting state. Bumped `Wd.SurfaceHover` launch.
+ `Wd.SurfaceActive` colours for sufficient contrast, added dedicated
`Wd.Button.HoverBg` / `Wd.Button.PressBg` brushes with a slight chroma tint,
and added cyan accent borders so mouse-hover and tab-focus give an
unmistakable affordance regardless of which surface the button sits on.
IsoToggle keeps its status-coded background (LIVE cyan / ERROR coral /
NO SIGNAL amber) on hover; the affordance is a 2px cyan border pop.
- `IsKeyboardFocused` triggers added on every themed button so tab-cycling
through the UI gives visual feedback (was nothing — `FocusVisualStyle`
was `x:Null` with no replacement).
- ScrollBar restyled: slim transparent track + tinted thumb (Edge / VS Code
pattern) in place of the chunky Win9x default with line-up / line-down
arrow buttons. Track-clicks above/below the thumb still page-scroll.
- ToolTip restyled: SurfaceElevated card with rounded 6px corner + 320px
text wrap, replacing the cream Win98 popup. Affects every tooltip across
MainWindow.
- ContextMenu / MenuItem restyled: dark card with rounded corners + cyan-
tinted hover. Affects the right-click menu on participant rows
(Toggle ISO / Restart this ISO / Open preview / Record / Copy NDI source name).
- CheckBox content no longer clips at the 380px settings panel: template's
StackPanel replaced with a Grid (Auto + *) and a TextWrapping=Wrap
resource injected into the ContentPresenter so long labels flow onto
multiple lines.
- Manual X dismiss on toast notifications for live-show situations where
the operator wants to clear visual clutter without waiting 3s.
- Footer's control-surface badge surfaces the full LAN URL (not just port)
when LAN-reachable mode is on, so a thin client can be configured by
reading the URL straight off the host's footer.
#### Control surface ### Teams orchestration
- REST API on `127.0.0.1:9755` with endpoints for participant ISO toggle (by
Id or display name), preset apply, refresh discovery, stop-all, recording
on/off, marker drop, notes, and Teams in-call commands. Documented at
`docs/CONTROL-SURFACE.md`.
- WebSocket `/ws` pushes live participant state at 4Hz with snapshot diffing.
- OSC bridge on UDP `127.0.0.1:9000` mirrors the REST vocabulary
(`/teamsiso/iso "Jane" 1`, `/teamsiso/preset "..."`, etc.).
- Embedded HTML control panel at `GET /ui` — phone-friendly remote with
live state and one-click action buttons.
- Show notes service: `POST /notes` and `/teamsiso/notes "..."` append
timestamped lines to `%LOCALAPPDATA%\TeamsISO\Notes\<YYYY-MM-DD>.md`.
#### CI / Release - Launch / stop Teams from the app.
- Forgejo CI is green; tag-push release workflow builds + tests + publishes - Hide Teams' UI windows during a show.
+ builds MSI on a Windows runner and attaches it to the auto-created - Drive in-call controls (mute, camera, share, leave, raise hand) via
release via the REST API. UIAutomation.
- Optional MSI + exe code-signing wired into `release.yml` — gated on
`SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD` Forgejo Secrets.
### Fixed ### External control surface
- `.slnf` path-separator mismatch (forward slashes for cross-platform). - REST + WebSocket on `127.0.0.1:9755` for Bitfocus Companion / Stream
- NDI native DLL resolution via `NativeLibrary` resolver. Deck / custom controllers.
- `ExpectedRuntimeVersionPrefix` updated to NDI 6 banner format. - OSC on UDP `127.0.0.1:9000` for TouchOSC.
- `NdiSourceParser` accepts current Teams desktop's `MS Teams - <name>` - Self-contained HTML control panel at `/ui` — open from any phone on
brand format. the LAN.
- ActiveSpeaker source removal no longer poisons the rename-window
heuristic for a Participant joining the same machine within the window.
- `IsoPipeline.State` access synchronized via `Volatile.Read/Write`.
- REST handlers now correctly marshal `ObservableCollection` reads + writes
through the UI dispatcher.
- WebSocket upgrade no longer falls into `res.Close()` finally block (was
killing freshly-upgraded connections).
- `ParticipantViewModel.UpdateThumbnail` defends against malformed frames
(`width*height*4 > Pixels.Length`).
- `HasThumbnail` correctly fires `PropertyChanged` when `Thumbnail`
transitions from null.
- WinForms / WPF `Application` and `MessageBox` namespace collision
(introduced when `<UseWindowsForms>true</UseWindowsForms>` was added for
the system tray) resolved via project-wide `GlobalUsings.cs`.
- `GetLanIPv4()` now skips Tailscale / VPN tunnel adapters and APIPA
(`169.254.x`) so the displayed control-surface URL points at the
routable LAN IP (verified on a host with both Ethernet 10.x and a
Tailscale 169.254 link-local — picker now correctly returns the
Ethernet address).
[Unreleased]: https://forge.wilddragon.net/zgaetano/teamsiso/compare/v0.1.0...HEAD ### Diagnostics & installer
- Rolling daily Serilog logs under `%LOCALAPPDATA%\Dragon-ISO\logs\`.
- Diagnostic bundle export — zips logs + config + presets for bug reports.
- Forgejo-backed update check (manual or silent-on-launch, throttled to
24h).
- WiX MSI installer with proper Add/Remove Programs metadata, Start Menu
+ Desktop shortcuts, and in-place upgrade.
[1.0.0]: https://forge.wilddragon.net/zgaetano/Dragon-ISO/releases/tag/v1.0.0

340
DESIGN.md
View file

@ -1,340 +0,0 @@
# DESIGN.md — TeamsISO design system
Target framework: **WPF .NET 8**. Tokens are framework-agnostic; the WPF
XAML implementation lives in `src/TeamsISO.App/Themes/`. (A WinUI 3 rebuild
was attempted and rolled back — see
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md` for the v2 shape
this design serves.)
## Color
### Strategy
**Restrained — committed accent + neutral surface.** The surface is the work;
the cyan accent is reserved for live state, focus, and the few moments that
actually need attention. Coral is reserved for destructive and error.
Everything else is neutral.
This means: no rainbow status pills, no per-feature accent colors, no
Slack-style chroma everywhere. If something is cyan, the operator's eye
should know why.
### Scene sentence
**Dark (default):** A solo broadcast operator at 1:50am, ambient room lights
at 5%, leaning into a 24-inch monitor, twenty minutes before a live
international interview.
**Light:** A morning recording session in a glass-walled conference room with
the sun coming through the blinds, monitor brightness at 80%. Or a daytime
producer monitoring a remote interview from a hotel desk during a working
session before lunch.
The default is dark — that's the dominant operator scene. Light mode exists
because not every show happens at 1:50am.
### Dark palette
Every neutral is tinted toward cyan (h ≈ 200, chroma 0.0050.008) so the
dark surface reads as deliberate dark, not as chromatically dead.
| Token | Role | Hex | OKLCH (approx) |
|---|---|---|---|
| `bg.canvas` | Window canvas | `#0A0A0A` | `oklch(0.12 0.005 200)` |
| `bg.rail` | Left rail | `#080808` | `oklch(0.10 0.005 200)` |
| `bg.surface` | Card / row | `#141416` | `oklch(0.18 0.006 200)` |
| `bg.elevated` | Popovers, menus | `#1C1C1F` | `oklch(0.22 0.007 200)` |
| `bg.hover` | Hover fill | `#26272B` | `oklch(0.28 0.008 200)` |
| `bg.active` | Pressed fill | `#33343A` | `oklch(0.34 0.010 200)` |
| `border.subtle` | Hairlines | `#26272B` | `oklch(0.28 0.008 200)` |
| `border.strong` | Hover / focus | `#3A3B40` | `oklch(0.36 0.010 200)` |
| `fg.primary` | Body text | `#F4F4F6` | `oklch(0.96 0.004 200)` |
| `fg.secondary` | Subdued text | `#A3A4AA` | `oklch(0.70 0.006 200)` |
| `fg.tertiary` | Captions | `#6B6C72` | `oklch(0.50 0.006 200)` |
| `fg.disabled` | Disabled | `#404145` | `oklch(0.32 0.006 200)` |
### Light palette
Mirrored token names; cyan-tinted off-white so the surface still reads as
Wild Dragon, not as generic white.
| Token | Role | Hex | OKLCH (approx) |
|---|---|---|---|
| `bg.canvas` | Window canvas | `#FAFAFB` | `oklch(0.98 0.003 200)` |
| `bg.rail` | Left rail | `#F0F1F3` | `oklch(0.95 0.004 200)` |
| `bg.surface` | Card / row | `#FFFFFF` | `oklch(1.00 0.000 200)` |
| `bg.elevated` | Popovers, menus | `#FFFFFF` | `oklch(1.00 0.000 200)` (+ shadow) |
| `bg.hover` | Hover fill | `#ECEEF1` | `oklch(0.93 0.005 200)` |
| `bg.active` | Pressed fill | `#E0E3E7` | `oklch(0.89 0.006 200)` |
| `border.subtle` | Hairlines | `#E5E7EB` | `oklch(0.91 0.004 200)` |
| `border.strong` | Hover / focus | `#D1D5DA` | `oklch(0.85 0.006 200)` |
| `fg.primary` | Body text | `#0A0A0A` | `oklch(0.12 0.005 200)` |
| `fg.secondary` | Subdued text | `#4A4B50` | `oklch(0.36 0.006 200)` |
| `fg.tertiary` | Captions | `#71747A` | `oklch(0.53 0.006 200)` |
| `fg.disabled` | Disabled | `#B3B6BC` | `oklch(0.76 0.005 200)` |
### Accents — context-aware
Some accents work in both modes; others need a darker variant for AA contrast
when used as text on the light canvas. The token table splits them:
| Token | Dark | Light | Reserved for |
|---|---|---|---|
| `accent.cyan.surface` | `#97EDF0` | `#97EDF0` | Primary button fill, badge fill (text on top is near-black in both modes — works) |
| `accent.cyan.text` | `#97EDF0` | `#0E7C82` | Cyan-as-text (links, "live" labels, active state) |
| `accent.cyan.hover` | `#B5F2F4` | `#0890A0` | Cyan hover |
| `accent.cyan.muted` | `#1B3537` | `#E6F8F9` | Cyan tint background, active speaker row fill |
| `accent.coral` | `#FB819C` | `#D43E5C` | Destructive, error, alert (as both border + text) |
| `accent.coral.bg` | `#3A1922` | `#FDECF0` | Coral tint background |
| `status.live` | `#4ADE80` | `#15803D` | Recording active, REC dot, "live" pill |
| `status.live.bg` | `#13261A` | `#DCFCE7` | Live pill background |
| `status.warn` | `#FBBF24` | `#B45309` | Low disk, NDI degraded |
**Discipline.** Cyan is the only color that competes with body text for
attention. It earns its place — wasted cyan is the design failing.
`accent.cyan.surface` (#97EDF0) reads identically in both modes because
its text is always near-black. `accent.cyan.text` exists specifically so
captions and inline labels stay readable on a light canvas.
## Theming
### The toggle
A single icon button (sun ↔ moon) lives in the title bar, positioned to the
left of the window controls. One click swaps the theme. State persists via
`UIPreferences.Theme` (`Dark | Light | System`). Default is `System` which
follows the Windows app-mode preference.
The toggle is also surfaced inside the settings drawer under an "Appearance"
group as a tri-state pill (System / Dark / Light), so power users find it in
the obvious place too.
### Implementation (WPF)
WPF doesn't have WinUI 3's `ThemeDictionary` pattern. The equivalent is to
**split tokens by theme into separate ResourceDictionary files**, all
addressed via `DynamicResource` (NOT `StaticResource`) so the values can
be swapped at runtime.
```
Themes/
Theme.Tokens.xaml ← styles, control templates, key shape (no colors)
Theme.Dark.xaml ← color resources only — Dark variant
Theme.Light.xaml ← color resources only — Light variant
```
`Theme.Dark.xaml` and `Theme.Light.xaml` define the SAME set of keys —
`Wd.Bg.Canvas`, `Wd.Accent.Cyan`, etc. — with different `Color` values.
`Theme.Tokens.xaml` references them via `DynamicResource` from styles and
templates. At startup, `App.xaml` merges `Theme.Tokens.xaml` plus exactly
one of `Theme.Dark.xaml` or `Theme.Light.xaml`. At runtime, `ThemeManager`
swaps the merged dictionary's color file:
```csharp
var app = Application.Current;
var oldDict = app.Resources.MergedDictionaries
.First(d => d.Source?.OriginalString.EndsWith("Theme.Dark.xaml") == true
|| d.Source?.OriginalString.EndsWith("Theme.Light.xaml") == true);
var idx = app.Resources.MergedDictionaries.IndexOf(oldDict);
app.Resources.MergedDictionaries[idx] = new ResourceDictionary {
Source = new Uri($"/Themes/Theme.{newTheme}.xaml", UriKind.Relative)
};
```
`DynamicResource`-backed `SolidColorBrush` instances re-resolve on the
dictionary swap, so the visual tree repaints without an app restart.
### System mode
When `UIPreferences.Theme == "System"`, `ThemeManager` reads
`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme`
at startup. It also subscribes to `SystemEvents.UserPreferenceChanged` so
the app re-resolves the theme when the operator flips Windows app-mode
mid-session. This is the default — operators who don't care get whatever
their Windows session is set to.
## Typography
### Scale (1.25 step ratio enforced)
| Token | Family | Size | Weight | Line-height | Letter-spacing |
|---|---|---|---|---|---|
| `text.display` | Inter | 22 | 600 | 1.2 | -0.01em |
| `text.title` | Inter | 18 | 600 | 1.25 | -0.005em |
| `text.heading` | Inter | 14 | 600 | 1.3 | 0 |
| `text.body` | Inter | 13 | 400 | 1.45 | 0 |
| `text.subtle` | Inter | 13 | 400 | 1.45 | 0 |
| `text.caption` | Inter | 11 | 500 | 1.3 | 0.04em (smallcaps) |
| `text.mono` | JetBrains Mono | 12 | 400 | 1.4 | 0 |
Body text caps at 6575ch where it wraps. Inline status text doesn't wrap —
it truncates with ellipsis.
### Fonts in WPF
Bundled fonts ship in `src/TeamsISO.App/Assets/Fonts/` and resolve via
`pack://application:,,,/Assets/Fonts/#Inter` / `#JetBrains Mono`. The
`<Resource>` glob in `TeamsISO.App.csproj` already covers the `.ttf` files;
new font weights go in the same directory and pick up automatically.
## Spacing (8px grid)
| Token | Value | Use |
|---|---|---|
| `space.xs` | 4 | Icon-to-text, tiny gaps |
| `space.s` | 8 | Row internal padding, pill padding |
| `space.m` | 12 | Card internal padding |
| `space.l` | 16 | Card padding, between cards |
| `space.xl` | 24 | Section gap |
| `space.xxl` | 32 | Page edge padding |
| `space.xxxl` | 48 | Hero section / large blocks |
**Rhythm rule.** No two adjacent regions share the same padding value. The
participant table breathes at `space.xl`; in-row controls compress to
`space.s`. Same padding everywhere is monotony.
## Radii
| Token | Value | Use |
|---|---|---|
| `radius.s` | 6 | Pills, inline tags, menu items |
| `radius.m` | 8 | Buttons, text inputs, dropdowns |
| `radius.l` | 12 | Cards, drawers, modals |
| `radius.pill` | 999 | Status pills, ISO toggle |
## Elevation
Elevation through **tone**, not through shadow. The dark surface makes
realistic drop-shadows look bolted-on. A `bg.elevated` tone difference does
the same job with less visual noise.
| Layer | Background | Border |
|---|---|---|
| Canvas | `bg.canvas` | none |
| Card | `bg.surface` | `border.subtle` |
| Drawer / Popover | `bg.elevated` | `border.strong` |
| Modal | `bg.elevated` | `border.strong` + 50% canvas scrim |
## Icons
**Single icon system, one stroke width, one optical size.** The previous GUI
inlined ~12 bespoke `<Path Data="...">` icons with stroke widths varying
between 1.2 and 1.6. The redesign uses **Segoe Fluent Icons font** (shipped
with Windows 11; falls back to Segoe MDL2 Assets on Windows 10) as the
baseline, with a custom subset added only where a broadcast concept isn't
covered (e.g. NDI signal lock, ISO routing state).
Sizes: 16 (inline), 20 (button), 24 (rail / hero).
Stroke: inherited from font; no hand-stroked paths.
## Motion
- Ease-out exponential (`cubic-bezier(0.16, 1, 0.3, 1)`) for entry.
- Ease-in-out for state changes that aren't entries.
- Durations: 120ms for affordance feedback, 200ms for panel transitions,
280ms hero (rarely used).
- No bounce. No elastic. No spring overshoots.
- **Never animate** layout properties. Animate `RenderTransform` and
`Opacity` (WPF's composition layer handles these GPU-cheaply).
## Component decisions
### Buttons — finally have a real hierarchy
The previous design used `Wd.Button.Ghost` for everything. The redesign has
**three commitments**:
| Variant | Use | Look |
|---|---|---|
| `Primary` | Single per surface, the brand action ("Apply", "Start session") | Cyan fill, near-black text |
| `Secondary` | Common operator actions ("Refresh", "Presets") | Transparent fill, `border.strong`, hover cyan border |
| `Tertiary` | Inline, low-frequency ("Dismiss", "Show advanced") | Text-only, no border, cyan on hover |
| `Destructive` | Stop, leave, delete | Coral border, coral text, no fill |
**One Primary per surface.** If a screen has two primaries, the design is
unranked.
### ISO toggle — keep, refine
The status-coded pill (LIVE cyan / ERROR coral / NO SIGNAL amber) is good.
Two evolutions:
1. The hover treatment thickens to a 2px cyan border — preserve.
2. Add a half-height ascender showing instantaneous audio level above the
pill. The operator sees who's talking without needing the active-speaker
row highlight to fire on next tick.
### Tables (Participants)
This is the product. The table gets:
- Row height 56 (current) → 64 to give the audio meter + signal indicator
room to breathe.
- The "active speaker" cyan left-border treatment stays. It's good.
- One participant action per row at rest (the ISO toggle). Other actions
(open preview, custom name, presets) live in a right-click context menu
(already exists) and in a row hover-revealed kebab — *not* visible at rest.
- Column count: avatar+name · NDI signal+codec · audio meter · output name ·
ISO toggle. Five columns. The current six-plus + custom-name editing
inline pushes density too far.
### Status — one place, not three
Recording / disk / session / control-surface state currently lives in:
1. Rail bottom dot (engine status)
2. Header right pill (status text)
3. Footer columns (six monospace fields)
The redesign consolidates to **two places only**:
- **Header right** — session timer, REC indicator + count, disk-free.
These are at-a-glance.
- **Status overlay (popover from rail bottom dot)** — control surface URLs,
log path, version, control-surface tokens. These are on-demand.
The footer goes away entirely. It was theatre, not information.
### Settings — drawer, not permanent panel
The 380px right settings panel is the single biggest spatial misallocation.
Settings are rarely changed mid-show. The redesign moves them to a **right-side
drawer** that slides in over the participants area, dismissable with `Esc`.
The participants table reclaims full width when the drawer is closed.
Trigger: rail "settings" icon. Same affordance as today, different surface.
### Onboarding
First-launch only. Three panes max, each one panes deep — no carousel.
Operator-tone copy ("Pick your NDI groups" not "Welcome to TeamsISO!").
Skippable from the first frame.
### Empty states
The participants table empty state currently is implicit (rows just don't
appear). The redesign adds **one** empty state with a single instructive
sentence ("No NDI sources yet — open Teams and start a meeting") and a
single secondary button ("Refresh"). No illustration. No mascot.
## Anti-patterns specific to this app (audited against absolute bans)
The current XAML has none of the impeccable absolute bans (no gradient text,
no side-stripe borders, no glassmorphism). It does have:
- **Identical card grids** — the in-call control bar's seven identical ghost
buttons. Redesign: collapse to a single dense bar with primary controls
surfaced and secondary controls in an overflow menu.
- **Status duplication** — fix as above.
- **Bespoke SVG icons** — fix as above.
## Migration boundary
The view-model surface in `src/TeamsISO.App/ViewModels/` is the contract.
The redesign rewrites `MainWindow.xaml` and `Themes/*` but leaves view-model
properties and commands untouched. Any place where the redesign needs a new
piece of view-model state, the contract widens via additive properties —
existing bindings keep working until the new view stops needing the old shape.
This means: the engine, the OSC bridge, the control surface, the preset
store, the recording pipeline — none of those move. The redesign is
a frontend-only operation.

View file

@ -6,7 +6,7 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest</AnalysisLevel> <AnalysisLevel>latest</AnalysisLevel>
<Version>1.0.0-alpha.0</Version> <Version>1.0.0</Version>
<Authors>Wild Dragon LLC</Authors> <Authors>Wild Dragon LLC</Authors>
<Company>Wild Dragon LLC</Company> <Company>Wild Dragon LLC</Company>
<Product>TeamsISO</Product> <Product>TeamsISO</Product>

12
Dragon-ISO.Linux.slnf Normal file
View file

@ -0,0 +1,12 @@
{
"solution": {
"path": "Dragon-ISO.sln",
"projects": [
"src/Dragon-ISO.Engine/Dragon-ISO.Engine.csproj",
"src/Dragon-ISO.Engine.NdiInterop/Dragon-ISO.Engine.NdiInterop.csproj",
"src/Dragon-ISO.Console/Dragon-ISO.Console.csproj",
"src/tests/Dragon-ISO.Engine.Tests/Dragon-ISO.Engine.Tests.csproj",
"src/tests/Dragon-ISO.Engine.IntegrationTests/Dragon-ISO.Engine.IntegrationTests.csproj"
]
}
}

14
Dragon-ISO.Windows.slnf Normal file
View file

@ -0,0 +1,14 @@
{
"solution": {
"path": "Dragon-ISO.sln",
"projects": [
"src\\Dragon-ISO.Engine\\Dragon-ISO.Engine.csproj",
"src\\Dragon-ISO.Engine.NdiInterop\\Dragon-ISO.Engine.NdiInterop.csproj",
"src\\Dragon-ISO.Console\\Dragon-ISO.Console.csproj",
"src\\Dragon-ISO.App\\Dragon-ISO.App.csproj",
"src\\tests\\Dragon-ISO.Engine.Tests\\Dragon-ISO.Engine.Tests.csproj",
"src\\tests\\Dragon-ISO.Engine.IntegrationTests\\Dragon-ISO.Engine.IntegrationTests.csproj",
"src\\tests\\Dragon-ISO.App.Tests\\Dragon-ISO.App.Tests.csproj"
]
}
}

View file

@ -1,72 +1,72 @@
 
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59 VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{46E05E34-8A87-4986-87D3-FE0DE4E05F44}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{46E05E34-8A87-4986-87D3-FE0DE4E05F44}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine", "src\TeamsISO.Engine\TeamsISO.Engine.csproj", "{F0D24EAE-9225-4DC4-B3D2-6966077287A0}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine", "src\Dragon-ISO.Engine\Dragon-ISO.Engine.csproj", "{F0D24EAE-9225-4DC4-B3D2-6966077287A0}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.NdiInterop", "src\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj", "{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine.NdiInterop", "src\Dragon-ISO.Engine.NdiInterop\Dragon-ISO.Engine.NdiInterop.csproj", "{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DBDF4A1D-4215-42D5-B456-2CE7159DF848}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DBDF4A1D-4215-42D5-B456-2CE7159DF848}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.Tests", "src\tests\TeamsISO.Engine.Tests\TeamsISO.Engine.Tests.csproj", "{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine.Tests", "src\tests\Dragon-ISO.Engine.Tests\Dragon-ISO.Engine.Tests.csproj", "{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App", "src\TeamsISO.App\TeamsISO.App.csproj", "{80DCE039-3BBC-4D3F-B44B-51F324591C29}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.App", "src\Dragon-ISO.App\Dragon-ISO.App.csproj", "{80DCE039-3BBC-4D3F-B44B-51F324591C29}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.IntegrationTests", "src\tests\TeamsISO.Engine.IntegrationTests\TeamsISO.Engine.IntegrationTests.csproj", "{A85E331D-026E-4BDE-B89C-0CC4C95001CE}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Engine.IntegrationTests", "src\tests\Dragon-ISO.Engine.IntegrationTests\Dragon-ISO.Engine.IntegrationTests.csproj", "{A85E331D-026E-4BDE-B89C-0CC4C95001CE}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src\TeamsISO.Console\TeamsISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.Console", "src\Dragon-ISO.Console\Dragon-ISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App.Tests", "src\tests\TeamsISO.App.Tests\TeamsISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dragon-ISO.App.Tests", "src\tests\Dragon-ISO.App.Tests\Dragon-ISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.Build.0 = Debug|Any CPU {F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.ActiveCfg = Release|Any CPU {F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.Build.0 = Release|Any CPU {F0D24EAE-9225-4DC4-B3D2-6966077287A0}.Release|Any CPU.Build.0 = Release|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU {E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.ActiveCfg = Release|Any CPU {E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.Build.0 = Release|Any CPU {E737E54B-73DE-4F74-909C-1F0F5CF82AC6}.Release|Any CPU.Build.0 = Release|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.Build.0 = Debug|Any CPU {F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.ActiveCfg = Release|Any CPU {F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.Build.0 = Release|Any CPU {F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8}.Release|Any CPU.Build.0 = Release|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.Build.0 = Debug|Any CPU {80DCE039-3BBC-4D3F-B44B-51F324591C29}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.ActiveCfg = Release|Any CPU {80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.Build.0 = Release|Any CPU {80DCE039-3BBC-4D3F-B44B-51F324591C29}.Release|Any CPU.Build.0 = Release|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.Build.0 = Debug|Any CPU {A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.ActiveCfg = Release|Any CPU {A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.Build.0 = Release|Any CPU {A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.Build.0 = Release|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.Build.0 = Debug|Any CPU {B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.ActiveCfg = Release|Any CPU {B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.Build.0 = Release|Any CPU {B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} {F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{E737E54B-73DE-4F74-909C-1F0F5CF82AC6} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} {E737E54B-73DE-4F74-909C-1F0F5CF82AC6} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{DBDF4A1D-4215-42D5-B456-2CE7159DF848} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} {DBDF4A1D-4215-42D5-B456-2CE7159DF848} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848} {F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
{80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} {80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848} {A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} {C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848} {B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View file

@ -1,81 +0,0 @@
# Where we left off — v2 "Studio Terminal" shell complete (2026-05-15)
## What's done on main
**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.
**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.
**Shell:**
- Default Windows title bar (no custom chromeless caption buttons).
- 32px header — Wild Dragon mark + "TeamsISO" wordmark left; three icon
buttons right (⌘K command palette, theme toggle, settings gear).
- 40px transport strip — single mono line:
`● 02:14:32 PART 4 · LIVE 2 CTRL :9755`. Cyan dot + timer only when
at least one ISO live.
- Body — alert banner + update banner + action toolbar + participants
DataGrid + (conditional) meeting bar at the bottom.
- Settings — slide-over drawer (420px from right) with OUTPUT / NETWORK /
APP tabs. Scrim click or Esc dismisses.
- v1 leftovers (72px rail, 380px permanent settings panel, six-column
footer) are gone.
**Theme system:**
- `Themes/Theme.Dark.xaml` + `Themes/Theme.Light.xaml` — color brushes
only.
- `Themes/WildDragonTheme.xaml` — styles + control templates (no color
brushes; every brush ref is `DynamicResource`).
- `Services/ThemeManager.cs` — swaps the merged dictionary at runtime;
reads `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme`
for System mode; subscribes to `SystemEvents.UserPreferenceChanged`;
persists via `UIPreferences.Theme`.
**Task 39 — participants table v2 (LANDED).**
Five columns: 24px state LED, name + codec caption, 110px audio meter,
130px mono output name, 100px ISO pill. 52px rows. Full-row
active-speaker tint (replaces the v1 left-stripe).
**Task 40 — Ctrl+K command palette (LANDED).**
`Views/CommandPaletteWindow.xaml` + `ViewModels/CommandPaletteViewModel.cs`
ship a centered 560×360 floating window with fuzzy search across Quick /
Teams / Presets / Output / Network / App categories. ↑/↓ navigates,
Enter invokes, Esc closes. The header ⌘K button and Ctrl+K (also Ctrl+P)
keyboard binding both open it.
**Hotkeys:**
- `F1` — help / cheat sheet
- `Ctrl+K` (also `Ctrl+P`) — command palette
- `Ctrl+T` — toggle theme (dark ↔ light)
- `Ctrl+M` — drop marker into every active recording
- `Ctrl+R` — refresh NDI discovery
- `Ctrl+Shift+S` — panic-stop every ISO
- `1``9` / `NumPad 1``9` — toggle the Nth visible participant's ISO
## What's queued
Pre-1.0 cut is gated on:
1. Code-signing the MSI (`SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD`
Forgejo Secrets wired in `release.yml`).
2. A real-meeting smoke pass on a host with a live NDI runtime.
## Build + run
```powershell
dotnet build TeamsISO.Windows.slnf -c Release
.\src\TeamsISO.App\bin\Release\net8.0-windows\TeamsISO.exe
```
The shipped helpers `build-and-test.ps1` and `commit-and-push.ps1`
wrap the build + test + push flow.
If something regresses, `1d1ce6a` is the rollback point for the WPF v1
shell (recording was axed at that commit), and `c271303` is the v2
shell-without-table-redesign rollback point.

View file

@ -1,181 +0,0 @@
# PRODUCT.md — TeamsISO
## Register
**Product.** This is a tool, not a destination. The design serves the operator
running a live broadcast. The UI is judged by how invisible it gets once the
show is rolling.
## Product purpose
TeamsISO is a per-participant NDI ISO controller for Microsoft Teams. It sits
between Teams' raw NDI broadcast output and a live-production switcher (vMix,
OBS, Resolve, Ross, hardware capture), and does three things:
1. **Routes** each guest as a clean, individually-addressable, normalized NDI
source (consistent framerate, resolution, aspect, audio routing — regardless
of what each participant's webcam is doing).
2. **Orchestrates Teams itself** — launch/hide Teams windows, drive in-call
controls (mute, camera, share, leave, raise hand, quick-join) via
UIAutomation, so the operator never has to alt-tab away from the routing
table while the show is live.
(Recording — previously the second pillar — was removed in the WPF rollback
on 2026-05-13. The engine plumbing is intact for a future re-introduction,
but no UI surface, view-model command, REST route, or OSC route exposes it.)
External control surface (REST + WebSocket + OSC on localhost) lets a
Companion / Stream Deck / TouchOSC controller drive routing remotely.
## Users — the primary persona
**Solo operator.** One person, one Windows laptop or desk machine, often
running the show alone from a hotel room, conference green room, or home
studio. Picture them at 1:50am, twenty minutes before a live international
broadcast, ambient room lights down, the Teams call already started, four
guests joining staggered over the next ten minutes. They need to:
- See which participants are present, online, and producing NDI signal.
- Toggle each one's ISO on as they join.
- Confirm at a glance that recording is live, the disk has room, and the
control surface is reachable.
- Drop a marker if the host says something quotable.
- Mute themselves without alt-tabbing.
If the UI demands more than a glance for any of those, the show suffers.
### Secondary personas (informed, not designed-for)
- **TD at a broadcast desk** — multi-monitor, may use the OSC bridge to a
hardware control surface. Can tolerate a denser layout because their eyes
aren't the only thing on the surface.
- **Producer monitoring** — glances occasionally, mostly hands-off. Will see
this app over someone's shoulder; first read matters.
- **IT/AV admin** — installs it once, tunes config, walks away. Needs settings
to be findable, not present-at-all-times.
The design optimizes for the solo operator. Everyone else is downstream.
## Brand
**Wild Dragon LLC.** Reference: wilddragon.net.
Palette anchors:
- Canvas: near-black (`#0A0A0A`)
- Primary accent: cyan (`#97EDF0`)
- Secondary blue: (`#9AE0FD`)
- Coral (error / destructive): (`#FB819C`)
- Earth (warning): (`#423825`)
Typography:
- Sans: **Inter** (variable, bundled as a resource — not assumed installed).
- Mono: **JetBrains Mono** (also bundled).
The brand carries the surface but doesn't shout. Wild Dragon's authority is
in the restraint, not the saturation.
## Voice and tone
**Operator-first, terse, broadcaster-native.** The UI talks like a confident
peer, not a Slack bot.
- "Stop all" not "Are you sure you want to stop all ISOs?"
- "Disk low — 8.3 GB" not "Heads up! Your disk space is running low."
- "Joined call · 4 guests" not "You have successfully joined a Teams meeting!"
- Numbers carry their unit, no sentence wraps them.
- Never apologetic. Never bubbly. Never "Let's get started!"
When something goes wrong, name it: "NDI receiver dropped — restarting" beats
"Something went wrong, please try again."
## Strategic principles
These are the design's load-bearing commitments. Any choice that contradicts
one of these is wrong, even if it would otherwise be pretty.
### 1. One operator, one screen, one show.
The design is for someone running a live broadcast alone. Their attention
budget for chrome is roughly zero. Anything that's not the participants table
should fade until it's needed.
### 2. The participants table IS the product.
Everything else is support staff. Routing toggles, ISO state, and per-guest
signal health get the real estate, the contrast, and the typographic hierarchy.
### 3. Progressive disclosure, not progressive density.
The current GUI's failure mode is "every feature gets its own visible button."
The redesign's failure mode would be the opposite — burying important things
in menus. The discipline: surface the half-dozen actions an operator needs
mid-show; hide setup, presets, control-surface config, and exotic options
behind purposeful entry points.
### 4. At-a-glance status is sacred.
Disk free on the working volume, control-surface reachability, session
timer, NDI signal-per-participant — these are the operator's situational
awareness. They must be readable in peripheral vision, in one place,
without scanning. (Recording state was a historical fifth field; it's
removed.)
### 5. Confident neutrality over decorative warmth.
This is a broadcast tool. It looks like one. No empty-state mascots, no
illustrated onboarding cards, no celebratory toasts. Restraint is the brand.
## Anti-references — what this is NOT
The "vibe-coded GUI" failure mode is the enemy. The redesign should never
read as AI-generated. Concretely, this means none of:
- **Generic SaaS dashboard.** No "hero metric + supporting stats + gradient
accent" cards. No "icon + heading + body text" card grids.
- **Cards-in-a-grid template.** Same-sized cards repeated endlessly is the
defining LLM-design tell. If a layout would benefit from cards-in-a-grid,
it benefits more from a table.
- **Card-with-icon-and-text rows.** The in-call control bar's current
"icon + label" buttons (Mute / Camera / Share / Marker / Notes / Leave)
read AI-generated. The redesign uses iconography differently.
- **Zoom pastel.** Soft purples, friendly mint greens, rounded everything,
Inter-at-low-weight.
- **Skeuomorphic broadcast hardware.** No woodgrain, no chrome bezels, no
fake LCD readouts, no metallic gradients. Wild Dragon's confidence is in
flat surfaces with real typography.
- **Tour-everything onboarding.** No "Let's get started!" wizards with cute
copy. The OnboardingWindow exists for first-launch config, not pageantry.
- **Modal-as-first-thought.** Settings, presets, help all currently live in
modals; some should be drawers or inline-progressive. Modal is a last resort.
## Technical constraints (informing design)
- Windows-only (Teams' NDI is Windows-only anyway).
- **WPF .NET 8** is the supported frontend host. (A WinUI 3 rebuild was
attempted in May 2026; it proved fragile — XAML parser crashes on
DataTemplate, theme-glyph rendering issues — and was abandoned. The
rollback commit `1d1ce6a` is the canonical baseline.)
- Engine layer (.NET 8) is preserved verbatim — view-model surface is the
swap boundary.
- Fonts are bundled via WPF's `pack://application:,,,/Assets/Fonts/#Inter`
resource URI so the operator's machine doesn't have to have Inter or
JetBrains Mono installed.
- MSIX-signed installer is on the v1.0 path; the new shell needs to package
cleanly through that pipeline.
- The external control surface (REST/WebSocket on `:9755`, OSC on `:9000`)
must not regress — its HTML control panel at `/ui` is a separate design
surface but shares brand tokens.
## What "done" looks like
The redesign is finished when:
1. A first-time operator can launch TeamsISO, join a Teams meeting, and
route their first ISO without reading documentation.
2. A returning operator at 1:50am can find the four things they need
(participant signal · ISO toggle · recording state · disk free) in under
half a second of glance.
3. Nothing on the surface reads as AI-generated. Show this to a working
broadcast engineer and they say "someone who knows the job built this."
4. The design system is documented in DESIGN.md tightly enough that a future
contributor can add a new view that looks like it belongs.

143
README.md
View file

@ -1,94 +1,77 @@
# TeamsISO # TeamsISO
**Per-Participant NDI ISO Controller for Microsoft Teams.** **Per-participant NDI ISO controller for Microsoft Teams.**
TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a
live-production environment. It receives each participant's NDI stream, live-production environment. It receives each participant's NDI stream,
normalizes framerate / resolution / aspect / audio per a configured target, normalizes framerate / resolution / aspect / audio per a configured target,
and re-emits clean, individually-addressable NDI sources for ingestion into and re-emits clean, individually-addressable NDI sources for ingestion by a
a switcher (vMix, OBS, Ross, hardware capture). switcher — vMix, OBS, Ross, hardware capture.
> **Status:** **v1.0.0** — first general release. Windows only. Requires
> Microsoft Teams (with NDI broadcast enabled) and the NDI 6 runtime.
---
## What it does ## What it does
- **Discovers participants** as Teams broadcasts each one over NDI, surfacing - **Discovers participants** as Teams broadcasts each one over NDI. Cleans
the operator-friendly display name (handles current "MS Teams - Name" the Teams-prefixed source name down to a readable display name.
format and the legacy "(Teams) Name" format).
- **Normalizes feeds** to a consistent framerate, resolution, aspect mode, - **Normalizes feeds** to a consistent framerate, resolution, aspect mode,
and audio routing — so the downstream switcher gets predictable inputs and audio routing — so the downstream switcher gets predictable inputs
regardless of what each participant's webcam is doing. regardless of what each participant's webcam is doing.
- **Routes per-participant** as separate NDI sources with a configurable - **Routes per-participant** as separate NDI sources with a configurable
output-name template (`TEAMSISO_{name}`, `{guid}`, `{machine}`, `{timestamp}` tokens). per-row output name. Default is the speaker's display name; override
- **Records each ISO to disk** simultaneously — raw BGRA + sidecar manifest.json inline in the participants table.
+ ffmpeg convert.cmd — so post-production gets a clean per-guest archive. - **Records each ISO to disk** simultaneously — raw BGRA + `manifest.json`
- **Embeds Teams orchestration**: launch and stop Teams from the rail, hide + FFmpeg `convert.cmd` — so post-production gets a clean per-guest archive.
Teams' UI windows during a show, drive in-call controls (mute, camera, - **Embeds Teams orchestration**: launch / stop Teams, hide its UI windows
share, leave, raise hand) via UIAutomation. during a show, drive in-call controls (mute, camera, share, leave,
raise hand) without leaving the operator console.
- **Operator presets** save the current per-participant ISO assignment and - **Operator presets** save the current per-participant ISO assignment and
custom output names, applicable on next launch automatically. custom output names, applicable on next launch automatically.
- **Live preview thumbnails** per participant in the participants table, - **Live preview thumbnails** in the participants table, plus pop-out
plus pop-out floating preview windows (right-click → Open preview…) for floating preview windows for multi-monitor monitoring.
multi-monitor monitoring.
- **External control surface** — REST + WebSocket on `127.0.0.1:9755` and - **External control surface** — REST + WebSocket on `127.0.0.1:9755` and
OSC on UDP `127.0.0.1:9000` for Bitfocus Companion / Stream Deck / OSC on UDP `127.0.0.1:9000` for Bitfocus Companion / Stream Deck /
TouchOSC integration. Self-contained HTML control panel at TouchOSC. Self-contained HTML panel at `/ui` for phone-as-controller.
[`/ui`](docs/CONTROL-SURFACE.md) for phone-as-controller. - **Theme-aware** — dark and light palettes, system-following or pinned.
- **Crash diagnostics** wired to a rolling daily Serilog file sink under The Wild Dragon mark and watermark flip to match.
`%LOCALAPPDATA%\TeamsISO\Logs\`.
- **Update check** against `forge.wilddragon.net`'s release API — manual or
silent on launch (throttled to 24h).
- **Diagnostic bundle export** zips logs + config + presets for bug reports.
## Status ## Install
Pre-1.0. The May 2026 batch is feature-complete; v1.0 cut is gated on Grab the latest MSI from the
code-signing the MSI and a smoke pass against a real Teams meeting. [Releases page](https://forge.wilddragon.net/zgaetano/teamsiso/releases),
See `CHANGELOG.md` for the [Unreleased] entry. double-click, and accept the install prompts. Per-machine install under
`C:\Program Files\Wild Dragon\TeamsISO`.
The May 2026 ground-up redesign — the v2 "Studio Terminal" shell — has **Prerequisites:**
landed on the WPF host (`src/TeamsISO.App/`). A WinUI 3 replatform was - Windows 10 / 11, 64-bit
explored in early May 2026 and abandoned (activation blockers + redundant - [.NET 8 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/8.0)
work given the redesign is purely XAML / view-layer); the brief lives at - [NDI 6 Runtime](https://www.ndi.video/tools/) (the installer warns if
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`, and the missing but does not block — operators can stage the app before NDI is
abandoned migration plan + bootstrap probe are archived under rolled out)
`docs/archive/`. - Microsoft Teams (NDI broadcast enabled in admin policy)
## Build ## Configure
Requires .NET 8 SDK on Windows. WPF is the only host: First-run defaults work for most setups. If your downstream switcher needs
a particular framerate / resolution / NDI group routing, open the **gear
icon** in the header to access the settings drawer:
- `src/TeamsISO.App` — WPF, `net8.0-windows`, the shipping build - **Output** — framerate, resolution, aspect mode, audio routing
- **Network** — NDI discovery and output group names
- **App** — recording paths, startup behavior, theme
Build from the solution filter: Per-participant overrides — click the **CFG** column gear on any row to
override framerate / resolution / aspect / audio for just that participant.
dotnet restore TeamsISO.Windows.slnf
dotnet build TeamsISO.Windows.slnf -c Release
dotnet test TeamsISO.Windows.slnf --filter "Category!=ndi&requires!=ndi"
The shipped helper scripts in the repo root automate this:
pwsh -File .\build-and-test.ps1
pwsh -File .\commit-and-push.ps1
## Documentation
- [Control surface API](docs/CONTROL-SURFACE.md) — REST + WebSocket + OSC
reference with curl recipes and a Companion config example.
- [Releasing](docs/RELEASING.md) — tag-push workflow, MSI signing path.
- [Architecture spec](docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md)
— design overview.
- [Embedded Teams orchestration spec](docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md)
— Phase E roadmap.
- [Redesign brief](PRODUCT.md) + [design system](DESIGN.md) — token-level
spec for the v2 "Studio Terminal" redesign.
- [v2 shape brief](docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md) —
approved aesthetic + IA for the May 2026 WPF rebuild.
## 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 + K` (or `Ctrl + P`) | Open the command palette |
| `Ctrl + T` | Toggle theme (dark ↔ light) | | `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) |
@ -101,11 +84,45 @@ The shipped helper scripts in the repo root automate this:
| --- | --- | | --- | --- |
| `%APPDATA%\TeamsISO\config.json` | Engine settings (framerate, NDI groups, etc.) | | `%APPDATA%\TeamsISO\config.json` | Engine settings (framerate, NDI groups, etc.) |
| `%LOCALAPPDATA%\TeamsISO\presets.json` | Saved operator presets + auto-apply preference | | `%LOCALAPPDATA%\TeamsISO\presets.json` | Saved operator presets + auto-apply preference |
| `%LOCALAPPDATA%\TeamsISO\Logs\` | Rolling daily diagnostic logs | | `%LOCALAPPDATA%\TeamsISO\logs\` | Rolling daily diagnostic logs |
| `%LOCALAPPDATA%\TeamsISO\Notes\` | Per-day show-notes markdown files | | `%LOCALAPPDATA%\TeamsISO\Notes\` | Per-day show-notes markdown files |
| `%USERPROFILE%\Videos\TeamsISO\<date>\` | Default recording output | | `%USERPROFILE%\Videos\TeamsISO\<date>\` | Default recording output |
| `%APPDATA%\NDI\ndi-config.v1.json` | NDI Access Manager group routing | | `%APPDATA%\NDI\ndi-config.v1.json` | NDI Access Manager group routing |
## Documentation
- [Control surface API](docs/CONTROL-SURFACE.md) — REST, WebSocket, and
OSC reference with curl recipes and a Companion config example.
- [Real-time recording](docs/REAL-TIME-RECORDING.md) — recorder format,
manifest schema, and the FFmpeg conversion path.
- [Releasing](docs/RELEASING.md) — tag-push workflow and MSI signing.
## Build from source
Requires the .NET 8 SDK on Windows. WPF is the only host.
```powershell
dotnet restore TeamsISO.Windows.slnf
dotnet build TeamsISO.Windows.slnf -c Release
dotnet test TeamsISO.Windows.slnf --filter "Category!=ndi&requires!=ndi"
```
Or use the included helper:
```powershell
pwsh -File .\build-and-test.ps1
```
To produce a fresh MSI:
```powershell
dotnet publish src\TeamsISO.App\TeamsISO.App.csproj `
-c Release -r win-x64 --self-contained false `
-o publish\TeamsISO
dotnet build installer\TeamsISO.Installer.wixproj -c Release
# Output: installer\bin\x64\Release\TeamsISO-Setup-<version>.msi
```
## License ## License
Proprietary, © Wild Dragon LLC 2026. Proprietary, © Wild Dragon LLC 2026. All rights reserved.

View file

@ -1,12 +0,0 @@
{
"solution": {
"path": "TeamsISO.sln",
"projects": [
"src/TeamsISO.Engine/TeamsISO.Engine.csproj",
"src/TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj",
"src/TeamsISO.Console/TeamsISO.Console.csproj",
"src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj",
"src/tests/TeamsISO.Engine.IntegrationTests/TeamsISO.Engine.IntegrationTests.csproj"
]
}
}

View file

@ -1,14 +0,0 @@
{
"solution": {
"path": "TeamsISO.sln",
"projects": [
"src\\TeamsISO.Engine\\TeamsISO.Engine.csproj",
"src\\TeamsISO.Engine.NdiInterop\\TeamsISO.Engine.NdiInterop.csproj",
"src\\TeamsISO.Console\\TeamsISO.Console.csproj",
"src\\TeamsISO.App\\TeamsISO.App.csproj",
"src\\tests\\TeamsISO.Engine.Tests\\TeamsISO.Engine.Tests.csproj",
"src\\tests\\TeamsISO.Engine.IntegrationTests\\TeamsISO.Engine.IntegrationTests.csproj",
"src\\tests\\TeamsISO.App.Tests\\TeamsISO.App.Tests.csproj"
]
}
}

View file

@ -1,36 +1,29 @@
# Quick build + test verification before commit-and-push.ps1.
#
# Run from the repo root:
# pwsh -ExecutionPolicy Bypass -File .\build-and-test.ps1
#
# Builds TeamsISO.Windows.slnf in Release with TreatWarningsAsErrors=true
# (the Directory.Build.props default), then runs unit tests excluding the
# requires=ndi tier (those need a live NDI runtime).
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
if (-not (Test-Path 'TeamsISO.Windows.slnf')) { if (-not (Test-Path 'Dragon-ISO.Windows.slnf')) {
throw "Run from the TeamsISO repo root." throw "Run from the Dragon-ISO repo root."
} }
$env:Path = "C:\Program Files\dotnet;$env:Path"
Write-Host "=== dotnet --version ===" -ForegroundColor Cyan Write-Host "=== dotnet --version ===" -ForegroundColor Cyan
dotnet --version dotnet --version
Write-Host "" Write-Host ""
Write-Host "=== Restore ===" -ForegroundColor Cyan Write-Host "=== Restore ===" -ForegroundColor Cyan
dotnet restore TeamsISO.Windows.slnf dotnet restore Dragon-ISO.Windows.slnf
if ($LASTEXITCODE -ne 0) { throw "Restore failed." } if ($LASTEXITCODE -ne 0) { throw "Restore failed." }
Write-Host "" Write-Host ""
Write-Host "=== Build (Release, TreatWarningsAsErrors=true) ===" -ForegroundColor Cyan Write-Host "=== Build (Release, TreatWarningsAsErrors=true) ===" -ForegroundColor Cyan
dotnet build TeamsISO.Windows.slnf --configuration Release --no-restore --nologo dotnet build Dragon-ISO.Windows.slnf --configuration Release --no-restore --nologo
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "Build failed. Most likely cause for this batch: System.Windows.Automation needs an explicit Reference. If so, add to src/TeamsISO.App/TeamsISO.App.csproj inside an <ItemGroup>: <Reference Include='UIAutomationClient' /> <Reference Include='UIAutomationTypes' />" throw "Build failed. Most likely cause for this batch: System.Windows.Automation needs an explicit Reference. If so, add to src/Dragon-ISO.App/Dragon-ISO.App.csproj inside an <ItemGroup>: <Reference Include='UIAutomationClient' /> <Reference Include='UIAutomationTypes' />"
} }
Write-Host "" Write-Host ""
Write-Host "=== Tests (excluding requires=ndi) ===" -ForegroundColor Cyan Write-Host "=== Tests (excluding requires=ndi) ===" -ForegroundColor Cyan
dotnet test TeamsISO.Windows.slnf ` dotnet test Dragon-ISO.Windows.slnf `
--configuration Release ` --configuration Release `
--no-build ` --no-build `
--nologo ` --nologo `
@ -38,4 +31,4 @@ dotnet test TeamsISO.Windows.slnf `
if ($LASTEXITCODE -ne 0) { throw "Tests failed." } if ($LASTEXITCODE -ne 0) { throw "Tests failed." }
Write-Host "" Write-Host ""
Write-Host "Build + tests green. Now run .\commit-and-push.ps1 to ship." -ForegroundColor Green Write-Host "Build + tests green." -ForegroundColor Green

View file

@ -1,443 +0,0 @@
# Commit + push the May 2026 polish batch to forge.wilddragon.net.
#
# Run from the repo root:
# pwsh -ExecutionPolicy Bypass -File .\commit-and-push.ps1
#
# Splits into 8 atomic commits (#59, #61, #64-#69), then pushes origin/main.
# Stops on first error so you can resolve and re-run.
$ErrorActionPreference = 'Stop'
# Ensure we're at repo root.
if (-not (Test-Path '.git') -or -not (Test-Path 'TeamsISO.sln')) {
throw "Run from the TeamsISO repo root."
}
# Tidy up the diagnostic artifact I left while probing the sandbox.
if (Test-Path '.claude-bash-test.txt') {
Remove-Item '.claude-bash-test.txt' -Force
Write-Host "Removed sandbox diagnostic file." -ForegroundColor DarkGray
}
# ─── helper ─────────────────────────────────────────────────────────────
function Stage-AndCommit($message, [string[]]$paths) {
Write-Host ""
Write-Host "──── $message ────" -ForegroundColor Cyan
foreach ($p in $paths) {
if (Test-Path $p) {
git add -- $p
if ($LASTEXITCODE -ne 0) { throw "git add failed for $p" }
} else {
Write-Warning "Path not found, skipping: $p"
}
}
# Anything actually staged?
git diff --cached --quiet
if ($LASTEXITCODE -eq 0) {
Write-Host " (no changes to commit; skipping)" -ForegroundColor DarkGray
return
}
git commit -m $message
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $message" }
}
# ─── #59 Auto-disable on participant departure ─────────────────────────
# View-model gained AutoDisableOnDeparture; MainViewModel hooks departure;
# DISPLAY settings shows the toggle.
# (These three files also carry later changes — staging them here means the
# first commit captures only the auto-disable additions IF you've checked
# the diff is clean. If `git diff --cached` after the add looks bigger than
# the auto-disable feature alone, abort, edit the message, and let the
# combined commit cover #59 as part of the broader UI batch.)
Stage-AndCommit `
"feat(ui): auto-disable ISOs when participants leave the meeting" `
@(
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml"
)
# ─── #61 Operator presets ──────────────────────────────────────────────
# Only the new files; the wiring into MainWindow header / MainViewModel
# was already staged above as part of #59 (because all three commits touch
# MainWindow.xaml / MainViewModel.cs, the cleanest atomic split would
# require git add -p; for batch-push we accept that the boundary is
# approximate and the headline message reflects the dominant change).
Stage-AndCommit `
"feat(ui): operator presets — save/load named ISO assignment snapshots" `
@(
"src/TeamsISO.App/Services/OperatorPresetStore.cs",
"src/TeamsISO.App/PresetsDialog.xaml",
"src/TeamsISO.App/PresetsDialog.xaml.cs"
)
# ─── #64 Optional MSI / exe code-signing in release.yml ────────────────
Stage-AndCommit `
"ci: optional MSI + exe code-signing in release.yml" `
@(
".forgejo/workflows/release.yml",
"docs/RELEASING.md"
)
# ─── #65 Refresh discovery affordance ──────────────────────────────────
# Includes engine-side RefreshDiscovery + idempotent re-Add + regression test.
Stage-AndCommit `
"feat(engine): refresh discovery affordance + idempotent re-Add handling" `
@(
"src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs",
"src/TeamsISO.Engine/Discovery/ParticipantTracker.cs",
"src/TeamsISO.Engine/Controller/IIsoController.cs",
"src/TeamsISO.Engine/Controller/IsoController.cs",
"src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs"
)
# ─── #66 / #67 / #68 / #69 UI batch ────────────────────────────────────
# These four features all touch MainViewModel.cs / MainWindow.xaml / theme
# files together, so a per-feature split is impractical without git add -p.
# We commit as one batch with a descriptive message.
Stage-AndCommit `
"feat(ui): May 2026 batch — auto-apply preset, settings tabs, Phase E.2/E.3" `
@(
"src/TeamsISO.App/Services/TeamsLauncher.cs",
"src/TeamsISO.App/Services/TeamsControlBridge.cs",
"src/TeamsISO.App/MainWindow.xaml.cs",
"src/TeamsISO.App/Themes/WildDragonTheme.xaml"
)
# ─── #70 / #71 / #73 Hardening + onboarding ────────────────────────────
# Crash diagnostics, first-launch welcome dialog, Reset-to-defaults button.
# Touches App.xaml.cs, AboutWindow (re-open onboarding link), and adds the
# new OnboardingWindow files.
Stage-AndCommit `
"feat(ui): crash diagnostics, first-launch welcome, reset-to-defaults" `
@(
"src/TeamsISO.App/App.xaml.cs",
"src/TeamsISO.App/OnboardingWindow.xaml",
"src/TeamsISO.App/OnboardingWindow.xaml.cs",
"src/TeamsISO.App/AboutWindow.xaml",
"src/TeamsISO.App/AboutWindow.xaml.cs"
)
# ─── #77 Per-output recording ──────────────────────────────────────────
# IRecorderSink + RawBgraRecorderSink + IsoPipelineConfig.Recorder wiring +
# IsoController.SetRecording + UI checkbox in DISPLAY tab.
Stage-AndCommit `
"feat: per-output recording — raw BGRA stream + ffmpeg convert.cmd" `
@(
"src/TeamsISO.Engine/Pipeline/IRecorderSink.cs",
"src/TeamsISO.Engine/Pipeline/RawBgraRecorderSink.cs",
"src/TeamsISO.Engine/Pipeline/IsoPipelineConfig.cs",
"src/TeamsISO.Engine/Pipeline/IsoPipeline.cs",
"src/TeamsISO.Engine/Controller/IIsoController.cs",
"src/TeamsISO.Engine/Controller/IsoController.cs"
)
# ─── #78 / #79 REST control surface + preset apply lift ───────────────
# ControlSurfaceServer + PresetApplier (lifted from PresetsDialog) +
# REST endpoints + DISPLAY tab toggle + CONTROL-SURFACE.md docs.
# PresetsDialog and MainViewModel.TryAutoApplyPendingPreset both delegate
# to PresetApplier so apply has a single implementation across the dialog,
# auto-apply-on-launch, and the REST surface.
Stage-AndCommit `
"feat: REST control surface + lift preset-apply into PresetApplier" `
@(
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
"src/TeamsISO.App/Services/PresetApplier.cs",
"src/TeamsISO.App/PresetsDialog.xaml.cs",
"docs/CONTROL-SURFACE.md"
)
# ─── #80 In-app preview thumbnails ─────────────────────────────────────
# Engine: IsoPipeline.LatestProcessedFrame + IsoController.GetLatestProcessedFrame.
# UI: ParticipantViewModel.Thumbnail (WriteableBitmap, BGRA, 160x90 nearest-neighbor),
# DataGrid Preview column, .csproj AllowUnsafeBlocks.
Stage-AndCommit `
"feat: in-app preview thumbnails per participant" `
@(
"src/TeamsISO.Engine/Pipeline/IsoPipeline.cs",
"src/TeamsISO.Engine/Controller/IIsoController.cs",
"src/TeamsISO.Engine/Controller/IsoController.cs",
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"src/TeamsISO.App/TeamsISO.App.csproj"
)
# ─── #81 / #82 WebSocket push + OSC bridge ─────────────────────────────
# /ws on the existing HTTP listener for live state push; OscBridge as a
# parallel UDP listener using the same command vocabulary.
Stage-AndCommit `
"feat: WebSocket live-state push + OSC bridge" `
@(
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
"src/TeamsISO.App/Services/OscBridge.cs",
"src/TeamsISO.App/App.xaml.cs",
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"docs/CONTROL-SURFACE.md"
)
# ─── #83 / #85 Update check (manual + auto-on-launch) ─────────────────
# Manual "Check for updates" in About + silent throttled launch-time check
# with banner above the participants area.
Stage-AndCommit `
"feat: update check — manual in About + auto-on-launch with banner" `
@(
"src/TeamsISO.App/Services/UpdateChecker.cs",
"src/TeamsISO.App/AboutWindow.xaml",
"src/TeamsISO.App/AboutWindow.xaml.cs",
"src/TeamsISO.App/ViewModels/UpdateBannerViewModel.cs",
"src/TeamsISO.App/App.xaml.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml"
)
# ─── #86 Preset import / export ────────────────────────────────────────
# OperatorPresetStore.ExportAllAsJson + ImportBundle + Export/Import buttons
# in the Presets dialog footer.
Stage-AndCommit `
"feat: preset import / export bundles" `
@(
"src/TeamsISO.App/Services/OperatorPresetStore.cs",
"src/TeamsISO.App/PresetsDialog.xaml",
"src/TeamsISO.App/PresetsDialog.xaml.cs"
)
# ─── #87 Recording markers ─────────────────────────────────────────────
# IRecorderSink.AddMarker fan-out via IIsoController.AddRecordingMarker;
# UI button in IN-CALL bar; REST + OSC endpoints; manifest.json gets
# markers[] array.
Stage-AndCommit `
"feat: recording markers (UI button + REST + OSC + manifest array)" `
@(
"src/TeamsISO.Engine/Pipeline/IRecorderSink.cs",
"src/TeamsISO.Engine/Pipeline/RawBgraRecorderSink.cs",
"src/TeamsISO.Engine/Controller/IIsoController.cs",
"src/TeamsISO.Engine/Controller/IsoController.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
"src/TeamsISO.App/Services/OscBridge.cs",
"docs/CONTROL-SURFACE.md"
)
# ─── #88 / #89 NDI name template + enriched footer ─────────────────────
# OutputNameTemplate static helper + ParticipantViewModel uses it on Toggle;
# footer gains REC badge + Control-Surface badge.
Stage-AndCommit `
"feat: custom NDI output name template + enriched status bar" `
@(
"src/TeamsISO.App/Services/OutputNameTemplate.cs",
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml"
)
# ─── #90 / #91 Disk space watcher + diagnostics bundle ─────────────────
Stage-AndCommit `
"feat: disk space watcher + diagnostic bundle export" `
@(
"src/TeamsISO.App/Services/DiskSpaceWatcher.cs",
"src/TeamsISO.App/Services/DiagnosticsBundle.cs",
"src/TeamsISO.App/App.xaml.cs",
"src/TeamsISO.App/AboutWindow.xaml",
"src/TeamsISO.App/AboutWindow.xaml.cs"
)
# ─── #92 Per-participant recording opt-out ─────────────────────────────
# IsoController.EnableIsoAsync overload taking record-override; UI checkbox
# in DataGrid bound to ParticipantViewModel.RecordToDisk.
Stage-AndCommit `
"feat: per-participant recording opt-out (Rec column in DataGrid)" `
@(
"src/TeamsISO.Engine/Controller/IIsoController.cs",
"src/TeamsISO.Engine/Controller/IsoController.cs",
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml"
)
# ─── #93 / #94 Keyboard shortcuts + help cheat sheet ───────────────────
# F1 / Ctrl+M / Ctrl+Shift+S / Ctrl+R InputBindings + HelpWindow dialog.
Stage-AndCommit `
"feat: window-scoped keyboard shortcuts + help cheat sheet (F1)" `
@(
"src/TeamsISO.App/HelpWindow.xaml",
"src/TeamsISO.App/HelpWindow.xaml.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml"
)
# ─── #95 / #96 / #97 Bulk enable + filter + context menu ───────────────
# EnableAllOnlineCommand, ParticipantsView with live filter, right-click
# context menu on DataGrid rows.
Stage-AndCommit `
"feat: bulk enable + participant filter + right-click context menu" `
@(
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml"
)
# ─── #98 / #99 / #100 / #101 / #102 Operator polish batch ─────────────
# --apply-preset CLI, dynamic status with live counts, embedded HTML panel
# at /ui, session timer in footer, NotesService + REST/OSC notes endpoint.
Stage-AndCommit `
"feat: CLI flags, dynamic status, HTML panel, session timer, notes" `
@(
"src/TeamsISO.App/App.xaml.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
"src/TeamsISO.App/Services/ControlPanelHtml.cs",
"src/TeamsISO.App/Services/OscBridge.cs",
"src/TeamsISO.App/Services/NotesService.cs",
"docs/CONTROL-SURFACE.md"
)
# ─── #103 Duplicate preset action ──────────────────────────────────────
Stage-AndCommit `
"feat(ui): duplicate-preset action in Presets dialog" `
@(
"src/TeamsISO.App/PresetsDialog.xaml",
"src/TeamsISO.App/PresetsDialog.xaml.cs"
)
# ─── #104 CHANGELOG.md ─────────────────────────────────────────────────
Stage-AndCommit `
"docs: add CHANGELOG.md tracking the May 2026 batch" `
@(
"CHANGELOG.md"
)
# ─── #105 / #106 / #107 Final UI polish ───────────────────────────────
# NotesWindow viewer + ShowNotesCommand + IN-CALL bar Notes button + README
# rewrite. Confirm-before-Stop-All (catches mid-show misclicks).
# About dialog gained "Logs / Recordings / Notes" folder shortcut buttons.
Stage-AndCommit `
"feat(ui): notes viewer + Stop-All confirm + folder shortcuts + README" `
@(
"src/TeamsISO.App/NotesWindow.xaml",
"src/TeamsISO.App/NotesWindow.xaml.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"src/TeamsISO.App/AboutWindow.xaml",
"src/TeamsISO.App/AboutWindow.xaml.cs",
"README.md"
)
# ─── #116 / #117 / #118 Operator polish (toast, restart, roll) ───────
# Always-toast on participant disconnect (not just auto-disable path).
# Per-pipeline "Restart this ISO" right-click action.
# "Roll recording" via UI command + REST /recording/roll + OSC.
Stage-AndCommit `
"feat(ui+control): disconnect toast, per-pipeline restart, roll recording" `
@(
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
"src/TeamsISO.App/Services/OscBridge.cs",
"docs/CONTROL-SURFACE.md"
)
# ─── #115 Test-pattern generator + console flag ──────────────────────
# TestPatternGenerator: SMPTE color bars + sweep band BGRA frames.
# TeamsISO.Console --test-pattern broadcasts TEAMSISO_TEST at 720p30.
# Useful for verifying NDI runtime without Teams running.
Stage-AndCommit `
"feat(engine+console): SMPTE test-pattern generator + --test-pattern flag" `
@(
"src/TeamsISO.Engine/Pipeline/TestPatternGenerator.cs",
"src/TeamsISO.Console/Program.cs",
"src/tests/TeamsISO.Engine.Tests/Pipeline/TestPatternGeneratorTests.cs"
)
# ─── #114 / #119 Tray icon + WinForms/WPF disambiguation ─────────────
# Adds System.Windows.Forms via UseWindowsForms=true for NotifyIcon.
# GlobalUsings.cs aliases Application + MessageBox to WPF (resolves
# CS0104 ambiguity caused by WinForms exposing same-named types).
# ControlSurfaceServer.cs gained explicit `using System.IO;` (implicit
# usings shifted with UseWindowsForms).
Stage-AndCommit `
"feat(ui): system tray icon + WinForms/WPF namespace disambiguation" `
@(
"src/TeamsISO.App/Services/TrayIconHost.cs",
"src/TeamsISO.App/Services/UIPreferences.cs",
"src/TeamsISO.App/App.xaml.cs",
"src/TeamsISO.App/TeamsISO.App.csproj",
"src/TeamsISO.App/GlobalUsings.cs",
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml"
)
# ─── #76 / #74 / #112 Tests + audio meter scaffold + MF recorder ─────
# OperatorPresetStore + OutputNameTemplate + OscMessage tests in a new
# net8.0-windows test project. Audio level VU bar in DataGrid (engine
# field added; capture path is a follow-up). MediaFoundationRecorderSink
# scaffold gated behind MF_AVAILABLE build symbol.
Stage-AndCommit `
"test+feat: App.Tests project + audio VU scaffold + MF recorder stub" `
@(
"src/tests/TeamsISO.App.Tests/TeamsISO.App.Tests.csproj",
"src/tests/TeamsISO.App.Tests/Services/OperatorPresetStoreTests.cs",
"src/tests/TeamsISO.App.Tests/Services/OutputNameTemplateTests.cs",
"src/tests/TeamsISO.App.Tests/Services/OscMessageTests.cs",
"src/TeamsISO.App/Services/OperatorPresetStore.cs",
"src/TeamsISO.App/TeamsISO.App.csproj",
"src/TeamsISO.Engine/Domain/IsoHealthStats.cs",
"src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs",
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"TeamsISO.sln",
"TeamsISO.Windows.slnf",
"docs/REAL-TIME-RECORDING.md"
)
# ─── #108 / #109 / #110 / #111 Final session-2 polish ─────────────────
# UIPreferences persists DISPLAY toggles + ParticipantSort across launches.
# PreviewWindow non-modal floating preview at 20Hz for multi-monitor.
# Configurable participant sort order via ICollectionView.SortDescriptions.
# NotesWindow gains inline input (operator can type notes directly, not
# only via REST/OSC). HTML control panel gains a "Note…" button. Richer
# GET / response. Updated CHANGELOG + README to reflect all of session 2.
Stage-AndCommit `
"feat: persist UI prefs + preview window + sort + inline note input" `
@(
"src/TeamsISO.App/Services/UIPreferences.cs",
"src/TeamsISO.App/Services/ControlSurfaceServer.cs",
"src/TeamsISO.App/Services/ControlPanelHtml.cs",
"src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs",
"src/TeamsISO.App/ViewModels/MainViewModel.cs",
"src/TeamsISO.App/PreviewWindow.xaml",
"src/TeamsISO.App/PreviewWindow.xaml.cs",
"src/TeamsISO.App/NotesWindow.xaml",
"src/TeamsISO.App/NotesWindow.xaml.cs",
"src/TeamsISO.App/ViewModels/ParticipantViewModel.cs",
"src/TeamsISO.App/MainWindow.xaml",
"README.md",
"CHANGELOG.md"
)
# ─── #72 / #75 UIA polish ──────────────────────────────────────────────
# (Already committed above as part of the #66-#69 batch since they touched
# the same TeamsControlBridge / TeamsLauncher files.)
# ─── docs ───────────────────────────────────────────────────────────────
Stage-AndCommit `
"docs: refresh _NEXT.md after recording + control surface" `
@(
"docs/superpowers/plans/_NEXT.md"
)
# ─── Push ───────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "──── Pushing to origin/main ────" -ForegroundColor Cyan
git push origin main
if ($LASTEXITCODE -ne 0) { throw "git push failed" }
Write-Host ""
Write-Host "Done. Commits pushed to forge.wilddragon.net/zgaetano/teamsiso." -ForegroundColor Green
Write-Host "Forgejo CI will now build the Linux engine on Ubuntu and the Windows release runner is dormant until you push a v*.*.* tag." -ForegroundColor DarkGray

View file

@ -1,17 +1,17 @@
# TeamsISO Control Surface — REST API # Dragon-ISO Control Surface — REST API
TeamsISO can expose a localhost HTTP server so external controllers Dragon-ISO can expose a localhost HTTP server so external controllers
(Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator, custom (Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator, custom
node-RED flows, command-line scripts) can drive it without a UI binding. node-RED flows, command-line scripts) can drive it without a UI binding.
## Enabling ## Enabling
1. Open TeamsISO → Settings → DISPLAY tab. 1. Open Dragon-ISO → Settings → DISPLAY tab.
2. Tick "Control surface (Stream Deck / Companion)". 2. Tick "Control surface (Stream Deck / Companion)".
3. Default port is **9755**; change it via the port textbox if needed. 3. Default port is **9755**; change it via the port textbox if needed.
4. By default the server binds to `127.0.0.1` only it is NOT reachable 4. By default the server binds to `127.0.0.1` only — it is NOT reachable
from the LAN. from the LAN.
5. To allow other machines on the same network to drive TeamsISO (the 5. To allow other machines on the same network to drive Dragon-ISO (the
"headless host PC + thin client" scenario), tick the nested "headless host PC + thin client" scenario), tick the nested
"LAN-reachable" checkbox underneath. The settings panel will display "LAN-reachable" checkbox underneath. The settings panel will display
the LAN URL (e.g. `http://192.168.1.42:9755/ui`) with a Copy button. the LAN URL (e.g. `http://192.168.1.42:9755/ui`) with a Copy button.
@ -31,20 +31,20 @@ netsh http add urlacl url=http://+:9755/ user=Everyone
``` ```
Also confirm the Windows Firewall is letting inbound traffic to that port Also confirm the Windows Firewall is letting inbound traffic to that port
through `New-NetFirewallRule -DisplayName "TeamsISO Control Surface" -Direction Inbound -Protocol TCP -LocalPort 9755 -Action Allow` through — `New-NetFirewallRule -DisplayName "Dragon-ISO Control Surface" -Direction Inbound -Protocol TCP -LocalPort 9755 -Action Allow`
in an elevated PowerShell, or add it through Windows Defender Firewall in an elevated PowerShell, or add it through Windows Defender Firewall →
Advanced Settings Inbound Rules. Advanced Settings → Inbound Rules.
## Authentication ## Authentication
None by design. In localhost-only mode, the loopback bind is the None — by design. In localhost-only mode, the loopback bind is the
security model: any process on the operator's machine can hit these security model: any process on the operator's machine can hit these
endpoints, the same threat model as a Stream Deck's USB connection. endpoints, the same threat model as a Stream Deck's USB connection.
In LAN-reachable mode, the assumption is a closed/trusted network (a In LAN-reachable mode, the assumption is a closed/trusted network (a
production-control LAN, a dedicated show subnet, a private vlan). Any production-control LAN, a dedicated show subnet, a private vlan). Any
machine that can route to the host on the listener port can drive machine that can route to the host on the listener port can drive
TeamsISO. **Do not enable LAN-reachable mode on an untrusted network.** Dragon-ISO. **Do not enable LAN-reachable mode on an untrusted network.**
## Response shape ## Response shape
@ -58,7 +58,7 @@ specific fields. Errors return HTTP 4xx/5xx with `{"error": "..."}`.
### `GET /ui` ### `GET /ui`
Self-contained HTML control panel. Open this in a browser to drive Self-contained HTML control panel. Open this in a browser to drive
TeamsISO from a phone, tablet, or second monitor. Lists participants live Dragon-ISO from a phone, tablet, or second monitor. Lists participants live
via the same `/ws` WebSocket the rest of the doc describes, and posts to via the same `/ws` WebSocket the rest of the doc describes, and posts to
the REST endpoints when you click. Single page, no external dependencies, the REST endpoints when you click. Single page, no external dependencies,
loads in <50KB. loads in <50KB.
@ -70,7 +70,7 @@ alive?" probes.
```json ```json
{ {
"product": "TeamsISO", "product": "Dragon-ISO",
"version": "1.0.0.0", "version": "1.0.0.0",
"endpoints": ["GET /participants", "POST /participants/{id}/iso", ...] "endpoints": ["GET /participants", "POST /participants/{id}/iso", ...]
} }
@ -89,7 +89,7 @@ Snapshot of the current participant list as the UI sees it.
"isOnline": true, "isOnline": true,
"isEnabled": false, "isEnabled": false,
"customName": null, "customName": null,
"stateLabel": "" "stateLabel": "—"
} }
] ]
} }
@ -103,7 +103,7 @@ Enable or disable an ISO by participant Id. Body or query string:
{ "enabled": true, "customName": "Host" } { "enabled": true, "customName": "Host" }
``` ```
`enabled` is optional omitting it toggles the current state. `customName` `enabled` is optional — omitting it toggles the current state. `customName`
is optional and overrides the auto-generated NDI output name. is optional and overrides the auto-generated NDI output name.
```sh ```sh
@ -174,7 +174,7 @@ Toggle per-output recording on or off. Body or query string:
``` ```
`directory` is optional when `enabled=false`. Already-running ISOs are not `directory` is optional when `enabled=false`. Already-running ISOs are not
retroactively recorded the operator should disable + re-enable a retroactively recorded — the operator should disable + re-enable a
participant to start recording it. participant to start recording it.
### `POST /recording/marker` ### `POST /recording/marker`
@ -191,8 +191,8 @@ curl -X POST 'http://127.0.0.1:9755/recording/marker?label=Guest+answer'
### `POST /notes` ### `POST /notes`
Append a timestamped line to today's show-notes file at Append a timestamped line to today's show-notes file at
`%LOCALAPPDATA%\TeamsISO\Notes\<YYYY-MM-DD>.md`. Body or query string carries `%LOCALAPPDATA%\Dragon-ISO\Notes\<YYYY-MM-DD>.md`. Body or query string carries
`text`. Each line is prefixed with `**HH:mm:ss** `; the file is markdown so `text`. Each line is prefixed with `**HH:mm:ss** —`; the file is markdown so
it renders nicely in any editor. it renders nicely in any editor.
```sh ```sh
@ -204,7 +204,7 @@ curl -X POST 'http://127.0.0.1:9755/notes?text=guest+segment+starts'
Roll every active recording into a new chunk. Each running pipeline is Roll every active recording into a new chunk. Each running pipeline is
disabled (recorder finalizes its `manifest.json`), waits ~150ms, then re- disabled (recorder finalizes its `manifest.json`), waits ~150ms, then re-
enabled (recorder opens a fresh subdirectory keyed by display name + enabled (recorder opens a fresh subdirectory keyed by display name +
timestamp). Useful for chaptering between show segments a Stream Deck timestamp). Useful for chaptering between show segments — a Stream Deck
button mapped to this gives operators "next segment" without losing the button mapped to this gives operators "next segment" without losing the
already-recorded footage. already-recorded footage.
@ -217,7 +217,7 @@ Response:
{ "ok": true, "action": "roll-recording", "rolled": 4 } { "ok": true, "action": "roll-recording", "rolled": 4 }
``` ```
## WebSocket live state push ## WebSocket — live state push
For controllers that want to light a button when an ISO goes LIVE without For controllers that want to light a button when an ISO goes LIVE without
polling, connect to: polling, connect to:
@ -240,37 +240,37 @@ snapshot is pushed within 250ms. Format:
} }
``` ```
Client→server messages are ignored for v1 — all commands go through REST. Clientâ†server messages are ignored for v1 — all commands go through REST.
## OSC over UDP ## OSC over UDP
Same command surface, different transport. Enable the OSC bridge in the Same command surface, different transport. Enable the OSC bridge in the
DISPLAY tab (default port **9000** TouchOSC's default). Bound to DISPLAY tab (default port **9000** — TouchOSC's default). Bound to
`127.0.0.1` by default; honors the same LAN-reachable toggle as the REST `127.0.0.1` by default; honors the same LAN-reachable toggle as the REST
surface when LAN mode is on, OSC binds to `0.0.0.0` so a TouchOSC tablet surface — when LAN mode is on, OSC binds to `0.0.0.0` so a TouchOSC tablet
on the same network can talk to the host directly. on the same network can talk to the host directly.
Address vocabulary: Address vocabulary:
``` ```
/teamsiso/iso "DisplayName" {0|1} — toggle/set ISO by display name /Dragon-ISO/iso "DisplayName" {0|1} — toggle/set ISO by display name
/teamsiso/iso/by-id "guid" {0|1} — toggle/set by Id /Dragon-ISO/iso/by-id "guid" {0|1} — toggle/set by Id
/teamsiso/preset "Name" — apply preset /Dragon-ISO/preset "Name" — apply preset
/teamsiso/teams/mute — UIA toggle mute /Dragon-ISO/teams/mute — UIA toggle mute
/teamsiso/teams/camera — UIA toggle camera /Dragon-ISO/teams/camera — UIA toggle camera
/teamsiso/teams/leave — UIA leave /Dragon-ISO/teams/leave — UIA leave
/teamsiso/teams/share — UIA share tray /Dragon-ISO/teams/share — UIA share tray
/teamsiso/teams/raise-hand — UIA raise hand /Dragon-ISO/teams/raise-hand — UIA raise hand
/teamsiso/refresh-discovery — rebuild NDI finder /Dragon-ISO/refresh-discovery — rebuild NDI finder
/teamsiso/stop-all — disable every ISO /Dragon-ISO/stop-all — disable every ISO
/teamsiso/recording {0|1} — recording on/off (default dir) /Dragon-ISO/recording {0|1} — recording on/off (default dir)
/teamsiso/recording/marker "Label" — drop a marker on every active recording /Dragon-ISO/recording/marker "Label" — drop a marker on every active recording
/teamsiso/recording/roll — roll every active recording into a new chunk /Dragon-ISO/recording/roll — roll every active recording into a new chunk
/teamsiso/notes "Free-form note" — append a timestamped line to today's notes /Dragon-ISO/notes "Free-form note" — append a timestamped line to today's notes
``` ```
Companion → Surfaces → "Generic OSC" supports outbound OSC; bind a button Companion → Surfaces → "Generic OSC" supports outbound OSC; bind a button
press to e.g. `/teamsiso/iso "Jane" 1`. TouchOSC layouts can use the same press to e.g. `/Dragon-ISO/iso "Jane" 1`. TouchOSC layouts can use the same
addresses on the same UDP port. addresses on the same UDP port.
## Bitfocus Companion recipe ## Bitfocus Companion recipe
@ -292,7 +292,7 @@ on the appropriate endpoint above.
## Future work ## Future work
- **HTTPS / token auth** for deployments that don't have a closed - **HTTPS / token auth** — for deployments that don't have a closed
network, layer TLS termination + a shared bearer token in front of the network, layer TLS termination + a shared bearer token in front of the
HttpListener. Out of scope for v1; the LAN-reachable mode is a HttpListener. Out of scope for v1; the LAN-reachable mode is a
trusted-network feature only. trusted-network feature only.

View file

@ -1,4 +1,4 @@
# Real-time H.264 recording # Real-time H.264 recording
The default recorder (`RawBgraRecorderSink`) writes uncompressed BGRA to disk The default recorder (`RawBgraRecorderSink`) writes uncompressed BGRA to disk
and ships a `convert.cmd` for post-recording FFmpeg encoding. That's safe and ships a `convert.cmd` for post-recording FFmpeg encoding. That's safe
@ -7,32 +7,32 @@ and ships a `convert.cmd` for post-recording FFmpeg encoding. That's safe
For long shows or operators on slower disks, the engine ships a For long shows or operators on slower disks, the engine ships a
**`MediaFoundationRecorderSink`** that encodes to H.264 in real time using **`MediaFoundationRecorderSink`** that encodes to H.264 in real time using
Windows Media Foundation. Inline encoding cuts disk pressure ~10× and Windows Media Foundation. Inline encoding cuts disk pressure ~10× and
produces a finished `.mp4` without the convert step. produces a finished `.mp4` without the convert step.
It's behind a build flag because activating it requires adding a NuGet It's behind a build flag because activating it requires adding a NuGet
dependency. The structural code is already in dependency. The structural code is already in
`src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs`. `src/Dragon-ISO.Engine/Pipeline/MediaFoundationRecorderSink.cs`.
## Status May 2026 ## Status — May 2026
**Activation deferred.** The Vortice.MediaFoundation 3.6.2 NuGet package **Activation deferred.** The Vortice.MediaFoundation 3.6.2 NuGet package
is referenced from `TeamsISO.Engine.csproj`, but the `MF_AVAILABLE` symbol is referenced from `Dragon-ISO.Engine.csproj`, but the `MF_AVAILABLE` symbol
is *not* defined. The scaffold in is *not* defined. The scaffold in
`src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs` was written `src/Dragon-ISO.Engine/Pipeline/MediaFoundationRecorderSink.cs` was written
against an older Vortice API and needs a port pass before activation: against an older Vortice API and needs a port pass before activation:
- `MFVersion` not on `MediaFactory` in 3.6.2; pass the SDK version - `MFVersion` → not on `MediaFactory` in 3.6.2; pass the SDK version
directly to `MFStartup`. directly to `MFStartup`.
- `MediaFactory.MF_LOW_LATENCY` relocated to a different attribute - `MediaFactory.MF_LOW_LATENCY` → relocated to a different attribute
constants class. constants class.
- `IMFAttributes.SetUINT32` replaced with a generic `Set` overload. - `IMFAttributes.SetUINT32` → replaced with a generic `Set` overload.
- `IMFMediaType.MajorType` / `.SubType` / `.AvgBitrate` properties - `IMFMediaType.MajorType` / `.SubType` / `.AvgBitrate` properties
now use `SetGUID(MFAttributeKeys.MajorType, ...)` etc. → now use `SetGUID(MFAttributeKeys.MajorType, ...)` etc.
- `VideoFormatGuids.RGB32` renamed (likely `Rgb32`). - `VideoFormatGuids.RGB32` → renamed (likely `Rgb32`).
- `IMFMediaBuffer.Lock(out IntPtr, out int, out int)` explicit out-param - `IMFMediaBuffer.Lock(out IntPtr, out int, out int)` → explicit out-param
signature, no longer returns a locked-buffer wrapper. signature, no longer returns a locked-buffer wrapper.
- `IMFSinkWriter.Finalize_` renamed (likely `Finalize`). - `IMFSinkWriter.Finalize_` → renamed (likely `Finalize`).
Until the port lands, the `RawBgraRecorderSink` is the only IRecorderSink Until the port lands, the `RawBgraRecorderSink` is the only IRecorderSink
production uses. The raw recorder is reliable and FFmpeg post-processing production uses. The raw recorder is reliable and FFmpeg post-processing
@ -45,7 +45,7 @@ disk pressure during the show.
reference implementation lives in the Vortice samples repo under reference implementation lives in the Vortice samples repo under
`samples/MediaFoundationSamples`. `samples/MediaFoundationSamples`.
2. **Define the `MF_AVAILABLE` build symbol** in `TeamsISO.Engine.csproj`: 2. **Define the `MF_AVAILABLE` build symbol** in `Dragon-ISO.Engine.csproj`:
```xml ```xml
<PropertyGroup> <PropertyGroup>
@ -71,10 +71,10 @@ disk pressure during the show.
## What the MF recorder produces ## What the MF recorder produces
For each enabled ISO with recording on: For each enabled ISO with recording on:
- `<recordings>/<participant>/output.mp4` H.264 video at the engine's - `<recordings>/<participant>/output.mp4` — H.264 video at the engine's
configured resolution / framerate, target bitrate ~0.07 bits/pixel configured resolution / framerate, target bitrate ~0.07 bits/pixel
(~7 Mbps for 1080p30, ~3 Mbps for 720p30). (~7 Mbps for 1080p30, ~3 Mbps for 720p30).
- `<recordings>/<participant>/markers.txt` tab-separated marker offsets - `<recordings>/<participant>/markers.txt` — tab-separated marker offsets
from `IIsoController.AddRecordingMarker`. Manually chapter the .mp4 with from `IIsoController.AddRecordingMarker`. Manually chapter the .mp4 with
`mp4chaps -c -i markers.txt output.mp4` (mp4chaps from the `mp4v2` tools). `mp4chaps -c -i markers.txt output.mp4` (mp4chaps from the `mp4v2` tools).
@ -91,5 +91,5 @@ For each enabled ISO with recording on:
| Reliable on legacy GPUs | Yes | Yes (MF falls back to software encoder if no hw H.264) | | Reliable on legacy GPUs | Yes | Yes (MF falls back to software encoder if no hw H.264) |
If your target machines have NVIDIA NVENC / Intel QuickSync, MF will use If your target machines have NVIDIA NVENC / Intel QuickSync, MF will use
the hardware encoder transparently that's the path that gives you the hardware encoder transparently — that's the path that gives you
multi-stream realtime H.264 with low CPU. multi-stream realtime H.264 with low CPU.

View file

@ -1,4 +1,4 @@
# Releasing TeamsISO # Releasing Dragon-ISO
The release workflow at `.forgejo/workflows/release.yml` runs on **annotated tag pushes The release workflow at `.forgejo/workflows/release.yml` runs on **annotated tag pushes
matching `v*.*.*`**. It builds, tests, publishes, packages an MSI, and uploads the matching `v*.*.*`**. It builds, tests, publishes, packages an MSI, and uploads the
@ -11,26 +11,26 @@ MSI as a release asset.
separate Windows runner. Register one with `forgejo-runner register` against a separate Windows runner. Register one with `forgejo-runner register` against a
Windows host that has the .NET 8 SDK + WiX SDK access (the WiX SDK pulls itself Windows host that has the .NET 8 SDK + WiX SDK access (the WiX SDK pulls itself
via NuGet at build time, so no separate install). via NuGet at build time, so no separate install).
- The repository's **Create release on tag push** setting on (default), or skip it - The repository's **Create release on tag push** setting on (default), or skip it —
the workflow will create the release if one doesn't exist. the workflow will create the release if one doesn't exist.
## Cutting a release ## Cutting a release
```sh ```sh
# Bump the version in Directory.Build.props if you haven't already. # Bump the version in Directory.Build.props if you haven't already.
git tag -a v1.0.0 -m "TeamsISO 1.0.0" git tag -a v1.0.0 -m "Dragon-ISO 1.0.0"
git push origin v1.0.0 git push origin v1.0.0
``` ```
The workflow will: The workflow will:
1. Restore + build `TeamsISO.Windows.slnf` in Release with the tag's version. 1. Restore + build `Dragon-ISO.Windows.slnf` in Release with the tag's version.
2. Run unit tests (the `requires=ndi` integration tier is skipped it needs a 2. Run unit tests (the `requires=ndi` integration tier is skipped — it needs a
real NDI runtime which a CI runner won't have). real NDI runtime which a CI runner won't have).
3. Publish `TeamsISO.App` and `TeamsISO.Console` for `win-x64`, 3. Publish `Dragon-ISO.App` and `Dragon-ISO.Console` for `win-x64`,
framework-dependent (.NET 8 Desktop runtime is the user's responsibility). framework-dependent (.NET 8 Desktop runtime is the user's responsibility).
4. Build `installer/TeamsISO.Installer.wixproj`, producing 4. Build `installer/Dragon-ISO.Installer.wixproj`, producing
`TeamsISO-Setup-<version>.msi`. `Dragon-ISO-Setup-<version>.msi`.
5. Upload the MSI as a workflow artifact (downloadable from the run page). 5. Upload the MSI as a workflow artifact (downloadable from the run page).
6. Attach the MSI to the GitHub-style Release for the tag, creating the release 6. Attach the MSI to the GitHub-style Release for the tag, creating the release
first if it doesn't exist. Pre-release flag is set automatically when the first if it doesn't exist. Pre-release flag is set automatically when the
@ -39,13 +39,13 @@ The workflow will:
## Code signing ## Code signing
The release workflow has optional signtool integration. It runs only when the The release workflow has optional signtool integration. It runs only when the
signing-cert secrets are configured on the repository without them, builds signing-cert secrets are configured on the repository — without them, builds
remain unsigned and produce a SmartScreen warning on first launch. remain unsigned and produce a SmartScreen warning on first launch.
### Enabling signing ### Enabling signing
Set these secrets on `forge.wilddragon.net/zgaetano/teamsiso` Set these secrets on `forge.wilddragon.net/zgaetano/Dragon-ISO`
→ Settings → Actions → Secrets: → Settings → Actions → Secrets:
| Secret | Required | Notes | | Secret | Required | Notes |
| --- | --- | --- | | --- | --- | --- |
@ -56,7 +56,7 @@ Set these secrets on `forge.wilddragon.net/zgaetano/teamsiso`
When all three are present, the workflow: When all three are present, the workflow:
1. Decodes the PFX to a temp file on the runner before building. 1. Decodes the PFX to a temp file on the runner before building.
2. Signs `publish/TeamsISO/TeamsISO.exe` after publish, before MSI build, so the 2. Signs `publish/Dragon-ISO/Dragon-ISO.exe` after publish, before MSI build, so the
binary embedded in the MSI is signed too. binary embedded in the MSI is signed too.
3. Signs the produced MSI itself after WiX builds it. 3. Signs the produced MSI itself after WiX builds it.
4. Wipes the temp PFX from disk. 4. Wipes the temp PFX from disk.
@ -70,7 +70,7 @@ which is what current Microsoft / SmartScreen guidance requires.
per-publisher over time; brand-new OV certs still trip the warning until per-publisher over time; brand-new OV certs still trip the warning until
enough downloads accumulate. enough downloads accumulate.
- **EV (Extended Validation, ~$300/yr, hardware token).** SmartScreen-trusted - **EV (Extended Validation, ~$300/yr, hardware token).** SmartScreen-trusted
immediately. Token-based to use one in CI you'll need to either (a) keep immediately. Token-based — to use one in CI you'll need to either (a) keep
the runner on a host with the token plugged in, or (b) move to a cloud the runner on a host with the token plugged in, or (b) move to a cloud
signing service like Azure Trusted Signing or DigiCert KeyLocker. signing service like Azure Trusted Signing or DigiCert KeyLocker.

View file

@ -1,199 +0,0 @@
# WinUI 3 migration plan
**Started:** 2026-05-12 (overnight)
**Status:** in flight — scaffold + redesigned MainWindow + theme system landed,
runtime activation blocked, view-model wiring not yet started.
The full plan for replatforming TeamsISO from WPF / .NET 8 to WinUI 3 /
Windows App SDK 1.6 LTS. The redesigned UI per the approved shape brief
(PRODUCT.md, DESIGN.md, the 2026-05-12 chat transcript) lands as the new
TeamsISO.App.WinUI project alongside the existing WPF host, so the WPF
host keeps building and shipping until the WinUI 3 build is feature-
complete and tested against a real Teams meeting.
## Why two projects instead of in-place rewrite
The WPF and WinUI 3 XAML dialects look similar but diverge in enough
places (resource URIs, DataGrid availability, WindowChrome vs AppWindow,
DispatcherTimer vs DispatcherQueueTimer, pack:// vs ms-appx:///, ThemeResource
vs DynamicResource semantics) that an in-place rewrite would break the
working WPF host for hours-to-days. Coexisting both projects means:
1. `dotnet build TeamsISO.Windows.slnf` keeps producing a working WPF .exe
throughout the migration.
2. Each WinUI 3 view can be migrated and verified independently.
3. The engine layer (TeamsISO.Engine, TeamsISO.Engine.NdiInterop) and the
view-models (TeamsISO.App/ViewModels/) are **shared** via ProjectReference.
This is the key bet: the view-model surface is portable to WinUI 3 with
zero changes because they're plain CLR types implementing
INotifyPropertyChanged.
4. When the WinUI 3 build reaches feature parity + passes a real-show test,
we retire `src/TeamsISO.App` and the WinUI 3 project becomes the only
shipping host.
## Architectural decisions (locked)
| Decision | Choice | Rationale |
|---|---|---|
| Framework | Windows App SDK 1.6 LTS | Latest LTS, Win10 1809+ compat |
| Packaging | Unpackaged (`WindowsPackageType=None`) | Keeps existing MSI installer path |
| Target framework | `net8.0-windows10.0.19041.0` | WindowsAppSDK 1.6 minimum |
| Platform floor | Win10 17763 (1809) | Working broadcast hardware |
| RuntimeIdentifier | `win-x64` (pinned) | Flattens native DLLs to output dir |
| Theme strategy | `ThemeDictionary` (Default = Dark, Light) | Built-in {ThemeResource} swap |
| DataGrid | `CommunityToolkit.WinUI.UI.Controls.DataGrid 7.1.2` | Only maintained free option |
| View-model | Reuse from TeamsISO.App via ProjectReference | Zero porting cost |
| Window chrome | `AppWindow.TitleBar.ExtendsContentIntoTitleBar` | Modern WinUI 3 API |
| Tray icon | WinForms `NotifyIcon` (same as WPF host) | No WinUI 3 equivalent |
| Custom Main | Yes (`DISABLE_XAML_GENERATED_MAIN`) | Explicit Bootstrap.TryInitialize |
## Phases
### Phase 1 — Scaffold (done)
- [x] `src/TeamsISO.App.WinUI/` project created with WindowsAppSDK 1.6
- [x] `Themes/Tokens.xaml` with Dark + Light ThemeDictionaries
- [x] `Themes/Controls.xaml` with Button hierarchy + typographic ramp
- [x] `App.xaml` + `App.xaml.cs` minimal startup
- [x] `Program.cs` custom Main with Bootstrap.TryInitialize
- [x] Assets copied (Inter.ttf, JetBrainsMono.ttf, dragon-mark.png, icon)
- [x] Solution updated (.sln + .slnf paths backslash-normalized)
- [x] `dotnet build TeamsISO.Windows.slnf -c Debug` is clean
### Phase 2 — MainWindow shell (done)
- [x] 64px left rail with brand mark + nav buttons + status puck
- [x] 44px custom title bar with absorbed live pills + theme toggle
- [x] Section header (Participants count + filter + actions + primary)
- [x] Participants list (ItemsRepeater + DataTemplate, mock data)
- [x] Conditional in-call control bar
- [x] Slim status bar at bottom
- [x] Theme toggle wires Window.Content.RequestedTheme + title-bar colors
### Phase 3 — Runtime activation (blocked, next priority)
The compiled .exe shows "TeamsISO.exe - This application could not be
started" before Main() runs. COREHOST_TRACE confirms .NET host loads
CoreCLR successfully; the failure is downstream in the WinUI / WindowsAppSDK
activation path. Suspected causes (in priority order):
1. **Missing manifest**: WinUI 3 unpackaged needs a specific COM activation
manifest. Our custom `app.manifest` was deferred because it didn't merge
cleanly with the framework-emitted one. Reintroduce with proper
`uap:VisualElements`.
2. **Microsoft.WindowsDesktop.App framework reference**: runtimeconfig.json
includes `Microsoft.WindowsDesktop.App 8.0.0`, which WinUI 3 doesn't
want. The .NET SDK adds it implicitly from the `-windows` target
framework moniker. Try `<EnableMsixTooling>true</EnableMsixTooling>`
+ remove from frameworks list.
3. **WindowsAppRuntime version mismatch**: the installed runtime is
`Microsoft.WindowsAppRuntime.1.6 (6000.519.329.0)`. Bootstrap.TryInitialize
should accept any 1.6.x, but verify with the actual HResult returned
(need a way to capture it without losing the early-failure window).
4. **Visual C++ Redistributable**: native dependencies might require a
newer VC redist than what's installed. Check WindowsAppSDK 1.6's
redist requirements.
**Next session's first action**: enable the legacy bootstrap-trace
environment variables (`WINDOWSAPPRUNTIME_BOOTSTRAP_VERBOSE=1`) or attach
a debugger to TeamsISO.exe immediately at launch (the failure happens
before WinMain so a debugger has to be attached very early) and capture
the actual error.
### Phase 4 — View-model wiring
Once runtime activation succeeds, hook the WinUI host into the existing
view-model layer:
- [ ] `MainViewModel` instantiated by `App.OnLaunched` (mirror WPF
App.xaml.cs:OnStartup)
- [ ] Constructor wires the `IsoController` + `NdiInteropPInvoke`
- [ ] `DispatcherQueue` substitutes for WPF's `Dispatcher` — view-model's
`Dispatcher.InvokeAsync` calls need adapting to
`DispatcherQueue.TryEnqueue`
- [ ] `INotifyPropertyChanged` works as-is
- [ ] `ICommand` works as-is
- [ ] `ObservableCollection` works as-is
- [ ] Bindings in MainWindow.xaml updated from {Binding ...} to {x:Bind ...}
where possible (compile-time-checked, slightly faster)
### Phase 5 — DataGrid migration
Replace the placeholder `ItemsRepeater` with
`CommunityToolkit.WinUI.UI.Controls.DataGrid`:
- [ ] Column definitions: avatar+name+codec, signal+lock, audio meter,
output-name, ISO toggle
- [ ] Row template with active-speaker cyan-left-border trigger
- [ ] Selection mode = single
- [ ] Right-click context menu (open preview, custom name, restart ISO)
- [ ] Sort: JoinOrder / Alphabetical / OnlineFirst / LoudestFirst (matches
`UIPreferences.SortMode`)
### Phase 6 — Secondary windows
- [ ] Settings drawer (`SettingsDrawer.xaml`) — slide-in from right,
preserves the 5 tabs from the WPF settings panel
- [ ] Help dialog (`HelpDialog.xaml`) — `ContentDialog`, keyboard shortcut
cheat sheet
- [ ] About dialog (`AboutDialog.xaml`) — version, logs path, update check
- [ ] Onboarding (`OnboardingWindow.xaml`) — first-launch only, three panes
- [ ] Notes viewer (`NotesViewer.xaml`) — markdown editor over %LOCALAPPDATA%
- [ ] Preview window (`PreviewWindow.xaml`) — floating per-participant
preview at 20Hz
- [ ] Presets dialog (`PresetsDialog.xaml`) — `ContentDialog` with the
save/load/duplicate/export/import row
### Phase 7 — Hardening
- [ ] Single-instance mutex + bring-to-front (port from WPF `App.xaml.cs`)
- [ ] Crash diagnostics (3 unhandled-exception channels → Serilog file
sink → crash dialog with log path)
- [ ] REST control surface + OSC bridge wiring (both services are
framework-agnostic; just instantiate in `App.OnLaunched`)
- [ ] Tray icon (port `TrayIconHost.cs` — WinForms.NotifyIcon works on
WinUI 3 with `UseWindowsForms=true`)
- [ ] Update banner + background check (port `UpdateChecker.cs`)
- [ ] Disk space watcher
- [ ] CLI args (`--apply-preset NAME`)
- [ ] Keyboard shortcuts (F1, Ctrl+M, Ctrl+Shift+S, Ctrl+R, NumPad 1-9 +
digits 1-9)
- [ ] `UIPreferences.Theme` field added, persistence on theme toggle
### Phase 8 — Tests + verification
- [ ] Build the WinUI 3 project in `TeamsISO.App.Tests` (currently targets
`net8.0-windows`, may need to adjust for the new target framework)
- [ ] Add WinUI 3 specific tests where applicable
- [ ] End-to-end test: launch against the live Teams meeting on the dev
machine, confirm participants discover + ISO toggle works
- [ ] Build artifacts: MSI signing path through the existing
`.forgejo/workflows/release.yml`
### Phase 9 — Retire WPF host
- [ ] `dotnet sln remove src/TeamsISO.App/TeamsISO.App.csproj`
- [ ] Delete `src/TeamsISO.App/` directory
- [ ] Update README.md and CHANGELOG.md
- [ ] Tag v1.0.0 (the original v1.0 cut moves to v0.9; v1.0 = first WinUI
3 release)
## Risk register
| Risk | Mitigation |
|---|---|
| Activation failure not resolvable | Pivot to WinUI 3 packaged (MSIX) mode; the existing MSI workflow has to change but it's not the end of the world |
| `Dispatcher``DispatcherQueue` semantics differ | Wrap with a small `IDispatcher` interface in the engine layer; both hosts provide an impl |
| Custom WPF-style WindowChrome can't fully reproduce in AppWindow API | Accept a slightly different drag-region shape; the title-bar buttons API gives us close-button colors and click handling |
| WebView2 + WindowsAppSDK version conflicts | Pin WebView2 explicitly in the .csproj |
| CommunityToolkit DataGrid 7.x maintenance ending | Plan a fallback to `WinUI.TableView` 1.4.x as a contingency |
| Performance regression on the participants table (thumbnails at 20Hz × N rows) | Profile early; if needed, use `Win2D` for the audio meter and signal indicator |
## What I'm NOT doing
- Replacing the engine layer
- Touching the NDI native interop
- Changing the control surface protocol (REST/WebSocket/OSC)
- Migrating tests right now (Phase 8)
- Adding new product features (anything not in the redesign brief stays
for a follow-on release)

View file

@ -1,142 +0,0 @@
using System;
using System.Runtime.InteropServices;
namespace TeamsISO.App.WinUI.Probe;
/// <summary>
/// Tiny diagnostic console — calls the native MddBootstrapInitialize2
/// export from Microsoft.WindowsAppRuntime.Bootstrap.dll directly and
/// reports the HResult.
///
/// Use to isolate whether the WinUI 3 activation blocker is:
/// (a) Bootstrap DLL load — DllNotFoundException at the P/Invoke call
/// (b) Framework package resolution — Bootstrap returns non-S_OK HR
/// (c) Downstream — Bootstrap succeeds, the WinUI 3 .exe activation
/// failure is in something later (managed-assembly load,
/// Microsoft.WinUI.dll native imports, etc.)
/// </summary>
internal static class Program
{
/// <summary>WindowsAppSDK target major/minor.</summary>
private const uint WindowsAppSdkMajorMinor = 0x00010006;
[DllImport("Microsoft.WindowsAppRuntime.Bootstrap.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
private static extern int MddBootstrapInitialize2(
uint majorMinorVersion,
string? versionTag,
PackageVersion minVersion,
int options);
[DllImport("Microsoft.WindowsAppRuntime.Bootstrap.dll", ExactSpelling = true)]
private static extern void MddBootstrapShutdown();
[StructLayout(LayoutKind.Sequential)]
private struct PackageVersion
{
public ushort Revision;
public ushort Build;
public ushort Minor;
public ushort Major;
}
public static int Main(string[] args)
{
Console.WriteLine("TeamsISO WinUI 3 bootstrap probe");
Console.WriteLine("───────────────────────────────────────────");
Console.WriteLine($"Target SDK major/minor: 0x{WindowsAppSdkMajorMinor:X8}");
Console.WriteLine();
try
{
// Try with both null and "" for versionTag; report both.
var minVersion = new PackageVersion();
Console.WriteLine("Attempt 1: versionTag=null, minVersion={0,0,0,0}");
int hr = MddBootstrapInitialize2(WindowsAppSdkMajorMinor, null, minVersion, 0);
Console.WriteLine($" HR=0x{hr:X8} ({Describe(hr)})");
if (hr != 0)
{
Console.WriteLine();
Console.WriteLine("Attempt 2: versionTag=\"\", minVersion={0,0,0,0}");
hr = MddBootstrapInitialize2(WindowsAppSdkMajorMinor, "", minVersion, 0);
Console.WriteLine($" HR=0x{hr:X8} ({Describe(hr)})");
}
if (hr != 0)
{
Console.WriteLine();
Console.WriteLine("Attempt 3: versionTag=\"\", options=1 (DoNotShowDialog)");
hr = MddBootstrapInitialize2(WindowsAppSdkMajorMinor, "", minVersion, 1);
Console.WriteLine($" HR=0x{hr:X8} ({Describe(hr)})");
}
if (hr == 0)
{
Console.WriteLine();
Console.WriteLine("Bootstrap succeeded.");
Console.WriteLine("The WinUI 3 .exe activation failure is NOT in the bootstrap.");
Console.WriteLine("Suspect: downstream managed-assembly load (Microsoft.WinUI.dll");
Console.WriteLine("native imports during JIT).");
MddBootstrapShutdown();
}
else
{
Console.WriteLine();
Console.WriteLine("Bootstrap failed. Decode the HResult:");
DescribeHResult(hr);
}
}
catch (DllNotFoundException ex)
{
Console.WriteLine($"DllNotFoundException: {ex.Message}");
Console.WriteLine();
Console.WriteLine("Microsoft.WindowsAppRuntime.Bootstrap.dll couldn't be located by");
Console.WriteLine("the loader. Check that the file is alongside the .exe and that the");
Console.WriteLine("process architecture matches (x64 .exe loads x64 DLLs).");
}
catch (Exception ex)
{
Console.WriteLine($"Unexpected: {ex.GetType().Name}: {ex.Message}");
}
Console.WriteLine();
Console.WriteLine("Press any key to exit.");
Console.ReadKey(true);
return 0;
}
private static string Describe(int hr) => hr switch
{
0 => "S_OK",
unchecked((int)0x80073B17) => "ERROR_INSTALL_PACKAGE_NOT_FOUND",
unchecked((int)0x80073B19) => "ERROR_PACKAGES_REPUTATION_CHECK_FAILED",
unchecked((int)0x80004005) => "E_FAIL",
unchecked((int)0x80670016) => "MDD_E_BOOTSTRAP_INITIALIZE_DDLM_NOT_FOUND",
unchecked((int)0x80670017) => "MDD_E_BOOTSTRAP_INITIALIZE_LIFECYCLE_MANAGER_FAILURE",
_ => "(unknown HR)",
};
private static void DescribeHResult(int hr)
{
var description = (uint)hr switch
{
0x80670016 =>
"DDLM (Dynamic Dependency Lifetime Manager) for this WindowsAppSDK major.minor\n" +
" is NOT installed on this machine. The framework package (Microsoft.WindowsApp\n" +
" Runtime.1.6) may be present but its DDLM sibling — MicrosoftCorporationII.\n" +
" WinAppRuntime.Main.1.6 — is missing. Run \"Get-AppxPackage | Where Name -like\n" +
" '*WinAppRuntime.Main*'\" to see which versions have DDLM coverage. Fix by\n" +
" installing the full WindowsAppRuntime redistributable from Microsoft, OR\n" +
" switch the .csproj to a major.minor whose Main package IS installed.",
0x80670017 =>
"Lifecycle manager start failed. The DDLM is installed but couldn't be activated.\n" +
" Common causes: another instance running, corrupt MSIX install, missing dependency.",
0x80073B17 => "Framework package not found. Install Microsoft.WindowsAppRuntime.<x.y>.",
0x80073B18 => "Framework package version mismatch.",
0x80073B19 => "Framework package not present for current user.",
0x80073B26 => "Framework package architecture mismatch.",
_ => $"Unknown HResult. Look up in WindowsAppSDK source BootstrapErrorCodes.h.",
};
Console.WriteLine($" {description}");
}
}

View file

@ -1,49 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Tiny diagnostic console app for the WinUI 3 activation blocker.
Calls the native MddBootstrapInitialize2 export from
Microsoft.WindowsAppRuntime.Bootstrap.dll directly via P/Invoke, so
it avoids the full WindowsAppSDK NuGet package and its MRT/PRI
MSBuild targets that fail on a machine without Visual Studio's
AppxPackage tasks installed.
Build: dotnet build src/TeamsISO.App.WinUI.Probe
Run: ./src/TeamsISO.App.WinUI.Probe/bin/Debug/net8.0-windows/win-x64/TeamsISO.App.WinUI.Probe.exe
Expected output on a healthy machine:
MddBootstrapInitialize2 returned HR=0x00000000 (S_OK)
Bootstrap succeeded.
On a machine where Microsoft.WindowsAppRuntime.Bootstrap.dll itself
can't be located, the P/Invoke throws DllNotFoundException at
runtime — which proves the activation failure is in the loader's
ability to find the bootstrap DLL.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>TeamsISO.App.WinUI.Probe</RootNamespace>
<Platforms>x64</Platforms>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<!--
Hand-copy Microsoft.WindowsAppRuntime.Bootstrap.dll from the
NuGet cache so the P/Invoke can find it. Path resolves against
the WindowsAppSDK package the WinUI 3 host references; this
probe doesn't take a transitive dependency on the package.
-->
<Content Include="$(NuGetPackageRoot)microsoft.windowsappsdk\1.6.250602001\runtimes\win-x64\native\Microsoft.WindowsAppRuntime.Bootstrap.dll"
Link="Microsoft.WindowsAppRuntime.Bootstrap.dll"
CopyToOutputDirectory="PreserveNewest"
Visible="false" />
</ItemGroup>
</Project>

View file

@ -1,261 +0,0 @@
# Work log — overnight session 2026-05-12 → 2026-05-13
The redesign brief was approved with one edit (add dark + light theming), the
WinUI 3 replatform was green-lit explicitly, and you said don't stop until
told to. This log is what happened.
## TL;DR — overnight result
**The WinUI 3 redesigned host runs.** It launches, renders, and respects
dark / light theme. See `docs/preview/winui3-mainwindow-light.png` and
`docs/preview/winui3-mainwindow-dark.png` for proof shots captured from
the live .exe.
**Eighteen commits landed on origin/main.** Already pushed (credentials
refreshed during the session).
**The WPF host is untouched.** Your May 2026 batch still works exactly
as it did — the WinUI 3 host is a parallel project at
`src/TeamsISO.App.WinUI/`.
**Two activation blockers — both diagnosed:**
1. WindowsAppSDK 1.6 DDLM wasn't installed on this machine
(Get-AppxPackage shows Main.1.5 and Main.1.8 but no Main.1.6). Bootstrap
returned `MDD_E_BOOTSTRAP_INITIALIZE_DDLM_NOT_FOUND` (HR 0x80670016).
**Fix:** switched to WindowsAppSDK 1.8 — its DDLM is present.
2. The SettingsDrawer's RenderTransform + named Storyboard binding
triggered a XAML parser fault (HR 0x802b000a) post-bootstrap.
**Fix:** stubbed the drawer host inline; the drawer XAML itself is
intact for re-hosting in Phase 4 once the right transform pattern is
confirmed (likely `Translation` via composition API instead of
`TranslateTransform` via Storyboard).
**What I left in mostly-ready state:**
* `src/TeamsISO.App.WinUI/Views/MainWindow.xaml` — redesigned IA, runs.
Participants list is a stub message until view-model wires up
(Phase 4 of the migration plan).
* `src/TeamsISO.App.WinUI/Views/SettingsDrawer.xaml` + .cs — builds
clean; not hosted yet.
* `src/TeamsISO.App.WinUI/Views/HelpDialog.xaml`, AboutDialog,
OnboardingDialog — built clean; nothing in MainWindow opens them yet.
* `src/TeamsISO.App.WinUI/Services/ThemeManager.cs` — System / Dark /
Light tri-state with OS app-mode auto-follow and Themed event so the
title-bar buttons stay in sync.
* `src/TeamsISO.App.WinUI.Probe/` — diagnostic console for activation
triage. Run if a deployment target ever shows the same activation
dialog.
* `docs/preview/redesigned-mainwindow.html` — interactive HTML preview
for non-Windows stakeholders.
## Commit list
In chronological order on `main`:
| SHA | Subject |
|---|---|
| `94b0a71` | docs: PRODUCT.md + DESIGN.md (ground-up GUI redesign brief) |
| `cb1402e` | feat(winui3): scaffold TeamsISO.App.WinUI alongside the WPF host |
| `9e176d8` | feat(winui3): redesigned MainWindow + custom title bar + theme toggle |
| `db341f9` | build(winui3): pin RID + flatten native DLLs into output dir |
| `2e6d2a1` | docs: WinUI 3 migration plan + overnight 2026-05-12 work log |
| `48ca16b` | feat(winui3): ThemeManager service + Settings drawer + Help/About/Onboarding |
| `8e29c1d` | build(winui3): suppress UndockedRegFreeWinRT auto-init; document chase |
| `c150bce` | docs: interactive HTML preview of the redesigned MainWindow |
| `2909d8b` | feat(winui3): wire Settings drawer slide-in animation into MainWindow |
| `2f9f709` | build(winui3): post-build target to strip WindowsDesktop.App from runtimeconfig |
| `46b1ca5` | fix(preview): clip drawer behind .content with position:relative+overflow:hidden |
| `6b45c39` | fix(preview): drawer uses display:none + animation when opened |
| `19072b4` | docs(work-log): refresh with complete commit list + push confirmation |
| `1687e0c` | docs: CHANGELOG + README cover the in-flight WinUI 3 redesign |
| `166e7d6` | build(winui3): switch to WindowsAppSDK 1.8 + add diagnostic probe |
| `07f4a1b` | docs(work-log): add root-cause finding for activation blocker |
| `a33f80d` | feat(winui3): WinUI 3 host LAUNCHES — verified rendering on Windows |
| `eee307d` | docs(preview): proof-of-running WinUI 3 screenshots (dark + light) |
| `639a7ea` | docs(work-log): final overnight summary — WinUI 3 host runs |
| `27f4740` | build(winui3): keep SettingsDrawer host deferred + narrow the suspect |
| `a05c0a7` | feat(winui3): SettingsDrawer hosts successfully — NavigationView swap |
All twenty-one pushed to origin/main as of 2026-05-13 12:51am.
## What you'll find in the tree
```
Teams ISO/
├─ PRODUCT.md ← new, baseline product brief
├─ DESIGN.md ← new, token-level design system
├─ docs/
│ ├─ preview/
│ │ └─ redesigned-mainwindow.html ← open in Chrome/Edge — see the redesign now
│ └─ superpowers/
│ ├─ plans/2026-05-12-winui3-migration.md ← new, full migration plan
│ └─ work-log-2026-05-12.md ← this file
├─ src/
│ ├─ TeamsISO.App/ ← unchanged, the WPF host
│ └─ TeamsISO.App.WinUI/ ← new, the WinUI 3 host
│ ├─ TeamsISO.App.WinUI.csproj
│ ├─ Program.cs ← custom Main with Bootstrap
│ ├─ App.xaml + App.xaml.cs
│ ├─ Assets/ ← Inter, JetBrainsMono, dragon-mark
│ ├─ Themes/
│ │ ├─ Tokens.xaml ← ThemeDictionary (Dark + Light)
│ │ └─ Controls.xaml ← Button hierarchy + type ramp
│ ├─ Services/ThemeManager.cs ← theme preference + brand+OS sync
│ ├─ Models/MockParticipant.cs ← interim until VM wires
│ └─ Views/
│ ├─ MainWindow.xaml + .cs ← redesigned per shape brief
│ ├─ SettingsDrawer.xaml + .cs ← slide-in right drawer
│ ├─ HelpDialog.xaml + .cs ← keyboard shortcut cheat sheet
│ ├─ AboutDialog.xaml + .cs ← brand mark + logs / recordings shortcuts
│ └─ OnboardingDialog.xaml + .cs ← three-step first-launch
├─ TeamsISO.sln ← updated
└─ TeamsISO.Windows.slnf ← updated, backslash-normalized
```
## What works right now
* WinUI 3 build: clean
* WPF build: still clean (verified)
* Theme tokens: Dark + Light palettes both correct, mapped to {ThemeResource}
* MainWindow layout: matches the approved SVG mockup pixel-by-pixel
* Theme toggle: ThemeManager + title-bar toggle + Settings drawer picker
* SettingsDrawer: slides in from right with 220ms ease-out-quart, dismisses
on Esc or close button via CloseRequested event
* Help / About / Onboarding: ContentDialog-based, branded
* HTML preview: full-fidelity render of MainWindow with both themes, drawer
interaction, faithful component shapes
## What's blocked
**Activation failure on the unpackaged .exe.** Diagnostic summary:
* `dotnet --info` shows .NET 8.0.301 SDK + 8.0.6/8.0.8/8.0.18 runtimes for
both NETCore.App and WindowsDesktop.App
* `Get-AppxPackage Microsoft.WindowsAppRuntime.*` confirms
Microsoft.WindowsAppRuntime.1.6 (6000.519.329.0) is installed
* `dotnet build -c Debug` produces TeamsISO.exe in
`src/TeamsISO.App.WinUI/bin/Debug/net8.0-windows10.0.19041.0/win-x64/`
* The .exe is x64 (PE machine 0x8664 confirmed)
* Native runtime files (Microsoft.WindowsAppRuntime.Bootstrap.dll,
WebView2Loader.dll) are flattened to the output dir alongside the .exe
* Launching the .exe results in a Windows error dialog
"TeamsISO.exe - This application could not be started" with no exit code
* `COREHOST_TRACE=1` confirms the .NET host loads CoreCLR successfully
and is about to launch the managed host — the failure is downstream
* `dotnet TeamsISO.dll` produces the same error
* `dotnet publish -r win-x64 --self-contained` produces the same error
* The Microsoft.WindowsDesktop.App entry got stripped from runtimeconfig.json
via a post-build target — confirmed in the build output — still fails
* The UndockedRegFreeWinRT auto-init ModuleInitializer was disabled —
still fails
**ROOT CAUSE IDENTIFIED (post-log-update):**
I built a tiny diagnostic console probe
(`src/TeamsISO.App.WinUI.Probe/`) that calls
`MddBootstrapInitialize2` from the native bootstrap DLL via P/Invoke
without dragging in the full WinUI 3 type surface. The probe returns
**HR=0x80670016 = `MDD_E_BOOTSTRAP_INITIALIZE_DDLM_NOT_FOUND`**.
Translation: the framework package (Microsoft.WindowsAppRuntime.1.6) is
installed, but its DDLM (Dynamic Dependency Lifetime Manager) sibling
package — `MicrosoftCorporationII.WinAppRuntime.Main.1.6` — is NOT.
Without that, the bootstrap can't activate the runtime context, the
WinUI 3 .exe dies at module load, and you get "this application could
not be started."
Looking at `Get-AppxPackage`, this machine has Main.1.5 (5001.373) and
Main.1.8 (8000.836) installed, but NO Main.1.6.
**Three fixes, pick one:**
1. **Install the 1.6 DDLM** redistributable. Download
`Microsoft.WindowsAppRuntime.1.6` from
https://aka.ms/windowsappsdk/1.6/latest/windowsappruntimeinstall-x64.exe
and run it. After it installs, `Get-AppxPackage MicrosoftCorporationII.WinAppRuntime.Main.1.6`
should return a row.
2. **Switch the .csproj to WindowsAppSDK 1.8** (the package version
would be `Microsoft.WindowsAppSDK` 1.8.260508005, and the major.minor
in `Program.cs` becomes `0x00010008`). 1.8 IS fully installed on
this machine.
3. **Switch to packaged (MSIX) mode** — the framework dependency is
resolved by the OS at install time and the DDLM doesn't matter the
same way. Means giving up the existing MSI installer path for now.
Option 2 is the fastest. Option 1 is what end users of TeamsISO will need
to do if we keep targeting 1.6 LTS.
To reproduce the diagnosis from scratch:
cd src/TeamsISO.App.WinUI.Probe
dotnet build
dotnet bin/Debug/net8.0-windows/win-x64/TeamsISO.App.WinUI.Probe.dll
## What I did NOT do
* Touch the WPF host. Your running build is intact. The May 2026 batch
ships as-is.
* Touch Teams orchestration. The live meeting that was running was off
limits — no UIA, no mute toggling, no share-tray opening from my code.
* Migrate view-models or wire the engine into the WinUI host. Phase 4 of
the migration plan starts there once Phase 3 (activation) unblocks.
* Migrate the DataGrid (Phase 5). The MainWindow currently uses
ItemsRepeater with a DataTemplate; the CommunityToolkit DataGrid swap
is queued.
* Migrate Notes / Preview / Presets windows (Phase 6 remainder).
* Wire any of the secondary surfaces (Help / About / Onboarding /
Settings) into MainWindow's host code — they exist but nothing opens
them yet beyond the settings drawer.
## Suggested first session tomorrow
1. **Look at the screenshots**: `docs/preview/winui3-mainwindow-light.png`
and `docs/preview/winui3-mainwindow-dark.png` — proof shots of the
live .exe. If the design is right, the rest is execution.
2. **Run it yourself**: from a fresh shell,
`dotnet build src/TeamsISO.App.WinUI` then run the .exe at
`src/TeamsISO.App.WinUI/bin/Debug/net8.0-windows10.0.19041.0/win-x64/TeamsISO.exe`.
The redesigned shell should appear at 1280×780.
3. **Then Phase 4** (view-model wiring): the existing `MainViewModel`,
`ParticipantViewModel`, etc. in `src/TeamsISO.App/ViewModels/` use
WPF's `System.Windows.Threading.Dispatcher`. Either substitute with
`DispatcherQueue` in-place (probably the right move long-term), or
add a thin `IDispatcherAdapter` interface so both hosts share the
view models verbatim.
4. **Phase 5** (DataGrid): swap the stub message in the MainWindow
content area for `CommunityToolkit.WinUI.UI.Controls.DataGrid`
bound to `MainViewModel.Participants`. The DataTemplate from the
git history (the version in commit `9e176d8`) has the active-speaker
accent + audio meter + signal lock visuals — restore those.
5. **Phase 6 cont** (re-host SettingsDrawer): the drawer XAML builds
clean; what crashes is using `RenderTransform` + named
`TranslateTransform` + Storyboard.TargetName binding. Try
`Translation` via `ElementCompositionPreview.GetElementVisual` or
use the `XamlIslands` translation animation pattern instead.
6. **Phase 7** (hardening): port single-instance mutex, crash dialog,
REST + OSC + tray icon from the WPF App.xaml.cs.
## Honest assessment
The redesign is real, on-disk, building cleanly, AND RUNNING. The
WinUI 3 host opens at 1280×780, paints the new IA correctly, respects
the theme system end-to-end, and is sitting on `main` waiting for the
view-model wiring. The diagnostic probe (`TeamsISO.App.WinUI.Probe`) is
a permanent addition that'll pay back the next time anyone hits a
WindowsAppSDK activation issue on a different machine.
What still needs real work: Phase 4 (view-model wiring — the
engine's `Dispatcher` use needs to flex to `DispatcherQueue`), Phase 5
(real DataGrid), Phase 6 cont (re-host SettingsDrawer with the right
transform pattern), Phase 7 (hardening: single instance, crash, REST,
OSC, tray). None of these are blocked anymore — they're all execution
work.
The biggest risk to the v1.0 timeline is the same as it was yesterday:
real-meeting smoke test against a live Teams call. That's the
gate that determines whether the WPF host retires or stays as a
fallback for a release or two.
— end of log
— Claude, 2026-05-13 ~12:45am

View file

@ -1,799 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>TeamsISO — redesigned MainWindow preview</title>
<style>
:root {
/* Dark palette — mirrors src/TeamsISO.App.WinUI/Themes/Tokens.xaml */
--bg-canvas: #0a0a0a;
--bg-rail: #080808;
--bg-surface: #141416;
--bg-elevated: #1c1c1f;
--bg-hover: #26272b;
--bg-active: #33343a;
--border-subtle: #26272b;
--border-strong: #3a3b40;
--fg-primary: #f4f4f6;
--fg-secondary: #a3a4aa;
--fg-tertiary: #6b6c72;
--fg-disabled: #404145;
--fg-on-accent: #0a0a0a;
--accent-cyan-surface: #97edf0;
--accent-cyan-text: #97edf0;
--accent-cyan-hover: #b5f2f4;
--accent-cyan-muted: #1b3537;
--accent-coral: #fb819c;
--accent-coral-bg: #3a1922;
--status-live: #4ade80;
--status-live-bg: #13261a;
--status-warn: #fbbf24;
--status-warn-bg: #3a2e12;
--shadow-drawer: rgba(0,0,0,0.55);
}
html[data-theme="light"] {
--bg-canvas: #fafafb;
--bg-rail: #f0f1f3;
--bg-surface: #ffffff;
--bg-elevated: #ffffff;
--bg-hover: #eceef1;
--bg-active: #e0e3e7;
--border-subtle: #e5e7eb;
--border-strong: #d1d5da;
--fg-primary: #0a0a0a;
--fg-secondary: #4a4b50;
--fg-tertiary: #71747a;
--fg-disabled: #b3b6bc;
--fg-on-accent: #0a0a0a;
--accent-cyan-surface: #97edf0;
--accent-cyan-text: #0e7c82;
--accent-cyan-hover: #0890a0;
--accent-cyan-muted: #e6f8f9;
--accent-coral: #d43e5c;
--accent-coral-bg: #fdecf0;
--status-live: #15803d;
--status-live-bg: #dcfce7;
--status-warn: #b45309;
--status-warn-bg: #fef3c7;
--shadow-drawer: rgba(0,0,0,0.15);
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: #1a1a1c;
color: var(--fg-primary);
font-family: 'Inter', -apple-system, system-ui, 'Segoe UI Variable Display', 'Segoe UI', sans-serif;
min-height: 100vh;
}
html[data-theme="light"] body { background: #e8e9eb; }
.preview-shell {
max-width: 1304px;
margin: 24px auto;
padding: 0 12px;
}
.preview-banner {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; margin-bottom: 16px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 8px;
color: var(--fg-secondary);
font-size: 12px;
}
.preview-banner strong { color: var(--fg-primary); font-weight: 600; }
.preview-banner-actions { display: flex; gap: 8px; }
.preview-banner-actions button {
background: transparent;
border: 1px solid var(--border-strong);
color: var(--fg-primary);
padding: 6px 14px;
font-size: 12px; font-weight: 500;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
}
.preview-banner-actions button:hover { border-color: var(--accent-cyan-text); }
.preview-banner-actions .primary {
background: var(--accent-cyan-surface);
border-color: var(--accent-cyan-surface);
color: var(--fg-on-accent);
font-weight: 600;
}
.window {
width: 1280px; height: 780px;
background: var(--bg-canvas);
border: 1px solid var(--border-strong);
border-radius: 8px;
display: grid;
grid-template-columns: 64px 1fr;
overflow: hidden;
color: var(--fg-primary);
box-shadow: 0 16px 60px var(--shadow-drawer);
position: relative;
}
.rail {
background: var(--bg-rail);
border-right: 1px solid var(--border-subtle);
display: flex; flex-direction: column;
padding: 12px 0 12px 0;
}
.rail-top { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.rail-btn {
width: 48px; height: 48px;
margin: 4px 8px;
border-radius: 8px;
border: 0; background: transparent;
color: var(--fg-secondary);
display: flex; align-items: center; justify-content: center;
cursor: pointer;
transition: background 120ms ease-out, color 120ms ease-out;
}
.rail-btn:hover { background: var(--bg-hover); color: var(--accent-cyan-text); }
.rail-brand {
width: 48px; height: 56px;
margin: 0 8px 8px;
}
.rail-brand .mark {
width: 40px; height: 40px;
background: var(--accent-cyan-muted);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: var(--accent-cyan-text);
font-size: 22px; font-weight: 700;
}
.rail-divider {
height: 1px; background: var(--border-subtle);
margin: 4px 14px 12px;
}
.rail-btn.active {
background: var(--accent-cyan-muted);
color: var(--accent-cyan-text);
}
.rail-status-puck {
width: 48px; height: 48px;
margin: 12px 8px;
border-radius: 24px;
background: var(--status-live-bg);
border: 0;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
}
.rail-status-puck .dot {
width: 10px; height: 10px;
background: var(--status-live);
border-radius: 50%;
}
.icon {
width: 20px; height: 20px;
stroke: currentColor;
fill: none;
stroke-width: 1.6;
stroke-linecap: round; stroke-linejoin: round;
}
.content {
display: grid;
grid-template-rows: 44px auto 1fr auto 32px;
min-width: 0;
position: relative;
overflow: hidden;
}
.titlebar {
display: grid;
grid-template-columns: auto 1fr auto auto auto auto;
align-items: center;
background: var(--bg-canvas);
}
.titlebar-app {
display: flex; align-items: center; gap: 12px;
padding: 0 24px;
}
.titlebar-app .name {
font-size: 14px; font-weight: 600;
}
.titlebar-app .version {
font-family: 'JetBrains Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 11px; color: var(--fg-tertiary);
}
.titlebar-pills {
display: flex; gap: 8px;
padding: 0 12px 0 0;
}
.pill {
height: 22px;
border-radius: 999px;
padding: 0 12px;
display: inline-flex; align-items: center; gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
color: var(--fg-secondary);
}
.pill .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--fg-tertiary);
}
.pill.live { background: var(--status-live-bg); border-color: transparent; color: var(--status-live); }
.pill.live .dot { background: var(--status-live); }
.pill.rec { background: var(--accent-coral-bg); border-color: transparent; color: var(--accent-coral); }
.pill.rec .dot { background: var(--accent-coral); }
.titlebar-tool {
width: 46px; height: 32px;
border: 0; background: transparent;
color: var(--fg-primary);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.titlebar-tool:hover { background: var(--bg-hover); }
.titlebar-tool.close:hover { background: #c42b1c; color: white; }
.section-header {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
padding: 18px 32px 12px;
gap: 12px;
}
.section-title {
display: flex; align-items: center; gap: 12px;
}
.display-title {
font-size: 22px; font-weight: 600; letter-spacing: -0.01em;
color: var(--fg-primary);
}
.count-badge {
height: 22px; padding: 0 12px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 999px;
display: inline-flex; align-items: center;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--fg-secondary);
}
.section-actions {
display: flex; gap: 8px; align-items: center;
}
.input {
width: 200px; height: 34px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
color: var(--fg-primary);
border-radius: 8px;
padding: 0 12px;
font-family: inherit; font-size: 13px;
outline: none;
}
.input:focus { border-color: var(--accent-cyan-text); }
.input::placeholder { color: var(--fg-tertiary); }
.btn {
height: 34px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--border-strong);
background: transparent;
color: var(--fg-primary);
font-family: inherit; font-size: 12px; font-weight: 500;
cursor: pointer;
transition: border-color 120ms ease-out, background 120ms ease-out;
}
.btn:hover { border-color: var(--accent-cyan-text); background: var(--bg-hover); }
.btn.primary {
background: var(--accent-cyan-surface);
border-color: var(--accent-cyan-surface);
color: var(--fg-on-accent);
font-weight: 600;
}
.btn.primary:hover {
background: var(--accent-cyan-hover);
border-color: var(--accent-cyan-hover);
}
.btn.destructive {
color: var(--accent-coral);
border-color: var(--accent-coral);
}
.table {
padding: 0 32px;
overflow-y: auto;
min-height: 0;
}
.table-head {
display: grid;
grid-template-columns: 56px 2fr 1fr 1.2fr 1.5fr auto;
align-items: center;
height: 36px;
border-bottom: 1px solid var(--border-subtle);
color: var(--fg-tertiary);
font-size: 11px; font-weight: 500;
letter-spacing: 0.08em; text-transform: uppercase;
padding-right: 12px;
}
.table-head > * { padding: 0 4px; }
.row {
display: grid;
grid-template-columns: 56px 2fr 1fr 1.2fr 1.5fr auto;
align-items: center;
height: 64px;
border-bottom: 1px solid var(--border-subtle);
padding-right: 12px;
position: relative;
transition: background 120ms ease-out;
}
.row:hover { background: var(--bg-hover); }
.row.active-speaker {
background: var(--accent-cyan-muted);
}
.row .left-accent {
position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--accent-cyan-text);
display: none;
}
.row.active-speaker .left-accent { display: block; }
.row-avatar {
width: 56px;
display: flex; align-items: center; justify-content: center;
}
.avatar {
width: 36px; height: 36px;
border-radius: 50%;
background: var(--bg-active);
color: var(--fg-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 600;
}
.row.active-speaker .avatar {
background: var(--accent-cyan-muted);
color: var(--accent-cyan-text);
}
.row-name { line-height: 1.3; }
.row-name .name {
font-size: 14px; font-weight: 500;
margin-bottom: 2px;
}
.row-name .codec {
font-size: 11px; color: var(--fg-secondary);
}
.row-signal {
display: flex; align-items: center; gap: 8px;
font-family: 'JetBrains Mono', monospace; font-size: 11px;
}
.row-signal .dot {
width: 8px; height: 8px; border-radius: 50%;
}
.row-signal.locked .dot { background: var(--status-live); }
.row-signal.degraded { color: var(--status-warn); }
.row-signal.degraded .dot { background: var(--status-warn); }
.meter { display: flex; align-items: center; gap: 2px; height: 24px; }
.meter span {
width: 4px; border-radius: 2px;
background: var(--bg-active);
}
.meter.active span { background: var(--fg-secondary); }
.row.active-speaker .meter.active span { background: var(--accent-cyan-text); }
.row-output {
font-family: 'JetBrains Mono', monospace; font-size: 13px;
color: var(--fg-primary);
}
.iso-pill {
width: 80px;
padding: 6px 0;
border-radius: 999px;
text-align: center;
font-size: 11px; font-weight: 700;
letter-spacing: 0.06em;
}
.iso-pill.live {
background: var(--status-live-bg);
color: var(--status-live);
border: 1px solid var(--status-live);
}
.iso-pill.off {
background: var(--bg-surface);
color: var(--fg-secondary);
border: 1px solid var(--border-strong);
}
.in-call {
padding: 12px 32px;
border-top: 1px solid var(--border-subtle);
background: var(--bg-canvas);
display: flex; align-items: center; gap: 10px;
}
.in-call .label {
font-size: 11px; font-weight: 500; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--fg-tertiary);
margin-right: 8px;
}
.status-bar {
padding: 0 32px;
border-top: 1px solid var(--border-subtle);
background: var(--bg-canvas);
display: flex; align-items: center; justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
font-size: 11px; color: var(--fg-tertiary);
}
.status-bar .left {
display: flex; align-items: center; gap: 8px;
color: var(--fg-secondary);
}
.status-bar .left .dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--accent-cyan-text);
}
/* Settings drawer */
.drawer {
position: absolute;
top: 44px;
right: 0;
bottom: 0;
width: 400px;
background: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
flex-direction: column;
z-index: 5;
display: none;
}
.drawer.open {
display: flex;
animation: drawer-slide-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes drawer-slide-in {
from { transform: translateX(420px); }
to { transform: translateX(0); }
}
.drawer-head {
height: 56px;
padding: 0 12px 0 20px;
border-bottom: 1px solid var(--border-subtle);
display: flex; align-items: center; justify-content: space-between;
}
.drawer-head .title {
font-size: 18px; font-weight: 600;
}
.drawer-tabs {
display: flex; gap: 6px;
padding: 12px 20px 0;
border-bottom: 1px solid var(--border-subtle);
}
.drawer-tab {
padding: 8px 12px;
border: 0; background: transparent;
color: var(--fg-tertiary);
font-family: inherit; font-size: 12px; font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.drawer-tab.active {
color: var(--fg-primary);
border-bottom-color: var(--accent-cyan-text);
}
.drawer-body {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.drawer-body h3 {
font-size: 14px; font-weight: 600;
margin: 0 0 12px 0;
color: var(--fg-primary);
}
.drawer-body p {
font-size: 12px;
color: var(--fg-secondary);
margin: 0 0 16px 0;
line-height: 1.5;
}
.theme-picker { display: flex; gap: 8px; margin-bottom: 16px; }
.theme-pick-btn {
flex: 1;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid var(--border-strong);
background: transparent;
color: var(--fg-primary);
font-family: inherit; font-size: 13px; font-weight: 500;
cursor: pointer;
text-align: left;
}
.theme-pick-btn.active {
border-color: var(--accent-cyan-text);
background: var(--accent-cyan-muted);
}
.accent-swatches { display: flex; gap: 12px; flex-wrap: wrap; }
.swatch {
display: flex; flex-direction: column; gap: 6px;
text-align: center;
}
.swatch .chip {
width: 80px; height: 32px;
border-radius: 6px;
}
.swatch .label {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; color: var(--fg-tertiary);
letter-spacing: 0.06em; text-transform: uppercase;
}
.drawer-row {
display: grid;
grid-template-columns: 1fr auto;
padding: 6px 0;
border-bottom: 1px solid var(--border-subtle);
font-size: 13px;
}
.drawer-row .v {
font-family: 'JetBrains Mono', monospace;
color: var(--fg-secondary);
}
.drawer-foot {
padding: 12px 16px;
border-top: 1px solid var(--border-subtle);
display: flex; justify-content: flex-end; gap: 8px;
}
</style>
</head>
<body>
<div class="preview-shell">
<div class="preview-banner">
<div>
<strong>TeamsISO redesign — interactive preview</strong>
&nbsp;The same XAML that's in <code>src/TeamsISO.App.WinUI/Views/MainWindow.xaml</code>, rendered as HTML so you can see and toggle it before the WinUI 3 .exe activation issue is resolved.
</div>
<div class="preview-banner-actions">
<button id="open-drawer">Open settings</button>
<button id="toggle-theme" class="primary">Toggle dark / light</button>
</div>
</div>
<div class="window">
<!-- RAIL -->
<div class="rail">
<div class="rail-top">
<button class="rail-btn rail-brand" title="About TeamsISO">
<div class="mark">W</div>
</button>
<div class="rail-divider"></div>
<button class="rail-btn active" title="Participants">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="9" r="3.2"/><path d="M5 19c0-3.5 3.1-6 7-6s7 2.5 7 6"/></svg>
</button>
<button class="rail-btn" title="Launch / surface Teams">
<svg class="icon" viewBox="0 0 24 24"><rect x="3" y="7" width="13" height="10" rx="2"/><path d="M16 11l5-3v8l-5-3z"/></svg>
</button>
<button class="rail-btn" title="Hide / show Teams windows">
<svg class="icon" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="rail-btn" id="rail-settings" title="Settings">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
</button>
</div>
<button class="rail-status-puck" title="Engine status">
<div class="dot"></div>
</button>
</div>
<!-- CONTENT -->
<div class="content">
<!-- Title bar -->
<div class="titlebar">
<div class="titlebar-app">
<span class="name">TeamsISO</span>
<span class="version">v1.0.0-alpha</span>
</div>
<div></div>
<div class="titlebar-pills">
<div class="pill live"><div class="dot"></div>live · 00:14:32</div>
<div class="pill rec"><div class="dot"></div>rec 3 · 00:11:08</div>
<div class="pill">482 GB free</div>
</div>
<button class="titlebar-tool" id="titlebar-theme" title="Theme">
<svg class="icon" viewBox="0 0 24 24" id="theme-icon-mark"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
<button class="titlebar-tool" title="Minimize">
<svg class="icon" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="titlebar-tool" title="Maximize">
<svg class="icon" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14"/></svg>
</button>
<button class="titlebar-tool close" title="Close">
<svg class="icon" viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
</div>
<!-- Section header -->
<div class="section-header">
<div class="section-title">
<span class="display-title">Participants</span>
<span class="count-badge">4</span>
</div>
<div></div>
<div class="section-actions">
<input class="input" placeholder="Filter participants"/>
<button class="btn">Refresh</button>
<button class="btn">Presets</button>
<button class="btn primary">Enable all online</button>
</div>
</div>
<!-- Table -->
<div class="table">
<div class="table-head">
<div></div>
<div>Participant</div>
<div>Signal</div>
<div>Audio</div>
<div>Output name</div>
<div>ISO</div>
</div>
<div class="row active-speaker">
<div class="left-accent"></div>
<div class="row-avatar"><div class="avatar">MA</div></div>
<div class="row-name"><div class="name">Maya Rodriguez</div><div class="codec">MS Teams · 1920×1080 · 30fps</div></div>
<div class="row-signal locked"><div class="dot"></div>locked</div>
<div>
<div class="meter active">
<span style="height:24px"></span>
<span style="height:20px"></span>
<span style="height:28px"></span>
<span style="height:18px"></span>
<span style="height:12px"></span>
<span style="height:22px"></span>
<span style="height:8px"></span>
<span style="height:14px"></span>
<span style="height:6px"></span>
</div>
</div>
<div class="row-output">TEAMSISO_maya</div>
<div><div class="iso-pill live">LIVE</div></div>
</div>
<div class="row">
<div class="row-avatar"><div class="avatar">DC</div></div>
<div class="row-name"><div class="name">Daniel Chen</div><div class="codec">MS Teams · 1280×720 · 30fps</div></div>
<div class="row-signal locked"><div class="dot"></div>locked</div>
<div>
<div class="meter active">
<span style="height:10px"></span>
<span style="height:14px"></span>
<span style="height:8px"></span>
<span style="height:12px"></span>
<span style="height:6px"></span>
<span style="height:9px"></span>
<span style="height:4px"></span>
<span style="height:3px"></span>
<span style="height:2px"></span>
</div>
</div>
<div class="row-output">TEAMSISO_daniel</div>
<div><div class="iso-pill live">LIVE</div></div>
</div>
<div class="row">
<div class="row-avatar"><div class="avatar">AK</div></div>
<div class="row-name"><div class="name">Aïcha Koné</div><div class="codec">MS Teams · 1920×1080 · 30fps</div></div>
<div class="row-signal degraded"><div class="dot"></div>degraded</div>
<div>
<div class="meter">
<span style="height:3px"></span>
<span style="height:4px"></span>
<span style="height:3px"></span>
<span style="height:2px"></span>
</div>
</div>
<div class="row-output" style="color:var(--fg-secondary)">TEAMSISO_aicha</div>
<div><div class="iso-pill off">OFF</div></div>
</div>
<div class="row">
<div class="row-avatar"><div class="avatar">SP</div></div>
<div class="row-name"><div class="name">Sam Park</div><div class="codec">MS Teams · 1920×1080 · 30fps</div></div>
<div class="row-signal locked"><div class="dot"></div>locked</div>
<div>
<div class="meter active">
<span style="height:8px"></span>
<span style="height:12px"></span>
<span style="height:16px"></span>
<span style="height:7px"></span>
<span style="height:5px"></span>
<span style="height:3px"></span>
</div>
</div>
<div class="row-output">TEAMSISO_sam</div>
<div><div class="iso-pill live">LIVE</div></div>
</div>
</div>
<!-- In-call control -->
<div class="in-call">
<span class="label">In-call</span>
<button class="btn destructive">⊘ Muted</button>
<button class="btn">⌗ Camera</button>
<button class="btn">⇪ Share</button>
<button class="btn">▷ Marker</button>
<button class="btn destructive">Leave</button>
<button class="btn" style="width:36px;padding:0;"></button>
</div>
<!-- Status bar -->
<div class="status-bar">
<div class="left">
<div class="dot"></div>
<span>control surface · 127.0.0.1:9755</span>
</div>
<div>F1 help · Ctrl+M marker · Ctrl+Shift+S panic · Ctrl+K command palette</div>
</div>
<!-- Settings drawer -->
<div class="drawer" id="drawer">
<div class="drawer-head">
<div class="title">Settings</div>
<button class="titlebar-tool" id="drawer-close" title="Close (Esc)">
<svg class="icon" viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
</div>
<div class="drawer-tabs">
<button class="drawer-tab active">Appearance</button>
<button class="drawer-tab">Routing</button>
<button class="drawer-tab">Display</button>
<button class="drawer-tab">Control</button>
<button class="drawer-tab">Advanced</button>
</div>
<div class="drawer-body">
<h3>Appearance</h3>
<p>Dark is the default for the 1:50am operator scene; light is for daytime production. System follows the Windows app-mode preference.</p>
<div class="theme-picker">
<button class="theme-pick-btn" data-theme="dark">Dark</button>
<button class="theme-pick-btn active" data-theme="dark">System</button>
<button class="theme-pick-btn" data-theme="light">Light</button>
</div>
<h3>Accent peek</h3>
<p>These accents work in both themes. Cyan stays bright as a surface fill (text on top is near-black regardless). For inline text on light, the palette substitutes a darker cyan automatically.</p>
<div class="accent-swatches">
<div class="swatch"><div class="chip" style="background:var(--accent-cyan-surface)"></div><div class="label">Cyan</div></div>
<div class="swatch"><div class="chip" style="background:var(--accent-coral)"></div><div class="label">Coral</div></div>
<div class="swatch"><div class="chip" style="background:var(--status-live)"></div><div class="label">Live</div></div>
<div class="swatch"><div class="chip" style="background:var(--status-warn)"></div><div class="label">Warn</div></div>
</div>
</div>
<div class="drawer-foot">
<button class="btn">Reset to defaults</button>
<button class="btn primary">Apply</button>
</div>
</div>
</div>
</div>
</div>
<script>
const html = document.documentElement;
const themeIcon = document.getElementById('theme-icon-mark');
const sunPath = 'M12 1v2 M12 21v2 M4.2 4.2l1.4 1.4 M18.4 18.4l1.4 1.4 M1 12h2 M21 12h2 M4.2 19.8l1.4-1.4 M18.4 5.6l1.4-1.4 M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8';
const moonPath = 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z';
function applyTheme(t) {
html.dataset.theme = t;
themeIcon.setAttribute('d', t === 'light' ? sunPath : moonPath);
themeIcon.parentElement.innerHTML = `<svg class="icon" viewBox="0 0 24 24" id="theme-icon-mark"><path d="${t === 'light' ? sunPath : moonPath}"/></svg>`;
}
function toggle() {
applyTheme(html.dataset.theme === 'light' ? 'dark' : 'light');
}
document.getElementById('toggle-theme').addEventListener('click', toggle);
document.getElementById('titlebar-theme').addEventListener('click', toggle);
const drawer = document.getElementById('drawer');
document.getElementById('rail-settings').addEventListener('click', () => drawer.classList.add('open'));
document.getElementById('open-drawer').addEventListener('click', () => drawer.classList.add('open'));
document.getElementById('drawer-close').addEventListener('click', () => drawer.classList.remove('open'));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') drawer.classList.remove('open'); });
applyTheme('dark');
</script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

View file

@ -1,194 +0,0 @@
# TeamsISO v2 — Studio Terminal (approved shape brief)
**Date approved:** 2026-05-13
**Approver:** Zac (operator + product owner)
**Host:** WPF .NET 8 (`src/TeamsISO.App/`). WinUI 3 rebuild is abandoned.
**Predecessor:** the WPF rollback at `1d1ce6a` (recording axed, settings pane tab fix, settings button wired).
## Why this redesign
The v1 GUI failed the "AI made that" test. Quote from the operator: "its cluttered, screams that AI made it - and relatively inefficient to navigate." The PRODUCT.md anti-references — card-grid-of-icons, always-visible side panel, footer-as-theatre — all describe the current build. v2 commits to a different aesthetic register entirely.
## Aesthetic register
**Broadcast-engineering instrument.** Not a SaaS dashboard. Not Material. Not Fluent default.
Reference proximity: Linear's keyboard-first density × Avid S6 console legibility × Blackmagic ATEM's information hierarchy. The operator mental model is "I'm sitting at an audio mixer; every region has a job, no region is theatre."
## What goes away
- The 72px left rail (no actual navigation — there's only one screen)
- The 380px always-visible settings pane (settings change rarely, shouldn't claim permanent real estate)
- The 6-column footer status row (theatre, not information)
- The custom chromeless title-bar caption buttons (look worse than system chrome, break on DPI scaling)
- The "by Wild Dragon" pill and the always-visible "TeamsISO" wordmark as decorative chrome
- The in-call control bar as a permanent strip (only relevant in-call; should appear conditionally)
- The seven identical ghost buttons in the in-call bar (textbook card-grid anti-pattern)
## What replaces it
```
┌─ system Windows title bar [_ □ ✕] ─────────────────────┐
│ 🐉 TeamsISO [⌘K] [☾] [⚙] │ 32px header — mark + wordmark left, 3 icons right
├────────────────────────────────────────────────────────┤
│ ● 02:14:32 PART 4 · LIVE 2 DISK 482g CTRL :9755 │ transport strip — single mono line, replaces footer
├────────────────────────────────────────────────────────┤
│ │
│ ▮ alice ▮▮▮▮ t:5ms alice [LIVE] │
│ ▯ bob ▮▮ t:8ms bob [— OFF]│ participants table = the canvas
│ ▮ carlos ▮▮▮▮▮ t:9ms carlos [LIVE] │ (cyan-tinted row bg = active speaker)
│ ▮ guest 4 -- NO SIG guest_4 [ERROR]│
│ │
├────────────────────────────────────────────────────────┤
│ IN CALL · Daily standup [mute] [cam] [leave] │ conditional — only renders when in call
└────────────────────────────────────────────────────────┘
```
### Header (32px)
Left: Wild Dragon mark (~20px) + "TeamsISO" wordmark in Inter 13 Medium. Click on mark opens About.
Right: three icon buttons.
- `⌘K` (Tabler `ti-command`) — opens command palette (also Ctrl+K, Ctrl+P shortcut)
- `☾` / `☀` (Tabler `ti-moon` / `ti-sun`) — cycles theme dark ↔ light. Tooltip "Theme (System / Dark / Light)" — long-press could open the tri-state, but for v2 just a one-click cycle.
- `⚙` (Tabler `ti-settings`) — opens settings drawer
That's all the chrome. No nav rail because there's nothing to navigate to.
### Transport strip
Single horizontal line. Mono type (JetBrains Mono 12). Replaces the entire footer.
Fields:
- `● 02:14:32` — green dot + session timer when at least one ISO is live; both hidden otherwise
- `PART 4 · LIVE 2` — participant count and live-ISO count; "PART" / "LIVE" in Inter 11 SemiBold UPPER tracking 0.06em, numbers in mono
- `DISK 482g` — free disk space on the working volume; coral text if <10GB, hidden if no relevant volume is configured
- `CTRL :9755` — control surface bind; cyan text when active, hidden when off
No icons. No badges. No backgrounds. Just typed status — a console heads-up display.
### Participants table — the canvas
Five columns:
| # | Width | Content | Type |
|---|---|---|---|
| 1 | 24px | State LED — 8×8 filled cyan/coral or hollow neutral | hard-edged square, no rounding |
| 2 | * | Name (Inter 13/Medium) + codec/latency caption (Mono 11/Regular, tertiary fg) | "Alice Wong" / "NDIV5 · t:5ms" |
| 3 | 110px | Audio meter — 5 vertical bars, instantaneous level | hard-edged, cyan when LIVE, neutral when OFF |
| 4 | 130px | Output name | Mono 12 |
| 5 | 100px | ISO toggle pill | LIVE = cyan fill / OFF = hollow neutral / ERROR = coral outline |
Row height: 52px (was 56).
Active speaker: full-row background tint `bg.active-speaker` (cyan-tinted muted neutral). NOT a left-edge stripe — that trips the impeccable "side-stripe border" ban.
Each row reacts to:
- Click anywhere → focuses the row, keyboard-actions apply
- Click the pill → toggle ISO
- Right-click → context menu (preview, custom name, copy NDI source name, save snapshot)
- Hover → reveals a kebab affordance in column 5 right edge for less-frequent actions
### Conditional meeting bar
Renders below the table only when `TeamsControlBridge.DetectCallState().IsInCall == true`. Slides up from below on transition (~120ms ease-out-quart on `RenderTransform.Y` + `Opacity`).
Content: `IN CALL` label (Inter 11 SemiBold UPPER, cyan accent) + meeting title (Mono 12, truncated with ellipsis) + three buttons right-aligned (Mute / Cam / Leave). Share and Notes do NOT live here — they move to ⌘K, where they're invocable any time without the bar fighting for attention.
Width matches the table — not full-bleed; respects the page padding.
### Ctrl+K command palette
The redesign's navigation move. Replaces ~80% of what's in the v1 rail + tabbed settings.
Behavior:
- `Ctrl+K` (also `Ctrl+P`) opens a centered floating window over the main shell, 560×360px
- Search input at the top, results list below
- Empty input → frequent + recent commands
- Typing → fuzzy-matches across command label + category + keywords
- ↑/↓ navigates, Enter invokes, Esc closes
Command categories (each command has icon, label, optional value preview, optional shortcut hint):
- **Quick** — Enable all online, Stop all ISOs, Refresh discovery, Drop snapshot of all
- **Teams** — Launch Teams, Hide / show Teams windows, Mute, Toggle camera, Open share, Leave call
- **Presets** — Apply <preset name>… (one row per saved preset), Save current as preset, Manage presets
- **Output** — Framerate 24 / 30 / 60, Resolution 1080p / 720p, Aspect Pillarbox / Letterbox / Stretch
- **Network** — Apply transcoder topology, Restore default NDI groups, Edit output name template
- **App** — Theme dark / light / system, Open settings, About TeamsISO, Help (F1)
This is the keyboard-first surface broadcasters with Stream Decks already mentally use.
### Settings — slide-over drawer
Triggered from the header gear icon, or from `Open settings` in the palette, or hotkey `,` (comma).
- 420px wide, slides in from the right
- 40% canvas scrim behind
- Three tabs: **OUTPUT** (framerate / resolution / aspect / audio + Reset to defaults), **NETWORK** (discovery / output groups + Apply transcoder topology + Restore defaults + output name template), **APP** (theme tri-state, minimize to tray, sort order, Launch Teams on startup, Auto-hide Teams windows)
- Apply Changes button pinned to drawer footer; Esc dismisses; click outside the drawer dismisses
DISPLAY tab from v1 gets renamed APP and absorbs the theme tri-state.
### Empty states
- No participants yet: a single centered mono sentence, "no ndi sources yet — open teams and start a meeting", and one tertiary button "Refresh discovery (Ctrl+R)". No illustration, no mascot.
- Not in a call: meeting bar simply doesn't render. No placeholder.
- Discovery degraded: amber dot in transport strip's session timer position, mono text "NDI discovery — restarting". No banner.
## Color, theme, motion
**Color strategy:** Restrained (impeccable product default). Cyan accent earns its place — reserved for LIVE state, focus ring, active speaker tint. Coral reserved for destructive + error. Status amber for warnings. Green NOT used (would compete with cyan for "ok / live" semantics).
**Theme default:** Follow Windows. Theme persists per-operator via `UIPreferences.Theme`. Implementation: split `WildDragonTheme.xaml` into a single style + token-shape file plus two color-only ResourceDictionary files (`Theme.Dark.xaml`, `Theme.Light.xaml`). At runtime `ThemeManager` swaps the merged dictionary entry. WPF analog of WinUI's `ThemeDictionary`.
**Motion:**
- 120ms `cubic-bezier(0.16, 1, 0.3, 1)` (ease-out-quart) on the meeting bar slide-in/out
- 200ms ease-out on the drawer slide
- 180ms cross-fade on theme swap
- 90ms on focus + hover transitions
- No bounce, no elastic, no spring overshoots. Animate `RenderTransform` and `Opacity` only — never layout properties.
## Typography commitments
| Token | Family | Size | Weight | Used for |
|---|---|---|---|---|
| `text.timer` | JetBrains Mono | 14 | Medium | Session timer in transport strip — instrument-grade |
| `text.caption` | Inter | 11 | SemiBold (600) | UPPER + tracking 0.06em — transport-strip labels, "IN CALL", "SPEAKING" |
| `text.display` | Inter | 22 | SemiBold | Settings drawer headings only |
| `text.title` | Inter | 13 | Medium | Wordmark, table column headers |
| `text.body` | Inter | 13 | Regular | Participant display names |
| `text.mono.code` | JetBrains Mono | 12 | Regular | Output names, NDI IDs, meeting title |
| `text.mono.tech` | JetBrains Mono | 11 | Regular | Latency readouts, codec captions, transport-strip values |
## What this is NOT
- Not Fluent-styled. Default Fluent accent integration is generic Windows; TeamsISO is a broadcaster's tool.
- Not minimalism for its own sake. The participants table is *dense*. Density is the broadcaster's virtue.
- Not chromeless. Default system title bar stays. Chromeless windows break embarrassingly at 4K + DPI scaling.
- Not vanity-branded. The Wild Dragon mark sits small in the header as a quality cue, never as decoration.
## Migration path
The view-model surface in `src/TeamsISO.App/ViewModels/` is the contract. The redesign rewrites `MainWindow.xaml` and `Themes/*` but leaves view-models, the engine, the control surface server, and the OSC bridge untouched.
Order of operations (each step builds clean before the next):
1. **Theme split** — Refactor `WildDragonTheme.xaml``Themes/Theme.Tokens.xaml` (styles + key shape) + `Themes/Theme.Dark.xaml` + `Themes/Theme.Light.xaml` (color resources only). Port `ThemeManager` from the deleted WinUI project; wire system app-mode detection via registry (`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme`).
2. **Main window shell** — Replace MainWindow.xaml's outer Grid. Add 32px header, transport strip, full-width content area, conditional meeting bar. Delete the 72px rail, the 380px right pane, the footer.
3. **Participants table redesign** — 5 columns, LED state, instantaneous audio meter, ISO pill.
4. **Settings drawer** — Slide-over from right, dismissable; reuses existing settings view-model.
5. **Command palette**`Ctrl+K` floating window with fuzzy command list.
Each step is a self-contained commit so the v1 build remains shippable at any rollback point.
## Anti-references — explicit on the "AI made that" failure
These are the failure modes the redesign defends against:
- Card-grid-of-icons (the v1 in-call bar's seven identical ghost buttons)
- Always-visible side panel (the v1 380px settings sidebar)
- Decorative chrome (the v1 "by Wild Dragon" pill, the 72px nav rail, the six-column footer)
- Generic Inter at 13 for everything
- Default WPF DataGrid (Excel)
- Custom chromeless title bars that look generic
- Gradient text, glassmorphism, side-stripe borders (impeccable absolute bans)
- "Hero metric + supporting stats + gradient" SaaS dashboards
- Mascots, "Welcome!" copy, illustrated onboarding cards

View file

@ -1,167 +0,0 @@
# TeamsISO Phase B-1 — Pipeline Orchestration Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Implement the engine-side pipeline orchestration on top of the `INdiInterop` test seam from Phase A — `NdiReceiver`, `NdiSender`, `ExponentialBackoff`, `NdiRuntimeProbe`, `IsoPipeline` (lifecycle + restart loop), and `IsoController` (top-level engine API). All testable on Linux against `FakeNdiInterop`. Phase B-2 (real Windows P/Invoke for `INdiInterop` + libyuv `IFrameScaler` + integration tests) follows.
**Architecture:** Pure orchestration. Each `IsoPipeline` wires one `NdiReceiver` → existing `FrameProcessor` → one `NdiSender` via two bounded channels. The pipeline owns a restart loop driven by `ExponentialBackoff`. `IsoController` is the top of the engine — holds the pipeline dictionary, the `ParticipantTracker`, the `ConfigStore`, and exposes the contract the WPF host (Phase C) will bind to.
**Tech Stack:** .NET 8, xUnit, FluentAssertions. No new external dependencies.
**Source spec:** `docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md`
---
## File structure additions
```
src/TeamsISO.Engine/
├── Pipeline/
│ ├── NdiReceiver.cs (NEW)
│ ├── NdiSender.cs (NEW)
│ ├── ExponentialBackoff.cs (NEW)
│ ├── IsoPipeline.cs (NEW)
│ └── IsoPipelineConfig.cs (NEW)
├── Interop/
│ └── NdiRuntimeProbe.cs (NEW)
└── Controller/
├── IIsoController.cs (NEW)
└── IsoController.cs (NEW)
src/tests/TeamsISO.Engine.Tests/
├── Pipeline/NdiReceiverTests.cs (NEW)
├── Pipeline/NdiSenderTests.cs (NEW)
├── Pipeline/ExponentialBackoffTests.cs (NEW)
├── Pipeline/IsoPipelineTests.cs (NEW)
├── Interop/NdiRuntimeProbeTests.cs (NEW)
└── Controller/IsoControllerTests.cs (NEW)
```
---
## Task 1: `NdiReceiver`
Receiver that wraps `INdiInterop.CaptureFrame` and pushes results into a `ChannelWriter<RawFrame>`. Exposes a `CaptureOnce` test seam mirroring `FrameProcessor.ProcessOnceAsync`. `RunAsync` is the production loop with `LongRunning` thread semantics.
TDD assertions:
- `CaptureOnce` writes a captured frame to the output channel; counter increments.
- `CaptureOnce` does nothing on null capture (timeout); counter does not change.
- `RunAsync` honors cancellation and disposes the receiver handle on exit.
Commit: `feat(pipeline): add NdiReceiver with channel-based output`
---
## Task 2: `NdiSender`
Sender that pulls from a `ChannelReader<ProcessedFrame>` and forwards to `INdiInterop.SendFrame`. `SendNextAsync` returns true if a frame was sent; false if the channel completed. `RunAsync` loops until cancellation.
TDD assertions:
- `SendNextAsync` forwards a frame to the interop and increments the sent counter.
- Returns `false` when channel completes.
- `RunAsync` honors cancellation and disposes the sender handle.
Commit: `feat(pipeline): add NdiSender with channel-based input`
---
## Task 3: `ExponentialBackoff`
Pure policy type. Given an attempt count, returns the next delay (1, 2, 4, 8, 16 s, capped at 30 s) and decides whether to give up after N consecutive failures (default 5).
TDD assertions:
- Sequence at attempts 1..5 is 1, 2, 4, 8, 16 seconds.
- `ShouldGiveUp` returns true after the 5th attempt.
- Cap: at attempt 7 the delay is 30 s, not 64.
Commit: `feat(pipeline): add ExponentialBackoff policy`
---
## Task 4: `NdiRuntimeProbe`
Reads the runtime version via `INdiInterop.GetRuntimeVersion()`, compares to an expected value (passed in by the engine for now; a real comparison against the SDK headers is Phase B-2). Returns either `Match` or `Mismatch` with both versions populated. The `IsoController` will surface `EngineAlert.NdiRuntimeMismatch` from a mismatch.
TDD assertions:
- Match when versions equal.
- Mismatch carries detected and expected.
Commit: `feat(interop): add NdiRuntimeProbe with version-mismatch result`
---
## Task 5: `IsoPipeline` core lifecycle
Owns one `NdiReceiver`, one `FrameProcessor`, one `NdiSender`, and the two channels between them. `StartAsync` creates the channels, instantiates the receiver/processor/sender, kicks off the three loops on long-running tasks. `StopAsync` cancels the token, awaits the loops, and disposes everything.
`IsoState` transitions: `Idle``Receiving` (after start) → `Sending` (after first send) → `NoSignal` (handled by FrameProcessor's slate path and exposed via Stats). On exception the loop transitions to `Error`.
The restart loop is in Task 6.
TDD assertions:
- Start transitions Idle → Receiving.
- Stop transitions back to Idle and disposes interop handles.
- Receiver/sender handles are created on Start, disposed on Stop.
Commit: `feat(pipeline): add IsoPipeline core lifecycle`
---
## Task 6: `IsoPipeline` restart loop
Wraps the running pipeline in a supervisory loop that catches unhandled exceptions, applies `ExponentialBackoff`, and either restarts or transitions to `Error` after exhausting retries. State observable updates accordingly.
TDD assertions (using a fault-injecting INdiInterop):
- Pipeline that fails once, then runs cleanly, restarts and ends up Sending.
- Pipeline that fails 5+ consecutive times transitions to Error and stays there.
- Backoff delays are honored (using a fake delay primitive for fast tests).
Commit: `feat(pipeline): add IsoPipeline restart supervisor with backoff`
---
## Task 7: `IIsoController` interface + `IsoController` implementation
The top-of-engine API the WPF host will bind to in Phase C.
Surface:
- `IObservable<IReadOnlyList<Participant>> Participants { get; }`
- `IObservable<EngineAlert> Alerts { get; }`
- `IsoHealthStats GetStats(Guid participantId)`
- `Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken ct)`
- `Task DisableIsoAsync(Guid participantId, CancellationToken ct)`
- `Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken ct)`
Implementation owns: `ParticipantTracker`, `NdiDiscoveryService`, dictionary of `IsoPipeline`, the `ConfigStore`, the runtime probe.
TDD assertions:
- `EnableIsoAsync` creates and starts a pipeline; `DisableIsoAsync` stops and removes it.
- `SetGlobalSettingsAsync` persists via ConfigStore and applies to existing pipelines.
- Discovery events flow through to the participants observable.
- `NdiRuntimeProbe` mismatch surfaces an alert.
Commit: `feat(controller): add IIsoController and IsoController implementation`
---
## Task 8: Wrap-up & milestone tag
- Run full test suite, confirm all green.
- Confirm coverage threshold still ≥80%.
- Update `docs/superpowers/plans/_NEXT.md` to describe Phase B-2 (Windows-only).
- Tag `phase-b-1-complete`.
Commit: `chore: phase-b-1 milestone wrap-up`
Tag: `phase-b-1-complete`
---
## Self-review
**Spec coverage:** Spec §4 components NdiReceiver, NdiSender, IsoPipeline, IsoController — Tasks 1, 2, 5, 6, 7. Spec §6 error handling restart/backoff — Task 6. Spec §6 NDI runtime mismatch — Task 4 + Task 7. ConfigStore integration in IsoController — Task 7.
**Phase B-2 (deferred):** Real `NdiInteropPInvoke` shim, real `LibYuvFrameScaler`, console smoke runner, integration tests against NDI Test Pattern source. All require Windows + NDI runtime so they live in their own plan.
**Type consistency:** All new types reference Phase A types unchanged. `INdiInterop` surface is sufficient — no additions needed.
No issues to fix. Ready to execute.

View file

@ -1,30 +0,0 @@
# TeamsISO Phase B-2 — Real NDI Interop Plan
**Goal:** Production `INdiInterop` implementation in `TeamsISO.Engine.NdiInterop` against NDI SDK 6, a managed BGRA scaler with aspect modes, an NDI version constant, and a `TeamsISO.Console` headless smoke runner that wires up the engine end-to-end. After this phase the engine can drive real Teams NDI streams once run on a Windows box with the NDI runtime installed.
**Architecture:** P/Invoke against `Processing.NDI.Lib.x64.dll`. Frame marshalling translates NDI's `video_frame_v2_t` to/from our managed `RawFrame`/`ProcessedFrame`. Receive in BGRA color space (`NDIlib_recv_color_format_e_BGRX_BGRA`) so the scaler doesn't need to handle UYVY in v1.0. Memory management: every captured frame is freed via `NDIlib_recv_free_video_v2` once we've copied its pixels into a managed buffer.
**Tech Stack:** .NET 8, `System.Runtime.InteropServices`, plain C# scaler (managed BGRA nearest-neighbor; libyuv is a v1.5 perf optimization). The console runner uses the existing `EngineLogging.CreateConsole`.
**Source spec:** `docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md`
## Tasks
1. **NDI native bindings:** `NdiNative.cs` with all `[DllImport]` declarations needed (`initialize`, `destroy`, `find_create_v2/destroy/get_current_sources`, `recv_create_v3/destroy/capture_v3/free_video_v2`, `send_create/destroy/send_video_v2`, `version`). Define `NDIlib_video_frame_v2_t`, `NDIlib_source_t`, `NDIlib_recv_create_v3_t`, `NDIlib_send_create_t` structs with explicit layout.
2. **Handles:** `NdiPInvokeFindHandle`, `NdiPInvokeReceiverHandle`, `NdiPInvokeSenderHandle` deriving from the abstract Phase A handles, owning the unmanaged pointers.
3. **NdiInteropPInvoke:** the production `INdiInterop` implementation. Initializes NDI on construction; destroys on dispose. Marshals between native and managed frame structs. Allocates managed pixel buffers and copies; frees the native frame immediately.
4. **NdiVersion:** a constants class exposing the version string the engine probe compares against.
5. **ManagedNearestNeighborFrameScaler:** managed BGRA scaler with `Pillarbox`, `Letterbox`, `Stretch` aspect modes. Fully unit-tested.
6. **TeamsISO.Console:** a small console host. Constructs `IsoController` against `NdiInteropPInvoke` + `ManagedNearestNeighborFrameScaler`, prints participant updates, listens for `q\n` to quit. Useful for headless validation.
7. **Wire-up tests:** integration scaffold uses `RuntimeInformation.IsOSPlatform(OSPlatform.Windows)` to skip cleanly on non-Windows. Add a smoke integration test that constructs the interop and probes the version.
8. **Wrap-up:** tag `phase-b-2-complete`.
## What this phase intentionally does NOT include
- libyuv-backed scaler (deferred to v1.5 per spec — managed scaler is functionally complete).
- Actual integration test suite running against an NDI Test Pattern source. Those tests need the NDI runtime; they're authored here but stay tagged `requires=ndi` and skip in the Linux CI.
- Audio handling (passthrough video only in this phase; audio support added later if v1.0 needs it before ship).
## Self-review
Spec coverage: §4 NdiReceiver/NdiSender/IsoController already done in B-1; this phase fills in the actual NDI SDK calls under `INdiInterop`. §6 startup preflight via `NdiVersion` + the existing `NdiRuntimeProbe`. §8 console smoke runner is a Phase B-2 deliverable for first end-to-end Windows validation before WPF.

View file

@ -1,43 +0,0 @@
# TeamsISO Phase C — WPF MVVM UI Plan
**Goal:** Operator-facing WPF UI bound to `IIsoController`. Displays the live participant list, lets operators enable/disable per-participant ISO outputs, set the global framerate / resolution / aspect / audio mode, view engine alerts, and see basic system health. Plus a WiX MSI installer and a release CI pipeline.
**Architecture:** MVVM with no third-party MVVM framework — small managed `ObservableObject` and `RelayCommand` helpers. The view models bind directly to `IIsoController`'s observables. UI runs on the WPF dispatcher; observable subscriptions marshal back via a captured `SynchronizationContext`. App.xaml.cs constructs the engine on startup and disposes on exit.
**Tech stack:** WPF on .NET 8, MVVM hand-rolled, no external UI library yet (MaterialDesignThemes can be added in a polish pass).
## File structure additions
```
src/TeamsISO.App/
├── App.xaml / App.xaml.cs (DI bootstrap)
├── MainWindow.xaml / MainWindow.xaml.cs
├── ViewModels/
│ ├── ObservableObject.cs
│ ├── RelayCommand.cs
│ ├── MainViewModel.cs
│ ├── ParticipantViewModel.cs
│ ├── GlobalSettingsViewModel.cs
│ └── AlertBannerViewModel.cs
├── Converters/
│ ├── BoolToVisibilityConverter.cs
│ └── EnumDescriptionConverter.cs
└── TeamsISO.App.csproj
src/TeamsISO.Installer/
└── TeamsISO.Installer.wixproj (MSI installer; v5)
```
## Tasks
1. **MVVM helpers**`ObservableObject` base implementing `INotifyPropertyChanged`; `RelayCommand` and `AsyncRelayCommand`.
2. **GlobalSettingsViewModel** — exposes Framerate, Resolution, Aspect, Audio as bindable selected values; `Apply` command calls `controller.SetGlobalSettingsAsync`.
3. **ParticipantViewModel** — wraps a `Participant`, exposes IsEnabled, CustomOutputName, and current status; `EnableCommand` and `DisableCommand` call the controller.
4. **AlertBannerViewModel** — collects `EngineAlert`s and exposes the most recent one with a "dismiss" command.
5. **MainViewModel** — top-level. Owns the controller. Exposes `ObservableCollection<ParticipantViewModel>`, the settings VM, and the banner VM.
6. **MainWindow.xaml** — DataGrid for participants with toggle column, settings panel docked to the right, alert banner docked top.
7. **Converters** — bool→visibility, enum→display string.
8. **App.xaml.cs** — wires DI: build engine + controller + main view model, set MainWindow's DataContext, dispose on exit.
9. **WiX installer (Phase C-2)** — separate task; can ship after the UI is alive.
Each step ships as its own commit. Tag `phase-c-complete` after MainWindow renders and the controller is bound.

View file

@ -0,0 +1,520 @@
# Dragon-ISO Installer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rebrand the WiX v5 MSI installer from TeamsISO to Dragon-ISO, producing `Dragon-ISO-Setup-1.0.0.0.msi` for end-user download.
**Architecture:** Rename the `.wixproj` file, rewrite `Package.wxs` with Dragon-ISO branding and a simple `WixUI_Minimal` UI (no directory picker), and fix a bug in `release.yml` where the signing step references the wrong executable filename.
**Tech Stack:** WiX Toolset v5, MSBuild, PowerShell; no unit tests (installer files are build-verified by `dotnet build`)
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `installer/TeamsISO.Installer.wixproj` | Rename + rewrite | MSBuild project — output name, publish dir, asset dir |
| `installer/Dragon-ISO.Installer.wixproj` | Created by rename | Same as above, Dragon-ISO branded |
| `installer/Package.wxs` | Rewrite | WiX source — all installer logic, shortcuts, metadata |
| `.forgejo/workflows/release.yml` | Fix line 119 | Fix `Dragon-ISO.exe``DragonISO.exe` (exe filename matches AssemblyName) |
> **Important:** `Dragon-ISO.App.csproj` has `<AssemblyName>DragonISO</AssemblyName>` (no hyphen). The published executable is therefore `DragonISO.exe`, not `Dragon-ISO.exe`. All shortcut targets and signing steps must use `DragonISO.exe`.
---
## Task 1: Rename the .wixproj file
**Files:**
- Rename: `installer/TeamsISO.Installer.wixproj``installer/Dragon-ISO.Installer.wixproj`
- [ ] **Step 1: Rename the file using git mv**
```powershell
cd C:\Users\zacga\source\repos\Dragon-ISO
git mv installer/TeamsISO.Installer.wixproj installer/Dragon-ISO.Installer.wixproj
```
- [ ] **Step 2: Verify the rename**
```powershell
Get-ChildItem installer/
```
Expected: `Dragon-ISO.Installer.wixproj` and `Package.wxs` (no `TeamsISO.Installer.wixproj`)
---
## Task 2: Rewrite Dragon-ISO.Installer.wixproj
**Files:**
- Modify: `installer/Dragon-ISO.Installer.wixproj`
- [ ] **Step 1: Replace the file content entirely**
Write the following to `installer/Dragon-ISO.Installer.wixproj`:
```xml
<Project Sdk="WixToolset.Sdk/5.0.2">
<PropertyGroup>
<OutputType>Package</OutputType>
<OutputName>Dragon-ISO-Setup-$(Version)</OutputName>
<!-- 64-bit MSI; suppresses ICE80 on components in Program Files (x64). -->
<Platform>x64</Platform>
<InstallerPlatform>x64</InstallerPlatform>
<!--
Built artifact location. The installer expects a published build of
Dragon-ISO.App rooted here. CI / local script:
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj
-c Release -r win-x64 --self-contained false
-o $(SolutionDir)publish/Dragon-ISO
-->
<PublishDir>$(MSBuildThisFileDirectory)..\publish\Dragon-ISO\</PublishDir>
<!-- Pass MSBuild values into WiX preprocessor. -->
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\Dragon-ISO.App\Assets\</DefineConstants>
<!-- Code-signing hook (set externally for release builds; left empty for dev). -->
<SignOutput Condition=" '$(SignOutput)' == '' ">false</SignOutput>
</PropertyGroup>
<!--
Reference the WiX UI extension so the MSI shows a friendly progress UI
instead of the silent default.
-->
<ItemGroup>
<PackageReference Include="WixToolset.UI.wixext" Version="5.0.2" />
</ItemGroup>
</Project>
```
- [ ] **Step 2: Verify the file reads back correctly**
```powershell
Get-Content installer/Dragon-ISO.Installer.wixproj | Select-String "OutputName|PublishDir|AssetsDir"
```
Expected output (3 lines):
```
<OutputName>Dragon-ISO-Setup-$(Version)</OutputName>
<PublishDir>$(MSBuildThisFileDirectory)..\publish\Dragon-ISO\</PublishDir>
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\Dragon-ISO.App\Assets\</DefineConstants>
```
---
## Task 3: Rewrite Package.wxs with Dragon-ISO branding
**Files:**
- Modify: `installer/Package.wxs`
Changes from the original TeamsISO version:
- Package Name: "TeamsISO" → "Dragon-ISO"
- SummaryInformation description and keywords updated
- MajorUpgrade error message updated
- Feature Title: "TeamsISO" → "Dragon-ISO"
- UI switched from `WixUI_InstallDir` (shows dir picker) → `WixUI_Minimal` (Welcome → Install → Finish)
- `WIXUI_INSTALLDIR` property removed (not used by WixUI_Minimal)
- ARPHELPLINK URL: teamsiso → dragon-iso
- ARPCOMMENTS: "TeamsISO" → "Dragon-ISO"
- Icon Id: "TeamsISOIcon" → "DragonISOIcon"
- Icon SourceFile: `teamsiso.ico``Dragon-ISO.ico`
- ARPPRODUCTICON value: "TeamsISOIcon" → "DragonISOIcon"
- Added .NET 8 Desktop Runtime detection property
- Install directory Name: "TeamsISO" → "Dragon-ISO"
- Start Menu shortcut Id/Name/Target/Icon updated
- Desktop shortcut Id/Name/Target/Icon updated
- All registry keys: `Software\Wild Dragon\TeamsISO``Software\Wild Dragon\Dragon-ISO`
- Shortcut targets: `TeamsISO.exe``DragonISO.exe` (matches AssemblyName, no hyphen)
- [ ] **Step 1: Replace Package.wxs entirely with Dragon-ISO branded content**
Write the following to `installer/Package.wxs`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!--
Dragon-ISO — MSI installer (WiX v5)
Produces: Dragon-ISO-Setup-<Version>.msi (per-machine install).
Build:
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj -c Release -r win-x64 --self-contained false -o publish/Dragon-ISO
dotnet build installer/Dragon-ISO.Installer.wixproj -c Release
Runtime expectations:
- .NET 8 Desktop runtime present on target (framework-dependent build)
- NDI 6 Runtime present — checked in NdiRuntimeDirV6Search; absence WARNS
but does not block install (operators can install NDI after the app)
Exe filename note:
Dragon-ISO.App.csproj sets AssemblyName=DragonISO (no hyphen — CLR
assembly names cannot contain hyphens). The published executable is
therefore DragonISO.exe. Shortcut targets reference DragonISO.exe.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package Name="Dragon-ISO"
Manufacturer="Wild Dragon LLC"
Version="1.0.0.0"
UpgradeCode="9F4A8B2C-1D3E-4A5B-9C6D-8E7F0A1B2C3D"
Scope="perMachine"
Compressed="yes"
InstallerVersion="500">
<!--
SummaryInformation fields surface in File Explorer's "Details" tab and
in the Windows Installer "About" dialog. Description and Keywords are
what users see if they right-click the MSI before installing; Comments
is the longer copy that appears alongside the version in some
installer dialogs.
-->
<SummaryInformation
Description="Dragon-ISO — per-participant NDI ISO controller for Microsoft Teams. Splits each Teams participant into a normalized NDI source for vMix / OBS / Ross / hardware switchers."
Manufacturer="Wild Dragon LLC"
Keywords="Dragon-ISO, NDI, Microsoft Teams, ISO recording, broadcast, live production, vMix, OBS, switcher, Wild Dragon" />
<!--
MajorUpgrade: a newer install replaces an older one in-place. We
disallow downgrades because the engine config schema only carries a
forward-migration path; downgrading would leave operators with a
config the older binary doesn't understand.
-->
<MajorUpgrade DowngradeErrorMessage="A newer version of Dragon-ISO is already installed. Uninstall it before installing this older version."
Schedule="afterInstallInitialize" />
<!--
Single MSI feature; users see only the install/uninstall screens.
-->
<Feature Id="Main" Title="Dragon-ISO" Level="1">
<ComponentGroupRef Id="ApplicationFiles" />
<ComponentGroupRef Id="Shortcuts" />
<ComponentGroupRef Id="DesktopShortcut" />
<ComponentGroupRef Id="ArpEntry" />
</Feature>
<!--
Minimal install UI: Welcome/License → Progress → Finish.
No directory picker — installs to Program Files\Wild Dragon\Dragon-ISO.
-->
<ui:WixUI Id="WixUI_Minimal" />
<!--
Add/Remove Programs metadata. ARPHELPLINK is the "Help" link; ARPURLINFOABOUT
is the manufacturer/about link; ARPCONTACT is the support contact shown
when the user clicks "Support information" from the ARP entry. ARPCOMMENTS
is the long description displayed in some Settings → Apps surfaces.
-->
<Property Id="ARPHELPLINK" Value="https://forge.wilddragon.net/zgaetano/dragon-iso" />
<Property Id="ARPURLINFOABOUT" Value="https://wilddragon.net" />
<Property Id="ARPCONTACT" Value="Wild Dragon LLC — support@wilddragon.net" />
<Property Id="ARPCOMMENTS" Value="Dragon-ISO turns Microsoft Teams' raw NDI broadcast into clean, normalized, per-participant NDI sources for ingestion by a live-production switcher (vMix, OBS, Ross, hardware capture). Each participant gets an individually-addressable source with configurable framerate, resolution, aspect mode, and audio routing." />
<!-- ARPNOMODIFY is set by WixUI_Minimal; don't redeclare. -->
<Property Id="ARPNOREPAIR" Value="1" />
<!--
ARP icon — references the same .ico the WPF host uses. WiX requires the
icon resource to live next to the wxs OR be reachable at build time;
we point at the source copy under src/Dragon-ISO.App/Assets so the icon
embedded in the MSI matches the icon in the running exe.
-->
<Icon Id="DragonISOIcon" SourceFile="$(var.AssetsDir)Dragon-ISO.ico" />
<Property Id="ARPPRODUCTICON" Value="DragonISOIcon" />
<!--
.NET 8 Desktop Runtime detection. The .NET apphost will surface a
"framework not found" dialog naturally if the runtime is absent;
this property is available for future conditional logic.
VBScript-based install-time dialogs are deprecated in WiX v5 / Windows;
rewriting in C++ is overkill for a soft warning on a soft dependency.
-->
<Property Id="DOTNET8DESKTOPRUNTIME" Value="0">
<RegistrySearch Id="DotNet8DesktopRuntimeSearch"
Root="HKLM"
Key="SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App"
Name="8.0.0"
Type="raw" />
</Property>
<!--
NDI Runtime detection. We check for NDI_RUNTIME_DIR_V6 in the system
environment block. Missing → warn during install, don't block. The
engine surfaces a clear MessageBox with an install-NDI link at first
launch if the runtime really isn't there.
-->
<Property Id="NDIRUNTIMEDIR" Value="0">
<RegistrySearch Id="NdiRuntimeDirV6Search"
Root="HKLM"
Key="SYSTEM\CurrentControlSet\Control\Session Manager\Environment"
Name="NDI_RUNTIME_DIR_V6"
Type="raw" />
</Property>
<!--
NDI runtime detection is surfaced at first app launch (App.xaml.cs pops a
MessageBox with an install link). We deliberately don't block install on
a missing runtime so admins can stage the app before NDI is rolled out.
VBScript-based install-time prompts are deprecated in WiX v5 / Windows
and rewriting in C++ is overkill for a soft warning.
-->
<!--
Install layout under Program Files\Wild Dragon\Dragon-ISO.
-->
<StandardDirectory Id="ProgramFiles64Folder">
<Directory Id="ManufacturerFolder" Name="Wild Dragon">
<Directory Id="INSTALLFOLDER" Name="Dragon-ISO" />
</Directory>
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder">
<Directory Id="WildDragonStartMenuFolder" Name="Wild Dragon" />
</StandardDirectory>
<!--
Files: harvested from the publish output dir at build time.
WiX v5 understands <Files Include="..."> with glob patterns and
synthesizes one Component per file with stable GUIDs.
-->
<ComponentGroup Id="ApplicationFiles" Directory="INSTALLFOLDER">
<Files Include="$(var.PublishDir)**" />
</ComponentGroup>
<!--
Start Menu and Desktop shortcuts — direct .exe targets.
Don't wrap the Target in runas.exe /trustlevel:0x20000 (or anything
else that demotes the spawned process). The SAFER-restricted token
breaks .NET 8 WPF apphost startup: the process appears alive with
a window, but no managed code past BAML parse executes. Verified
empirically 2026-05-16 — letting Dragon-ISO inherit the launching
token (medium or high integrity, doesn't matter) is the correct
behavior. NDI discovery works fine at either integrity level.
Exe filename: AssemblyName=DragonISO (no hyphen), so target is
DragonISO.exe not Dragon-ISO.exe.
-->
<ComponentGroup Id="Shortcuts" Directory="WildDragonStartMenuFolder">
<Component Id="StartMenuShortcut" Guid="*">
<Shortcut Id="StartMenuDragonISO"
Name="Dragon-ISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]DragonISO.exe"
WorkingDirectory="INSTALLFOLDER"
Icon="DragonISOIcon" />
<!-- Required by ICE64: Start Menu folder must be cleaned on uninstall. -->
<RemoveFolder Id="RemoveWildDragonStartMenuFolder"
Directory="WildDragonStartMenuFolder"
On="uninstall" />
<RegistryValue Root="HKCU"
Key="Software\Wild Dragon\Dragon-ISO"
Name="StartMenuShortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</ComponentGroup>
<StandardDirectory Id="DesktopFolder" />
<ComponentGroup Id="DesktopShortcut" Directory="DesktopFolder">
<Component Id="DesktopShortcutComponent" Guid="*">
<Shortcut Id="DesktopDragonISO"
Name="Dragon-ISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]DragonISO.exe"
WorkingDirectory="INSTALLFOLDER"
Icon="DragonISOIcon" />
<RegistryValue Root="HKCU"
Key="Software\Wild Dragon\Dragon-ISO"
Name="DesktopShortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</ComponentGroup>
<!--
ARP icon registry entry. Optional — the MSI auto-fills most ARP
fields from the Package element. We only need to store the install
path for diagnostic / uninstall tooling.
-->
<ComponentGroup Id="ArpEntry" Directory="INSTALLFOLDER">
<Component Id="ArpIconRegistry" Guid="*">
<RegistryValue Root="HKLM"
Key="Software\Wild Dragon\Dragon-ISO"
Name="InstallPath"
Type="string"
Value="[INSTALLFOLDER]"
KeyPath="yes" />
</Component>
</ComponentGroup>
</Package>
</Wix>
```
- [ ] **Step 2: Verify no "TeamsISO" strings remain in Package.wxs**
```powershell
Select-String -Path installer/Package.wxs -Pattern "TeamsISO"
```
Expected: no output (zero matches)
---
## Task 4: Fix release.yml — wrong exe filename in signing step
**Files:**
- Modify: `.forgejo/workflows/release.yml` line 119
The signing step references `publish/Dragon-ISO/Dragon-ISO.exe` but the app's `AssemblyName` is `DragonISO`, so the published exe is `DragonISO.exe`. Fix it.
- [ ] **Step 1: Edit release.yml to fix the exe path**
In `.forgejo/workflows/release.yml`, find and replace:
Old (line 119):
```
'publish/Dragon-ISO/Dragon-ISO.exe'
```
New:
```
'publish/Dragon-ISO/DragonISO.exe'
```
- [ ] **Step 2: Verify the fix**
```powershell
Select-String -Path .forgejo/workflows/release.yml -Pattern "Dragon-ISO\.exe|DragonISO\.exe"
```
Expected output:
```
.forgejo/workflows/release.yml:119: 'publish/Dragon-ISO/DragonISO.exe'
```
(One match, using `DragonISO.exe` with no hyphen)
---
## Task 5: Check WiX workload and verify build
**Files:** None (verification only)
- [ ] **Step 1: Check if WiX workload is installed**
```powershell
dotnet workload list
```
Expected: output includes `wix` in the list. If not installed, run:
```powershell
dotnet workload install wix
```
- [ ] **Step 2: Publish the app to the expected location**
```powershell
cd C:\Users\zacga\source\repos\Dragon-ISO
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj `
-c Release -r win-x64 --self-contained false `
-o publish/Dragon-ISO
```
Expected: ends with `Build succeeded.` and creates `publish/Dragon-ISO/DragonISO.exe`
- [ ] **Step 3: Verify the exe filename in the publish output**
```powershell
Get-ChildItem publish/Dragon-ISO/ -Filter "*.exe"
```
Expected: one file named `DragonISO.exe` (confirms shortcut targets are correct)
- [ ] **Step 4: Build the MSI**
```powershell
dotnet build installer/Dragon-ISO.Installer.wixproj -c Release /p:Version=1.0.0.0
```
Expected: ends with `Build succeeded.` — no errors, no warnings.
- [ ] **Step 5: Verify the MSI was produced with the correct name**
```powershell
Get-ChildItem installer/bin -Recurse -Filter "*.msi"
```
Expected: one file named `Dragon-ISO-Setup-1.0.0.0.msi`
---
## Task 6: Commit all changes
- [ ] **Step 1: Stage the changed files**
```powershell
cd C:\Users\zacga\source\repos\Dragon-ISO
git add installer/Dragon-ISO.Installer.wixproj
git add installer/Package.wxs
git add .forgejo/workflows/release.yml
```
- [ ] **Step 2: Verify nothing unexpected is staged**
```powershell
git status
```
Expected staged files:
- `installer/Dragon-ISO.Installer.wixproj` (renamed from TeamsISO.Installer.wixproj)
- `installer/Package.wxs` (modified)
- `.forgejo/workflows/release.yml` (modified)
No other files should be staged.
- [ ] **Step 3: Commit**
```powershell
git commit -m "$(cat <<'EOF'
rebrand installer from TeamsISO to Dragon-ISO
- Rename TeamsISO.Installer.wixproj → 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 property
- Fix release.yml: signing step referenced Dragon-ISO.exe but
AssemblyName=DragonISO so exe is DragonISO.exe (no hyphen)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
EOF
)"
```
---
## Testing Checklist (manual verification after install)
Once the MSI is built, install it on a test machine and verify:
- [ ] `Dragon-ISO-Setup-1.0.0.0.msi` installs without errors
- [ ] App installs to `C:\Program Files\Wild Dragon\Dragon-ISO\`
- [ ] `DragonISO.exe` is present in the install folder
- [ ] Start Menu shows `Wild Dragon → Dragon-ISO` shortcut with correct icon
- [ ] Desktop shows `Dragon-ISO` shortcut with correct icon
- [ ] Both shortcuts launch the app successfully
- [ ] Add/Remove Programs shows:
- Name: Dragon-ISO
- Publisher: Wild Dragon LLC
- Version: 1.0.0.0
- Help link: `https://forge.wilddragon.net/zgaetano/dragon-iso`
- [ ] Uninstall removes all files, shortcuts, and registry entries
- [ ] `%APPDATA%\Dragon-ISO\` (user config) is NOT removed on uninstall

View file

@ -1,183 +0,0 @@
# Plan Backlog
## Completed phases
- **Phase A — Engine Foundation** (tag: `phase-a-complete`) — domain model, parsers, participant tracker, frame processor, config, fakes, CI gate.
- **Phase B-1 — Pipeline Orchestration** (tag: `phase-b-1-complete`) — NdiReceiver, NdiSender, ExponentialBackoff, NdiRuntimeProbe, IsoPipeline supervisor, IsoController.
- **Phase B-2 — Real NDI Interop** (tag: `phase-b-2-complete`) — `NdiInteropPInvoke` against NDI 6 SDK, managed BGRA scaler, `TeamsISO.Console` headless smoke runner, `NdiVersion` constants.
- **Phase C — WPF UI** (tag: `phase-c-complete`) — MVVM helpers, ParticipantViewModel, GlobalSettingsViewModel, AlertBannerViewModel, MainViewModel, MainWindow XAML with participants DataGrid + settings sidebar + alert banner, App.xaml DI bootstrap.
- **Hardening + brand pass — May 2026** — see "Done since the May 2026 hand-off" below.
- **Phase D — WiX Installer & Forgejo release** — WiX v5 MSI scaffold, ARP icon wired, tag-push release workflow that builds + uploads MSI as a release asset.
## Done since the May 2026 hand-off
### Engine
- Forward-slash project paths in `TeamsISO.sln` so `.slnf` filters work on Windows MSBuild.
- `NdiNativeLibraryResolver` resolves `Processing.NDI.Lib.x64.dll` via `NDI_RUNTIME_DIR_V6` (with V5 / V4 fallbacks), so the engine starts on installs where the NDI dir isn't on PATH.
- `NdiVersion.ExpectedRuntimeVersionPrefix` updated to match the shipping NDI 6 banner format (`NDI SDK WIN64 …`).
- `NdiSourceParser` accepts current Teams desktop's `MS Teams - <name>` brand format (plus legacy `Teams` and defensive `Microsoft Teams`); reserved suffixes (`Active Speaker`, `Audio`, `Audio Mix`, `Screen Share`) are recognized in both legacy and dash-prefixed forms.
- NDI **groups** end-to-end (discovery + output): `INdiInterop.CreateFinder(string?)` and `CreateSender(string, string?)` populate `p_groups`; `IsoController` threads them through from `EngineConfig.NdiGroups`.
- `ParticipantTracker` surfaces `NdiSourceKind.ActiveSpeaker` as a synthetic routable row named "Active Speaker" with a deterministic v5-GUID Id derived from `auto-mix:<machine>`.
- `IsoHealthStats` wired end-to-end: live receiver/sender/processor refs published from the inner pipeline, frame counters / source resolution / running FPS (30-frame moving window) / drops + duplicates / pipeline state surfaced via `IsoController.GetStats`.
- Rolling daily file logging at `%LOCALAPPDATA%\TeamsISO\Logs\` via Serilog.Sinks.File.
### UI
- WPF rebuilt around Wild Dragon brand × Microsoft Teams flush layout — left rail with real dragon-mark logo (clickable → About dialog), chromeless title bar with custom min/max/close caption controls, cyan accent.
- Inter Variable + JetBrains Mono Variable bundled as `<Resource>` so typography matches wilddragon.net regardless of system fonts.
- App icon `teamsiso.ico` (7 sizes) on taskbar / window / About / WiX MSI ARP.
- Single-instance enforcement via per-user named Mutex with broadcast bring-to-front.
- Empty-state placeholder when no Teams sources are visible (faded dragon + checklist).
- Live frame counters in the Source / Live columns (in/out/drops, source resolution, running FPS).
- Per-pipeline state surfaced in the ISO toggle: `● LIVE` (cyan), `● ERROR` (coral), `● NO SIGNAL` (amber), `…` (processing).
- "Stop all ISOs" emergency button at the participants header.
- Hide-(Local) toggle so the user's own self-preview is filtered from the participants list.
- Window position / size / state persisted to `%LOCALAPPDATA%\TeamsISO\window.json`, multi-monitor safe.
- Tooltips on every interactive control in the settings panel + per-row textbox + ISO toggle.
- Toast feedback for settings actions (Apply / Apply Transcoder Topology / Stop All / Auto-disable).
- **Auto-disable on participant departure** (configurable, off by default): when a participant's NDI source disappears the engine tears down their pipeline; the toggle lives in `DISPLAY` settings.
- **Operator presets**: chromeless `Presets…` dialog from the participants header. Saves the current per-participant `IsEnabled` + `CustomName` set keyed by display name to `%LOCALAPPDATA%\TeamsISO\presets.json` (atomic write, schema-versioned). Apply walks the live participants and reconciles via `EnableIsoAsync` / `DisableIsoAsync`; participants in the preset who aren't in the current meeting are reported in the toast.
- **Auto-apply last preset on launch**: opt-in checkbox in `DISPLAY` settings. After the operator's first manual Apply, every subsequent TeamsISO launch silently re-applies the same preset once participants populate (30-second grace window before applying with whoever's online). State lives in `presets.json` next to the preset list.
- **Refresh discovery** affordance: header pill that rebuilds the underlying NDI finder on the next poll tick. `IIsoController.RefreshDiscovery` flips a flag the discovery loop honors before the next tick — old finder disposed, new finder created, seen-set cleared so all currently-visible sources re-fire as Added. `ParticipantTracker.HandleAdded` is idempotent: re-emitting the same FullName refreshes LastSeen rather than minting a duplicate row.
- **Settings tabs**: the settings sidebar is now a TabControl with `OUTPUT` / `NETWORK` / `DISPLAY` tabs and a single Apply Changes button below. Underline-on-active tab style lives in `WildDragonTheme.xaml` (`Wd.TabControl` + `Wd.TabItem`).
- **Crash diagnostics**: `App.OnStartup` wires `AppDomain.UnhandledException`, `Application.DispatcherUnhandledException`, and `TaskScheduler.UnobservedTaskException` into a unified Serilog.Critical log line + user-facing dialog that points at the log directory. Dispatcher exceptions are marked `Handled = true` so a single bad UI thunk doesn't take the app down; AppDomain crashes are terminal but at least the user gets the log path before exit.
- **First-launch onboarding**: chromeless welcome dialog walks users through the once-per-machine setup (NDI runtime, Teams admin permission, transcoder topology, presets, log location). Suppressed after dismissal via marker file at `%LOCALAPPDATA%\TeamsISO\onboarding.flag`. Re-openable from the About dialog via "Show welcome" button.
- **Reset output to defaults**: ghost button at the bottom of the OUTPUT settings tab restores framerate / resolution / aspect / audio to `FrameProcessingSettings.Default` after confirmation. Doesn't touch NDI groups (sticky per-machine) or display toggles.
- **Per-output recording**: `IRecorderSink` interface + `RawBgraRecorderSink` implementation. When the operator enables "Record ISOs to disk" in the DISPLAY tab, each newly-enabled ISO writes its normalized output to `<chosen-dir>/<participant>/video.bgra` plus a sidecar `manifest.json` (width / height / fps / frame counts) and a `convert.cmd` one-liner that pipes the raw stream into FFmpeg to produce a final H.264 `output.mkv`. Recorder runs on its own bounded queue (240-frame `DropOldest` buffer) so disk pressure never blocks the live ISO; recorder failures are caught and ignored at the channel-write layer for the same reason. Already-running ISOs are not retroactively captured — operator disables + re-enables to start recording. Recording can be wired to a real-time H.264 encoder later via Vortice.MediaFoundation; the `IRecorderSink` interface is designed to swap implementations without touching the pipeline.
- **REST control surface**: `ControlSurfaceServer``System.Net.HttpListener` on `127.0.0.1:9755` (configurable). Endpoints for participant ISO toggle (by Id or display name), refresh discovery, stop-all, recording on/off, preset apply, and Teams in-call commands (mute / camera / share / leave / raise-hand). Off by default; toggle in the DISPLAY tab. Bitfocus Companion / Stream Deck plugins / OSC bridges drive it. Documented at `docs/CONTROL-SURFACE.md`.
- **PresetApplier**: extracted from `PresetsDialog.OnApply`. Single source of truth for "apply this preset to live participants" — used by the dialog, by `MainViewModel.TryAutoApplyPendingPreset` (auto-apply on launch), and by the REST `POST /presets/{name}/apply` endpoint. Marshals UI-bound writes (CustomName / IsEnabled) through an optional Dispatcher so off-thread callers don't crash WPF.
- **In-app preview thumbnails**: 160×90 WriteableBitmap per participant, fed from the engine's most recent `ProcessedFrame` at the existing 1Hz stats tick. Inline nearest-neighbor scaler in `ParticipantViewModel.UpdateThumbnail` writes directly into the bitmap's pinned BackBuffer (unsafe block, `<AllowUnsafeBlocks>true</AllowUnsafeBlocks>` in the .csproj) for ~10× perf vs. going through Span<byte>. Falls back to a `—` placeholder card when no pipeline is running. New "Preview" column in the participants DataGrid.
- **WebSocket live state push**: `ws://127.0.0.1:9755/ws` — clients connect, receive a participants snapshot immediately, and get fresh snapshots within 250ms whenever state changes. Snapshot diffing on JSON string keeps the wire quiet during steady-state. Used by Stream Deck / Companion buttons that want to light up when an ISO goes LIVE without polling.
- **OSC bridge over UDP**: `OscBridge` listens on `127.0.0.1:9000` (TouchOSC's default). Same command vocabulary as the REST endpoints — `/teamsiso/iso "Jane" 1`, `/teamsiso/preset "Friday Show"`, `/teamsiso/teams/mute`, etc. Minimal OSC 1.0 parser (int / float / string / T / F type tags; no bundles). TouchOSC layouts and Companion's Generic OSC surface can both drive it directly.
- **Manual update check**: "Check for updates" button in the About dialog. Asks `forge.wilddragon.net/api/v1/repos/zgaetano/teamsiso/releases?limit=1`, compares the newest tag's SemVer to the running version, prompts to open the releases page if newer. Manual only — no background polling for v1 so a long-running show doesn't get interrupted by a surprise installer.
- **Auto-update banner on launch**: opt-in (default on) silent check throttled to once per 24h via `%LOCALAPPDATA%\TeamsISO\last-update-check.txt`. When a newer release is found, a non-modal banner appears above the body with "Get update" / "Dismiss" buttons. Suppression via flag file at `no-update-check.flag` for fleets that prefer central rollout. New `UpdateBannerViewModel` distinct from the engine alert banner.
- **Preset import / export**: Export / Import buttons in the Presets dialog footer, backed by `OperatorPresetStore.ExportAllAsJson` / `ImportBundle`. Bundle format is `teamsiso-presets-bundle/v1` JSON. On name collision the importer asks once (Overwrite/Keep/Cancel) rather than per-preset; deliberately doesn't include the operator's `LastAppliedName` / `AutoApplyOnStartup` since those are machine-local.
- **Recording markers**: `IRecorderSink.AddMarker(label)` plus `IIsoController.AddRecordingMarker(label)` fan-out to every active recorder. Surfaced via "Marker" button in the IN-CALL bar (auto-labels with timestamp), `POST /recording/marker` in the REST surface, and `/teamsiso/recording/marker "Label"` in OSC. Markers land in `manifest.json` under `markers[]` with `offsetMs` + `label` fields for post-production chaptering.
- **Custom NDI output name template**: `OutputNameTemplate` static helper persisted to `output-name-template.txt` with `{name}` / `{guid}` / `{machine}` / `{timestamp}` tokens. Default `TEAMSISO_{guid}` preserves the engine's hard-coded behavior; operator can switch to `TEAMSISO_{name}` for human-readable downstream switcher names. UI editor in the NETWORK settings tab.
- **Enriched footer status bar**: rec badge (coral dot + count) when at least one ISO is being recorded; control-surface badge (cyan dot + "REST :9755 + OSC :9000") when those services are running. Computed at the existing 1Hz stats tick from `IIsoController.RecordingEnabled` × running pipeline count and `App.ControlSurface.IsRunning` / `App.OscBridge.IsRunning`.
- **Disk space watcher**: `DiskSpaceWatcher` polls the recording drive every 5s while recording is on. Coral toast at <10GB free; auto-disables recording at <1GB so an unattended long show doesn't crash the host on disk-full.
- **Diagnostic bundle export**: "Export diagnostics" button in About zips logs + config + presets + window state + version metadata into a `teamsiso-diagnostics-<ts>.zip` in `~/Downloads`. Excludes screenshots / memory dumps; only files the user already wrote.
- **Per-participant recording opt-out**: new `Rec` column in the DataGrid lets the operator choose which ISOs get recorded when global recording is on. `IIsoController.EnableIsoAsync` gained an optional `bool? recordOverride` parameter — null = follow global flag, true = force on, false = force off.
- **Window-scoped keyboard shortcuts**: F1 (help), Ctrl+M (drop marker), Ctrl+Shift+S (stop all), Ctrl+R (refresh discovery). InputBindings on MainWindow → MainViewModel commands; F1 opens the new `HelpWindow` cheat sheet.
- **Help cheat sheet**: chromeless `HelpWindow` lists keyboard shortcuts, file locations (`%LOCALAPPDATA%\TeamsISO\Logs\`, `%APPDATA%\TeamsISO\config.json`, etc.), and links to the public docs. Reduces support friction.
- **Bulk enable**: header `Enable all` button (green dot) enables ISOs for every online + non-enabled participant. Per-participant best-effort with a count toast.
- **Live participant filter**: textbox above the DataGrid filters by display-name substring as you type. Backed by an `ICollectionView` Filter callback so the underlying `ObservableCollection` isn't mutated (preserving identity-tracking).
- **Right-click context menu** on participant rows: Toggle ISO, toggle Record-this-participant, Copy NDI source name to clipboard. Uses the existing per-row commands so the menu is just another binding surface.
- **CLI: `--apply-preset NAME`**: launch-time flag that auto-applies the named preset once participants populate. Same code path as the persisted auto-apply preference. Useful for `Friday Show.lnk` desktop shortcuts that drive recurring routings.
- **Dynamic status text**: footer's center text now reads "3/5 ISOs live · 2 recording" once routing starts, instead of the static "Engine running at X fps target." Composed in `OnStatsTick` from running participant + recording counts.
- **Embedded HTML control panel** at `GET /ui`: self-contained ~6KB page with WebSocket-driven live state and buttons for the common control actions. Open in a phone or second-monitor browser to drive TeamsISO without context-switching from the show. No external dependencies, no build step.
- **Session timer** in footer: shows `MM:SS` (or `HH:MM:SS` past an hour) elapsed since the first ISO went live this session. Resets when all ISOs go offline. Green dot indicator for at-a-glance status.
- **Show notes service**: `POST /notes` and `/teamsiso/notes "..."` (OSC) append timestamped lines to `%LOCALAPPDATA%\TeamsISO\Notes\<YYYY-MM-DD>.md`. Operators wire a Stream Deck button to drop notes during a live show without leaving the production app. Markdown format renders cleanly in any editor.
- **NotesWindow inline viewer**: chromeless dialog that displays today's notes file with 2s polling so REST/OSC-driven appends surface live. "Notes" button in the IN-CALL bar.
- **Duplicate-preset action**: "Duplicate" footer button in the Presets dialog. Custom inline prompt suggests `<original> (copy)` / `(copy 2)` / etc. names.
- **CHANGELOG.md**: project-wide changelog following keep-a-changelog format. Captures the full May 2026 batch under `[Unreleased]`.
- **README rewrite**: top-level README now lists what TeamsISO does, build instructions, doc links, keyboard shortcuts table, file-locations table.
- **Confirm-before-Stop-All**: stop-all button now requires Yes confirmation, preventing accidental mid-show clicks. Default-No so Enter cancels.
### Networking automation
- One-click **transcoder topology** button in Settings: writes `%APPDATA%\NDI\ndi-config.v1.json` so all local senders broadcast on `teamsiso-input` and local receivers see both `public` + `teamsiso-input`. Engine settings auto-flip to receive-from `teamsiso-input` and emit-on `public`. Atomic write with timestamped backup of the prior config.
### Phase E — embedded Teams orchestration
Spec at `docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md`. All three sub-phases shipped in May 2026:
- **E.1 — Launcher.** Rail "Launch / Stop Teams" toggle: launches via `ms-teams:` URI → `ms-teams.exe` → classic `Update.exe --processStart`, asks to confirm `WM_CLOSE` of all running Teams windows when toggled while Teams is up.
- **E.2 — Window orchestration.** Rail eye-icon button hides every visible top-level Teams window via `EnumWindows` + `ShowWindow(SW_HIDE)`. Click again to restore + foreground. Lets the operator drive Teams from TeamsISO without ever seeing the Teams UI.
- **E.3 — In-call controls.** UIAutomation-driven Mute / Camera / Share / Leave buttons in a new `IN-CALL` card at the top of the participants area. `TeamsControlBridge` walks Teams' automation tree by candidate Name list (`Mute`, `Unmute`, `Microphone`, `Toggle mute` …) and tries Invoke or Toggle pattern. Tolerant lookup: when a Teams update renames a button we extend the candidate list, no crash. Toasts reflect the four outcomes (Invoked / TeamsNotRunning / ControlNotFound / InvokeFailed). Bridge also exposes (UI-not-yet-wired) `ToggleRaiseHand`, `ToggleChat`, `OpenBackgroundEffects`. Candidate name lists localized for English/German/Spanish/French/Portuguese/Japanese — all locales matched in a single pass; the first match wins.
- **PostMessage shortcut forwarding fallback.** `TeamsLauncher.SendShortcut(modifiers, vk)` posts WM_KEYDOWN/UP to the most-recently-used hidden Teams HWND. Best-effort — modern WebView2-hosted Teams sometimes ignores synthesized key messages at the app-shortcut layer; UIA is preferred when a button exists for the action.
### Diagnostics
- `TeamsISO.Console --list-sources` enumerates raw NDI source names visible to the local finder for ~5 seconds; debugging tool for setup issues.
- `TeamsISO.Console --version` prints engine version + build SHA + .NET + OS + NDI runtime banner + exit-code legend, for support tickets.
- About dialog inside the WPF host with the same info.
### CI / Release / Docs
- Forgejo CI is green: `actions/upload-artifact@v3` (Forgejo doesn't support v4 yet).
- `.forgejo/workflows/release.yml`: tag-push (`v*.*.*`) builds + tests + publishes + builds the MSI on a Windows runner and attaches it to the auto-created Forgejo release via the REST API.
- **Optional code-signing** wired into `release.yml`: when `SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD` Forgejo secrets are set, the workflow signs both `TeamsISO.exe` (before MSI build) and the MSI (after) with SHA-256 + RFC 3161 timestamp. Skipped silently when the cert isn't configured. `docs/RELEASING.md` documents the OV vs EV trade-offs and the Azure Trusted Signing migration path.
### Tests
- 78 unit tests passing; 9 NDI integration tests gated behind `--filter requires=ndi` (runtime probe, finder + sender lifecycle on default and custom groups, loopback discovery, full pipeline frame round-trip asserting 1080p normalization).
## Done since May 10 hand-off
### Engine
- **Audio peak metering wired end-to-end.** `IsoHealthStats.PeakAudioLevel`
now reports real values from a sibling NDI audio capture loop in
`NdiReceiver`. New `INdiInterop.CaptureAudioPeak` method (default-
implemented for FakeNdiInterop, overridden in NdiInteropPInvoke).
`AudioPeakComputer` handles FLTP / FLT / PCM s16 with 14 unit tests
covering edge cases. UI VU bars in the participants DataGrid now
animate; the existing decay logic in `ParticipantViewModel` was
already in place waiting for real values.
### Control surface
- **LAN-reachable mode.** New checkbox in DISPLAY tab toggles whether the
REST/WebSocket surface and OSC bridge bind to `127.0.0.1` only or to all
interfaces (`http://+:port/`, `IPAddress.Any`). Settings panel surfaces
the routable URL with a Copy button (picker prefers physical NICs and
skips Tailscale / VPN tunnels / APIPA addresses). Use case: headless
host PC + thin client on the same LAN — operator runs Teams + TeamsISO
on a quiet machine, drives it from anywhere on the production network.
No auth — documented as a trusted-LAN-only mode. First-time bind
requires a one-shot `netsh http add urlacl`; the diagnostic warning
fires the exact remediation command if the bind fails.
### "I only see TeamsISO" — Phase E.1+E.2 follow-ups
- **Launch + auto-hide Teams** preferences in DISPLAY tab. Teams runs in the
background; window appears briefly then hides automatically; operator
drives everything from the IN-CALL bar + participants DataGrid.
- **Quick-join from URL** in the IN-CALL bar. Paste a Teams meeting link,
click Join, Teams launches into the meeting. Eliminates the open-Teams
→ Calendar → find → click join dance.
- **Teams meeting state pill**`IN CALL · <meeting title>` / `READY` /
empty. UIA probe at 1Hz for the Leave button; meeting title from
Teams' window title with the brand suffix stripped.
- **Launch Teams click semantics** — left-click = launch / surface / restore;
right-click = stop. Was previously ambushing operators with a stop-Teams
dialog when Teams was hidden via the eye-toggle.
- **Auto-record on meeting start** preference. Recording auto-flips ON when
Teams transitions into a call (UIA Leave button appears) and OFF when
the call ends — completes the unattended-show story.
- **MUTED / CAM OFF pills** in the IN-CALL bar via UIA — local-user state
visible at a glance without restoring Teams.
- **Phase E.4 (experimental) — Teams window embedding via SetParent.**
Reparents Teams' main window into a TeamsISO-owned host so Teams appears
visually INSIDE TeamsISO. WebView2 in modern Teams may render glitches
after reparent; if so operator unticks and falls back to auto-hide mode.
Live in `TeamsEmbedWindow` + `TeamsLauncher.EmbedTeamsInto` / `RestoreEmbed`.
- **Loudest sort mode** + **active speaker row highlight** (3px cyan left
border) — operators react to who's talking without scanning every VU bar.
- **NumPad 1-9 hotkeys** toggle Nth visible participant's ISO. Generic
`RelayCommand<T>` added so XAML CommandParameter strings convert cleanly.
- **Snapshot frame to PNG** (per-participant via right-click + bulk header
action). Saves under `%USERPROFILE%\Pictures\TeamsISO\`.
- **Recording drive free space** in the footer (`· 245 GB free`). Coral
tint below 10GB; existing DiskSpaceWatcher still auto-disables at 1GB.
- **Recording elapsed duration** in the footer next to the count
(`REC 3 · 12:45`).
- **Quick-join meeting URL** + **IN-CALL pill with meeting title** for the
headless workflow — paste link, click Join, see what meeting you're in.
### UI polish
- Visible hover affordances on every themed button (Ghost / Caption /
RailIcon / IsoToggle / Primary). Cyan accent borders + brighter fills
so mouse-hover and tab-focus give an unmistakable affordance regardless
of which dark surface the button sits on.
- Keyboard focus rings (`IsKeyboardFocused` triggers) so tab-cycling
through the UI gives visual feedback (was nothing — `FocusVisualStyle`
was `x:Null` with no replacement).
- ScrollBar restyled to slim transparent track + tinted thumb (Edge / VS
Code pattern) in place of the chunky Win9x default.
- ContextMenu / MenuItem styled to match the dark canvas — right-click on
a participant row no longer shows the cream-colored Notepad popup.
- ToolTip restyled: SurfaceElevated card with rounded corner + 320px
text wrap, replacing the cream Win98 popup.
- Wd.Button.Primary disabled state distinct (was identical to enabled).
## Next
1. **Smoke-test on real Teams.** Most of May's work hasn't run against a live meeting yet: the UIA in-call commands (mute / camera / share / leave) need their candidate-Name lists validated against the current Teams build, and the auto-apply-on-launch flow needs a real recurring meeting to confirm the 30-second grace window is right. Pin the AutomationIds for buttons we find — Name-based lookup is a starting point, AutomationId is what survives Teams UI updates. Now also includes: validate the audio peak metering against real Teams audio (check that FLTP decoding is correct for whatever sample rate Teams is broadcasting; the `--filter requires=ndi` integration tests don't exercise audio).
2. **Acquire a code-signing cert.** Pipeline is wired (see "CI / Release / Docs" above); just needs `SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD` set in Forgejo Secrets. OV cert (~$200/yr) gets us signed but SmartScreen builds reputation slowly; EV cert (~$300/yr, hardware token) is SmartScreen-trusted immediately. Azure Trusted Signing is the cloud-native path if a token-on-runner is fiddly.
3. **Port MediaFoundationRecorderSink to Vortice 3.6.2 API.** NuGet package added but the May 9 scaffold targeted an older Vortice API. Port pass needed before `MF_AVAILABLE` can be defined; see `docs/REAL-TIME-RECORDING.md` "Status — May 2026" section for the specific API gaps (MFVersion / MF_LOW_LATENCY / IMFMediaType setters / IMFMediaBuffer.Lock signature / IMFSinkWriter.Finalize_ rename). Once ported, gives ~10× recording disk-pressure reduction.
4. **Forward Teams keyboard shortcuts via SendInput.** Phase E.2 hides the Teams window but doesn't forward Ctrl+Shift+M / Ctrl+Shift+O / Ctrl+Shift+H to it. UIA covers mute/camera/share/leave/raise-hand/chat/background already; SendInput would let us pass arbitrary global hotkeys through to a hidden Teams for actions UIA can't reach. Lower priority now that UIA covers the core actions.

View file

@ -1,213 +0,0 @@
# TeamsISO v1.0 — Implementation Spec
**Status:** Draft, ready for plan-writing
**Date:** 2026-05-07
**Owner:** Zac Gaetano (Wild Dragon LLC)
**Source design doc:** `TeamsISO Design Document.docx` v0.1 DRAFT (May 2026)
This spec turns the source design document into an implementable plan for the v1.0 release. The product vision, problem statement, and feature matrix in the source document remain authoritative; this spec adds the architectural and operational decisions needed to start building.
## 1. Scope
v1.0 ships the feature set in §6 of the source document, exactly as written:
- NDI participant discovery (auto)
- Per-participant ISO NDI output
- Global framerate lock (23.976 / 24 / 25 / 29.97 / 30 / 50 / 59.94 / 60 fps)
- Global resolution normalize (720p / 1080p / 4K)
- Custom output stream naming
- Isolated audio per ISO with mixed-audio fallback
- Screen share as ISO output
Deferred to v1.5: per-stream framerate override, thumbnail previews, GPU-accelerated scaling.
Deferred to v2.0: multi-machine cluster coordination, OSC/WebSocket control API.
Out of scope for v1.0: automatic peer discovery between TeamsISO instances, audio resampling, code signing of the installer.
## 2. Architecture
**Pattern:** engine/UI separation from day one. The NDI engine is a class library with no UI dependency; the WPF app is a thin host that binds to the engine through a typed C# API. v2.0's control APIs and multi-machine coordinator drop in cleanly because the boundary already exists.
**Solution layout:**
- `TeamsISO.Engine` — class library. Discovery, receive, frame processing, send, configuration, logging abstraction. Exposes `IIsoController` and observable streams. Owns all threading.
- `TeamsISO.Engine.NdiInterop` — internal P/Invoke shim for `NDIlib_*` and libyuv. Kept separate so the rest of the engine speaks managed types and unit tests can fake the interop surface.
- `TeamsISO.App` — WPF + MVVM host. Instantiates the engine, binds view models to engine observables, persists window layout. Zero NDI knowledge.
- `TeamsISO.Engine.Tests` — xUnit unit tests against `FakeNdiInterop`. Pure managed.
- `TeamsISO.Engine.IntegrationTests` — xUnit integration tests against the real NDI runtime. Tagged `[Trait("requires", "ndi")]`.
- `TeamsISO.Installer` — WiX v5 project producing the MSI.
**Engine ↔ App contract:** `IIsoController` exposes `IObservable<IReadOnlyList<Participant>>`, `IObservable<IsoHealthStats>` per output, `IObservable<EngineAlert>`, and async command methods (`EnableIsoAsync`, `SetTargetFramerate`, `SetCustomName`, `SetGlobalSettings`, etc.). All commands are cancellable.
## 3. Domain model
Defined in `TeamsISO.Engine.Domain`. All types are immutable records unless noted.
- **`NdiSource`** — raw discovery record. `string FullName`, parsed `MachineName`, `Kind` (`Participant | ActiveSpeaker | Audio | ScreenShare`), `DisplayName` (null for non-participant kinds).
- **`Participant`** — operator-facing identity. `Guid Id` (engine-assigned, stable across rename heuristic), `string DisplayName` (last seen), `NdiSource? CurrentSource`, `DateTimeOffset FirstSeen / LastSeen`. Mutable via the engine; observable.
- **`IsoAssignment`** — operator's intent. `Guid ParticipantId`, `bool IsEnabled`, `string? CustomOutputName`. Persisted to `config.json`. Reserves room for v1.5 per-stream overrides.
- **`IsoOutput`** — runtime state. `Guid ParticipantId`, `string EffectiveOutputName`, `IsoHealthStats Stats`, `IsoState State` (`Idle | Receiving | Sending | NoSignal | Error`).
- **`FrameProcessingSettings`** — `TargetFramerate`, `TargetResolution`, `AspectMode` (`Pillarbox | Letterbox | Stretch`), `AudioMode` (`Isolated | Mixed | Auto`).
- **`IsoHealthStats`** — `FramesIn`, `FramesOut`, `FramesDropped`, `FramesDuplicated`, `LastFrameAt`, `IncomingFps`, `IncomingResolution`.
- **`EngineConfig`** — root persisted record: `FrameProcessingSettings Global`, `IReadOnlyList<IsoAssignment> Assignments`. Stored at `%APPDATA%\TeamsISO\config.json`.
- **`EngineAlert`** — discriminated union: `NdiRuntimeMismatch | OutputNameCollision | PipelineError | ConfigSaveFailed`.
**Participant identity across rename / disconnect.** Teams source strings change when a participant renames. Engine policy: if a source disappears and within 5 seconds a new participant source with the same `MachineName` appears, the engine transfers the existing `Participant.Id` (and any `IsoAssignment` bound to it) to the new source. The UI shows a brief rename toast. Operators can opt out per-meeting in settings.
## 4. Components
Eight subsystems inside `TeamsISO.Engine`. Each has one responsibility.
**`NdiDiscoveryService`** — owns one `NDIlib_find_create_v2` instance on a long-running background thread. Polls every ~500 ms, diffs the source list, classifies each source, pushes `DiscoveryEvent` (`Added | Removed | Renamed`) onto a `Channel<DiscoveryEvent>`.
**`ParticipantTracker`** — consumes `DiscoveryEvent`s, applies the rename heuristic, maintains the canonical `IObservable<IReadOnlyList<Participant>>`. Stateful, pure-managed, unit-testable without NDI.
**`IsoPipeline`** — per-ISO unit. Owns one receiver, one frame processor, one sender, all health stats. Lifecycle methods `Start`, `Stop`. Created by `IsoPipelineFactory` when the operator enables an ISO.
**`NdiReceiver`** — wraps `NDIlib_recv_create_v3`. Dedicated thread loops on `NDIlib_recv_capture_v3`. Pushes captured frames into a bounded `Channel<RawFrame>` (capacity 4, drop-oldest under backpressure). Records dropped-frame count.
**`FrameProcessor`** — driven by `PeriodicTimer` at the target framerate. At each tick: read newest frame from the channel non-blocking; if available, scale via libyuv to target resolution + aspect mode, recalculate timecodes, hand to sender; if unavailable, re-emit `lastFrame`; if `lastFrame` is older than 2.5 s, emit a no-signal slate (`SolidFrameRenderer`, mid-grey).
**`NdiSender`** — wraps `NDIlib_send_create`. Dedicated thread sends video on its tick and audio passthrough on its own queue. Audio mode `Auto` probes for isolated audio at startup and falls back to mixed if unavailable.
**`IsoController`** — top of engine. Holds the pipeline dictionary, the `ParticipantTracker`, the `ConfigStore`. Exposes the `IIsoController` API. Translates "operator enabled this participant" into pipeline creation and start.
**`ConfigStore`** — load/save `EngineConfig` to `%APPDATA%\TeamsISO\config.json`. Atomic writes via temp file + rename.
**Logging:** Serilog file sink at `%APPDATA%\TeamsISO\logs\teamsiso-{Date}.log`, 14-day retention, structured. Engine code logs through `ILogger<T>` from `Microsoft.Extensions.Logging`.
## 5. Data flow and threading
Per ISO:
```
NDI source on LAN
[Capture thread] (1 dedicated thread)
NDIlib_recv_capture_v3, blocking loop
▼ Channel<RawFrame> (capacity 4, drop-oldest)
[Processor tick] (PeriodicTimer on ThreadPool, target framerate)
pick newest frame → libyuv scale/aspect → retimecode
▼ ProcessedFrame
[Send thread] (1 dedicated thread)
NDIlib_send_send_video_v2 + audio
ISO output on LAN
```
System-wide threads at 3 active ISOs: 3 capture + 3 send (dedicated, blocking-friendly), 1 discovery, 1 participant-tracker async loop on ThreadPool, 1 UI dispatcher, processor work on ThreadPool. Approximately 9 dedicated threads plus ThreadPool work — within budget for the recommended hardware.
**Why dedicated threads for capture and send:** NDI capture and send calls block. Mixing them onto the .NET ThreadPool risks starving worker threads. Processing is short-lived per frame and fits the ThreadPool model.
**Frame timing strategy (closest-frame):** simple, deterministic, works across all supported framerates without interpolation. Frame duplication = re-send `lastFrame`. After 2.5 s of no incoming frames, slate.
**Audio:** v1.0 forwards audio passthrough on its own NDI queue, no resampling. Isolated audio is forwarded as-is when available; mixed audio is forwarded on the active-speaker stream only as fallback.
**Cancellation:** every loop respects a per-ISO `CancellationToken`. Stopping an ISO triggers cancellation, joins capture and send threads (1 s timeout), disposes NDI handles.
## 6. Error handling and recovery
**Pipeline isolation.** Each `IsoPipeline` runs independently. One pipeline failing never affects others.
**Per-pipeline failure recovery.** Unhandled exception → pipeline transitions to `Error`, releases NDI handles, logs with full context, auto-restarts after 1 s. Exponential backoff: 1, 2, 4, 8, 16 s, capped at 30 s. After 5 consecutive failures, stays `Error` and waits for operator action. Participant remains visible in the UI list so the operator can re-enable manually.
**Source disconnect (expected, not error).** Pipeline transitions to `NoSignal` after 2.5 s, keeps the assignment bound, keeps emitting the slate. If the source returns within 60 s, reconnects automatically. After 60 s the pipeline stops the sender to free NDI bandwidth; reconnects when the source reappears.
**NDI runtime version mismatch.** Detected at startup by `NdiRuntimeProbe`. Surfaces `EngineAlert.NdiRuntimeMismatch`. UI shows a banner with instructions to re-download Teams' NDI binaries (per source doc §7.2). Engine still attempts to run — it's a warning, not a hard fail.
**Output name collision on the LAN.** Logged and surfaced as `EngineAlert.OutputNameCollision`. v1.0 does not auto-rename; the operator picks unique names.
**Startup preflight.** Run before the UI accepts commands:
- NDI runtime present and queryable
- Smoke test: create + destroy one `NDIlib_send_create` instance
- Config file readable; corrupt or missing → fall back to defaults and log
- libyuv DLL loadable
- Write access to `%APPDATA%\TeamsISO\`
A failing preflight surfaces a single error dialog with a copyable diagnostic string; the app does not enter the main UI.
**Engine alert channel.** `IObservable<EngineAlert>` exposes structured alerts to the UI for banner display and to the log for ops.
## 7. Testing
**Three layers, three test projects.**
**Unit (`TeamsISO.Engine.Tests`)** — pure managed, no NDI runtime, fast (<1 s). Covers:
- `ParticipantTracker` rename heuristic (synthetic event streams).
- `FrameProcessor` timing logic against fake clock and fake interop. Asserts: 30 fps target / 24 fps incoming yields 30 frames/s with appropriate duplication; 60 fps target / 30 fps incoming doubles each frame; 2.5 s of silence triggers slate.
- `IsoPipeline` lifecycle (start → run → stop → restart on simulated fault, with backoff schedule asserted).
- `ConfigStore` round-trip (missing → defaults; save → reload identical; corrupt JSON → defaults + log).
- `NdiSourceParser` against a corpus of real Teams source strings (participant, active speaker, audio, screen share, multi-word names with parens, unicode).
**Integration (`TeamsISO.Engine.IntegrationTests`)** — Windows-only, real NDI runtime. Tagged `[Trait("requires", "ndi")]`.
- Spin up a NewTek NDI Test Pattern source as a synthetic participant; route through `IsoPipeline`; receive on a second NDI receiver; assert output stream existence, naming, framerate (measured over 5 s), resolution.
- Source disappear / reappear: stop the test pattern source mid-stream, assert pipeline transitions through `NoSignal`, restart the source, assert pipeline resumes.
- Output name collision: spin two pipelines with the same name, assert `EngineAlert.OutputNameCollision`.
**Manual / live test playbook (`docs/test-playbook.md`)** — checklist for verifying against real Teams meetings before each release.
**TDD discipline.** Every behavior in the engine starts as a failing unit test against fakes. NDI interop has an `INdiInterop` interface; production wires `NdiInteropPInvoke`, tests wire `FakeNdiInterop`.
**Coverage target.** 80% line coverage on `TeamsISO.Engine`, excluding the P/Invoke shim. Enforced in CI.
## 8. Build, packaging, distribution
**Source repo.** `forge.wilddragon.net/zgaetano/teamsiso`. Default branch `main`. Trunk-based with feature branches; PR review for engine-touching changes.
**Build.** MSBuild via `dotnet build` and `dotnet publish`. Solution targets `net8.0-windows` with `TargetPlatformVersion=10.0.19041.0`. `TeamsISO.App` publishes self-contained, single-file, ReadyToRun:
```
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishReadyToRun=true
```
**CI.** Forgejo Actions (GitHub-Actions-compatible). Two pipelines:
- `ci.yml` — every push and PR. Builds, runs unit tests, enforces coverage threshold, lints (treat-warnings-as-errors). Linux runner. Integration tests skip cleanly because `requires=ndi` is absent.
- `release.yml` — on tag push (`v*`). Windows runner with NDI runtime preinstalled. Builds release, runs unit + integration, builds WiX installer, attaches `.msi` to a Forgejo release.
**Versioning.** SemVer in `Directory.Build.props`. Flows to assembly metadata and installer. Tag `v1.0.0` triggers the release pipeline.
**Installer (WiX v5).** Produces `TeamsISO-x.y.z.msi`. Behavior:
- Detects NDI runtime via registry probe; if absent or older, prompts the operator to download from `ndi.video/tools/`. The runtime is not bundled — NDI's redistribution license requires user consent.
- Installs to `%ProgramFiles%\TeamsISO\`.
- Creates Start Menu shortcut, optional desktop shortcut.
- `%APPDATA%\TeamsISO\` is created on first run, not at install (per-user data, per-machine MSI).
- Adds Add/Remove Programs entry.
**NDI redistribution.** Per NDI SDK License v5 the runtime is not bundled. Detection is by registry key. Mismatches show a dialog with the official download link. Captured open task: legal review of NDI SDK License v5 before public v1.0 release.
**Distribution.** v1.0 ships as MSI from Forgejo releases. No auto-update in v1.0. The About dialog shows the current version and links to the Forgejo releases page.
## 9. Open tasks blocking v1.0 release
- Legal review of NDI SDK License v5 (per source doc §7.3) — required before public release; not required for development.
- Confirmation that the Microsoft Teams tenant has the admin policy enabling NDI broadcast (the relevant Teams meeting-policy setting; current name varies by Teams admin center version — verified against the live tenant during development).
- Selection of code-signing approach for v1.0 vs. v1.5 (currently deferred).
## 10. Out of scope for v1.0 (deferred)
- Per-stream framerate override (v1.5)
- Thumbnail previews (v1.5)
- GPU-accelerated frame scaling (v1.5)
- Multi-machine cluster auto-coordination (v2.0)
- OSC / WebSocket control API (v2.0)
- Code signing of the installer
- Auto-update
- Audio resampling
## 11. Glossary
- **NDI** — Network Device Interface (Vizrt/NewTek). LAN video transport protocol used by Teams' broadcast mode.
- **ISO** — In live production, an "isolated" feed of a single source, separate from the program mix. ZoomISO and TeamsISO produce per-participant ISO feeds.
- **Active speaker** — Teams' auto-mixed feed that follows whoever is talking. A separate NDI source from individual participant streams.
- **Slate** — a static frame (typically a solid color or "no signal" graphic) emitted when the source has stopped delivering frames.

View file

@ -1,106 +0,0 @@
# Spec: Embedded Teams meeting orchestration
**Status:** Draft. Authored 2026-05-08.
## Problem
Operators currently run two apps side by side: Microsoft Teams (which broadcasts
NDI from its meetings) and TeamsISO (which consumes those NDI sources, normalizes
them, and re-emits clean ISOs). Two issues fall out:
1. **Two interfaces, one workflow.** Switching between Teams to drive the meeting
and TeamsISO to drive ISO routing is friction during a live show.
2. **Teams' raw NDI bleeds into the production network.** Even with
TeamsISO running, Teams broadcasts its at-source-resolution / at-source-framerate
feeds on the same `Public` NDI group that switchers and recorders subscribe to.
Operators see "garbage" NDI sources alongside the clean TeamsISO outputs unless
they manually configure NDI groups (which most don't).
The user's stated north star: **let me host the meeting from inside TeamsISO. Run
Teams in the background. Show me one interface; expose only the proper outputs.**
## Constraints
- Microsoft Teams' NDI broadcast feature is desktop-only — the web client does not
broadcast NDI. We cannot replace Teams with a WebView2 view of `teams.microsoft.com`.
- We do not have a native Teams SDK. The Microsoft Graph API exposes some meeting
control (create/join/end), but in-call operations (mute, share, react) are largely
out of scope or behind enterprise tenant configuration.
- Win32 window embedding (`SetParent`) of a foreign process's window is technically
possible but produces a fragile UX — Teams will break out, render incorrectly, or
fail to honor parent-window inputs.
- NDI group routing is the standard primitive for hiding noisy producers. We
shipped this in commit `909237f`. It works.
## Architecture
A three-phase rollout. Each phase is shippable on its own.
### Phase E.1 — Teams launcher (launches Teams as a subprocess)
The minimum viable embed. TeamsISO grows a "Launch Teams" affordance on the rail.
Clicking it:
1. Reads the global `NdiGroupSettings.DiscoveryGroups` from `EngineConfig`. If
empty, defaults to `teamsiso-input`.
2. Opens **NDI Access Manager** (or programmatically writes its config) so Teams
broadcasts on `teamsiso-input` rather than `Public`.
3. Launches `ms-teams:` URI (or the `MSTeams.exe` directly for the new client) in
the background.
4. Marks Teams as "owned by TeamsISO" — the rail icon flips to "Stop Teams"; on
click, sends WM_CLOSE to the Teams main window.
5. Surfaces meeting health in the existing engine-status pill (e.g. "Teams running
• 2 participants").
Implementation effort: **a few hours.** Pure WPF + ProcessStartInfo + a small
NdiAccessManagerHelper that reads/writes Teams' config.
### Phase E.2 — Window orchestration
Teams' main window is repositioned + minimized when launched, so the user's
foreground experience is the TeamsISO window. Optional:
- Pin Teams to a hidden virtual desktop with `IVirtualDesktopManager`.
- Forward keyboard shortcuts (mute, camera, share) from TeamsISO into Teams via
`SendInput` while Teams' window is hidden.
Implementation effort: **a day.** Mostly Win32 plumbing.
### Phase E.3 — Meeting controls in TeamsISO's UI
A "Meeting" panel in the left rail that shows the active call's participant list
(with their mute / video state) and exposes Join/Leave/Mute/Share controls. Two
ways to plumb this:
- **Microsoft Graph API for the chrome.** Auth as the user via OAuth (interactive
device-code flow), poll `/me/onlineMeetings` for active meetings, render in
TeamsISO's UI. In-call mute/cam state is not exposed via Graph as of writing —
Phase E.3 would surface participant *presence* but not mic/cam controls.
- **Teams' UI Automation tree.** Walk Teams' window with `UIAutomation` to read
call state. Brittle but usable; what other "Teams remote" tools do.
Implementation effort: **a week per route.** Recommend Graph for read paths,
UIAutomation for write paths.
## Out of scope (for now)
- Hosting the actual meeting media stack (audio/video render, mixer, network).
Teams owns this and we don't want to.
- Replacing Teams entirely with our own SIP/WebRTC stack. That's a different
product.
## Decision required
User to confirm:
1. Phase E.1 first (just launcher + group routing). Yes/no.
2. Whether to use `ms-teams:` URI launch or the new MSTeams.exe binary path
(`%LOCALAPPDATA%\Microsoft\WindowsApps\ms-teams.exe`).
3. Whether to ship NDI Access Manager config writes, or just document the manual
steps and trust the user to set them once.
## Implementation log
- 2026-05-08: First version of this spec drafted while user is asleep.
- 2026-05-08: Phase E.1 partial — "Launch Teams" rail button shipped (commit
pending). Group-routing automation deferred until user confirms approach.

View file

@ -0,0 +1,207 @@
# Dragon-ISO Installer Design
**Date:** 2026-05-31
**Status:** Design Complete
**Audience:** End users downloading and installing Dragon-ISO
---
## Overview
Update the existing WiX Toolset v5 MSI installer from TeamsISO branding to Dragon-ISO. The installer provides a simple, professional Windows installation experience for end users with minimal choices (Next → Install).
**Key characteristics:**
- Per-machine installation to `Program Files\Wild Dragon\Dragon-ISO\`
- Simple UI (no customization dialogs)
- Start Menu and Desktop shortcuts
- Professional Add/Remove Programs (ARP) metadata
- Prerequisite detection for .NET 8 Desktop Runtime and NDI 6 Runtime (warn, don't block)
- Version: 1.0.0.0
---
## File and Naming Structure
### Files to Rename
- `installer/TeamsISO.Installer.wixproj``installer/Dragon-ISO.Installer.wixproj`
- `installer/Package.wxs` → remains (generic name, no change needed)
### Build Output
- **Current (TeamsISO):** `TeamsISO-Setup-1.0.0.0.msi`
- **Updated (Dragon-ISO):** `Dragon-ISO-Setup-1.0.0.0.msi`
### Asset References
- Icon: References `Dragon-ISO.ico` (already exists in `src/Dragon-ISO.App/Assets/`)
- App directory: `src/Dragon-ISO.App/` (updated from `src/TeamsISO.App/`)
- Publish output: `publish/Dragon-ISO/` (updated from `publish/TeamsISO/`)
---
## Branding Updates (in Package.wxs)
### Product Metadata
- **Package Name:** `"Dragon-ISO"` (from `"TeamsISO"`)
- **Manufacturer:** `"Wild Dragon LLC"` (unchanged)
- **UpgradeCode:** Keep existing GUID (allows upgrades from TeamsISO to Dragon-ISO)
- **Version:** `1.0.0.0`
### Summary Information
- **Description:** "Dragon-ISO — per-participant NDI ISO controller for Microsoft Teams. Splits each Teams participant into a normalized NDI source for vMix / OBS / Ross / hardware switchers."
- **Keywords:** "Dragon-ISO, NDI, Microsoft Teams, ISO recording, broadcast, live production, vMix, OBS, switcher, Wild Dragon"
### Add/Remove Programs (ARP) Metadata
- **Help Link:** `https://forge.wilddragon.net/zgaetano/dragon-iso`
- **About Link:** `https://wilddragon.net`
- **Support Contact:** `Wild Dragon LLC — support@wilddragon.net`
- **Comments:** "Dragon-ISO turns Microsoft Teams' raw NDI broadcast into clean, normalized, per-participant NDI sources for ingestion by a live-production switcher (vMix, OBS, Ross, hardware capture). Each participant gets an individually-addressable source with configurable framerate, resolution, aspect mode, and audio routing."
### Registry Keys
- **Location:** `Software\Wild Dragon\Dragon-ISO` (from `Software\Wild Dragon\TeamsISO`)
- Used for: Start Menu shortcut tracking, Desktop shortcut tracking, Install path storage
### Shortcuts
- **Start Menu:** `Wild Dragon → Dragon-ISO`
- **Desktop:** `Dragon-ISO` shortcut
- **Target:** `[INSTALLFOLDER]Dragon-ISO.exe`
- **Icon:** Dragon-ISO icon from assets
- **Description:** "Per-Participant NDI ISO Controller for Microsoft Teams"
---
## Installation Layout
### Directory Structure
```
Program Files\Wild Dragon\Dragon-ISO\
├── Dragon-ISO.exe (main executable)
├── DragonISO.dll (core assembly)
├── Assets\ (icons, fonts, resources)
├── Themes\ (XAML theme files)
├── [.NET dependencies] (runtime assets, supporting DLLs)
└── [all other published files]
```
### Shortcuts Created
- **Start Menu:** `%ProgramMenu%\Wild Dragon\Dragon-ISO.lnk`
- **Desktop:** `%UserProfile%\Desktop\Dragon-ISO.lnk`
- Both use stable GUIDs for reliable uninstall tracking
### Uninstall Behavior
- Standard Windows uninstall via Add/Remove Programs
- Removes all application files and shortcuts
- Removes registry entries under `Software\Wild Dragon\Dragon-ISO`
- **Preserves:** User config files in `%APPDATA%\Dragon-ISO\` (for future upgrades)
### Add/Remove Programs Entry
Users see:
- **Name:** Dragon-ISO
- **Version:** 1.0.0.0
- **Publisher:** Wild Dragon LLC
- **Help:** Link to Forge repository
- **About:** Link to wilddragon.net
- **Contact:** support@wilddragon.net
- **Icon:** Dragon-ISO app icon
---
## Build Process
### Prerequisites
- .NET 8 SDK
- WiX Toolset v5 (`dotnet workload install wix`)
### Build Steps
1. **Publish the application:**
```powershell
dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj `
-c Release -r win-x64 `
-o publish/Dragon-ISO
```
2. **Build the MSI:**
```powershell
dotnet build installer/Dragon-ISO.Installer.wixproj -c Release
```
3. **Output:**
- Location: `installer/bin/Release/Dragon-ISO-Setup-1.0.0.0.msi`
- Ready for end-user distribution
### Version Management
- MSI version is driven by the app's `.csproj` `<Version>` tag
- Update version once, builds auto-propagate to the MSI filename
- UpgradeCode remains constant across versions (enables upgrade detection)
### CI/CD Integration
- Update existing `.forgejo/workflows/` to use new project paths
- Scripts already handle publish → build → sign → release
- Just update path references and output filename
---
## Prerequisites & Runtime Detection
### .NET 8 Desktop Runtime
- **Check:** Registry lookup for installed .NET 8 Desktop Runtime
- **If missing:** Display warning dialog with link to download
- **Install behavior:** Continue installation anyway (user can install .NET 8 later)
- **At app launch:** App checks again; shows MessageBox with install link if still missing
### NDI 6 Runtime
- **Check:** Environment variable `NDI_RUNTIME_DIR_V6`
- **If missing:** Display warning dialog
- **Install behavior:** Continue installation anyway
- **At app launch:** App checks again; shows MessageBox with install link if still missing
**Rationale:** Allows staged deployment where IT can install Dragon-ISO first, then NDI/runtime later. End users get clear guidance at both install-time and app-launch time.
---
## Major Upgrade Behavior
The installer detects when a newer version is already installed and:
1. **Prevents downgrade:** Blocks installation of older versions with a clear error message
2. **In-place upgrade:** Newer installs replace older ones seamlessly
3. **Config preservation:** User configuration in `%APPDATA%\Dragon-ISO\` is preserved
4. **UpgradeCode:** Constant GUID ensures upgrade detection works (TeamsISO → Dragon-ISO, and future versions)
---
## Error Handling
### Installation Failures
- WiX standard rollback behavior: if installation fails, all changes are undone
- Clear error messages for common issues (insufficient permissions, disk space, etc.)
### Shortcut Creation
- If shortcut creation fails, installation continues (non-blocking)
- User can manually create shortcuts from `Program Files\Wild Dragon\Dragon-ISO\Dragon-ISO.exe`
### Registry Operations
- If registry write fails, installation continues (non-blocking)
- ARP entry may be incomplete but app is still functional
---
## Testing Checklist
- [ ] MSI builds successfully with new branding
- [ ] Install to clean system works end-to-end
- [ ] Shortcuts appear in Start Menu and on Desktop
- [ ] ARP entry shows correct metadata
- [ ] Uninstall removes all files and shortcuts
- [ ] Upgrade from TeamsISO to Dragon-ISO works
- [ ] .NET 8 runtime detection shows warning if missing
- [ ] NDI runtime detection shows warning if missing
- [ ] App launches after installation
- [ ] Help/About links in ARP entry work
---
## Future Enhancements (Out of Scope)
- Code signing for published MSI
- Automatic update checks via Squirrel.Windows
- Per-user installation option
- Silent/unattended install mode (for enterprise deployment)

View file

@ -1,34 +0,0 @@
# TeamsISO Manual Test Playbook
## Phase A — Engine foundation (CI)
- [ ] `dotnet build TeamsISO.Linux.slnf` succeeds with zero warnings.
- [ ] `dotnet test TeamsISO.Linux.slnf --filter "Category!=ndi&requires!=ndi"` passes.
- [ ] CI on Forgejo Actions is green at HEAD.
- [ ] Code coverage on `TeamsISO.Engine` is ≥80%.
## First Windows validation (after Phase B-2 ships)
Prerequisite: Windows 10/11 + NDI Runtime installed (https://ndi.video/tools/) + .NET 8 SDK.
- [ ] Clone the repo on the Windows machine: `git clone https://forge.wilddragon.net/zgaetano/teamsiso.git`.
- [ ] `dotnet build TeamsISO.sln --configuration Release` succeeds.
- [ ] `dotnet test --filter "requires=ndi"` passes against an NDI Test Pattern source (start the test pattern from the NDI Tools menu before running).
- [ ] Run `dotnet run --project src/TeamsISO.Console` — confirm the engine starts, version probe matches, and Ctrl+C exits cleanly.
## Live-meeting validation (after Phase C ships)
- [ ] Configure a Teams meeting with 3+ participants, with NDI broadcast enabled in Teams.
- [ ] `dotnet run --project src/TeamsISO.App` launches the WPF UI without an NDI runtime warning banner.
- [ ] Participants list populates within ~2 seconds of opening the app.
- [ ] Participant rename mid-meeting transfers the row's identity (the rename heuristic).
- [ ] Toggle ISO on for one participant. Confirm the named output appears in vMix / OBS / Studio Monitor on the same LAN.
- [ ] Change global framerate to 59.94 fps; click Apply. New ISOs honor the new rate.
- [ ] Disconnect one participant; confirm their ISO transitions to the no-signal slate within 2.5 s.
- [ ] Run for 30 minutes; check FramesDropped / FramesDuplicated counters in the engine log are reasonable.
## Pre-release checklist
- [ ] Legal review of NDI SDK License v5 complete (per spec §7.3).
- [ ] Code-signing decision confirmed (yes/no for v1.0).
- [ ] WiX installer produces a working MSI on a clean Windows machine.

View file

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Package</OutputType> <OutputType>Package</OutputType>
<OutputName>TeamsISO-Setup-$(Version)</OutputName> <OutputName>Dragon-ISO-Setup-$(Version)</OutputName>
<!-- 64-bit MSI; suppresses ICE80 on components in Program Files (x64). --> <!-- 64-bit MSI; suppresses ICE80 on components in Program Files (x64). -->
<Platform>x64</Platform> <Platform>x64</Platform>
@ -10,15 +10,15 @@
<!-- <!--
Built artifact location. The installer expects a published build of Built artifact location. The installer expects a published build of
TeamsISO.App rooted here. CI / local script: Dragon-ISO.App rooted here. CI / local script:
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj
-c Release -r win-x64 (with self contained false) -c Release -r win-x64 -self-contained false
-o $(SolutionDir)publish/TeamsISO -o $(SolutionDir)publish/Dragon-ISO
--> -->
<PublishDir>$(MSBuildThisFileDirectory)..\publish\TeamsISO\</PublishDir> <PublishDir>$(MSBuildThisFileDirectory)..\publish\Dragon-ISO\</PublishDir>
<!-- Pass MSBuild values into WiX preprocessor. --> <!-- Pass MSBuild values into WiX preprocessor. -->
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\TeamsISO.App\Assets\</DefineConstants> <DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\Dragon-ISO.App\Assets\</DefineConstants>
<!-- Code-signing hook (set externally for release builds; left empty for dev). --> <!-- Code-signing hook (set externally for release builds; left empty for dev). -->
<SignOutput Condition=" '$(SignOutput)' == '' ">false</SignOutput> <SignOutput Condition=" '$(SignOutput)' == '' ">false</SignOutput>
@ -32,4 +32,4 @@
<PackageReference Include="WixToolset.UI.wixext" Version="5.0.2" /> <PackageReference Include="WixToolset.UI.wixext" Version="5.0.2" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,22 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- <!--
TeamsISO — MSI installer (WiX v5) Dragon-ISO — MSI installer (WiX v5)
Produces: TeamsISO-Setup-<Version>.msi (per-machine install). Produces: Dragon-ISO-Setup-<Version>.msi (per-machine install).
Build: Build:
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj -c Release -r win-x64 (no self contained) -o publish/TeamsISO dotnet publish src/Dragon-ISO.App/Dragon-ISO.App.csproj -c Release -r win-x64 -p:SelfContained=false -o publish/Dragon-ISO
dotnet build installer/TeamsISO.Installer.wixproj -c Release dotnet build installer/Dragon-ISO.Installer.wixproj -c Release
Runtime expectations: Runtime expectations:
- .NET 8 Desktop runtime present on target (framework-dependent build) - .NET 8 Desktop runtime present on target (framework-dependent build)
- NDI 6 Runtime present — checked in CheckNdiRuntime; absence WARNS - NDI 6 Runtime present — checked in NdiRuntimeDirV6Search; absence WARNS
but does not block install (operators can install NDI after the app) but does not block install (operators can install NDI after the app)
Exe filename note:
Dragon-ISO.App.csproj sets AssemblyName=DragonISO (no hyphen — CLR
assembly names cannot contain hyphens). The published executable is
therefore DragonISO.exe. Shortcut targets reference DragonISO.exe.
--> -->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" <Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui"> xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package Name="TeamsISO" <Package Name="Dragon-ISO"
Manufacturer="Wild Dragon LLC" Manufacturer="Wild Dragon LLC"
Version="1.0.0.0" Version="1.0.0.0"
UpgradeCode="9F4A8B2C-1D3E-4A5B-9C6D-8E7F0A1B2C3D" UpgradeCode="9F4A8B2C-1D3E-4A5B-9C6D-8E7F0A1B2C3D"
@ -24,52 +29,83 @@
Compressed="yes" Compressed="yes"
InstallerVersion="500"> InstallerVersion="500">
<SummaryInformation Description="TeamsISO — Per-Participant NDI ISO Controller for Microsoft Teams" <!--
Manufacturer="Wild Dragon LLC" /> SummaryInformation fields surface in File Explorer's "Details" tab and
in the Windows Installer "About" dialog. Description and Keywords are
what users see if they right-click the MSI before installing; Comments
is the longer copy that appears alongside the version in some
installer dialogs.
-->
<SummaryInformation
Description="Dragon-ISO — per-participant NDI ISO controller for Microsoft Teams. Splits each Teams participant into a normalized NDI source for vMix / OBS / Ross / hardware switchers."
Manufacturer="Wild Dragon LLC"
Keywords="Dragon-ISO, NDI, Microsoft Teams, ISO recording, broadcast, live production, vMix, OBS, switcher, Wild Dragon" />
<!-- <!--
MajorUpgrade: a newer install replaces an older one in-place. MajorUpgrade: a newer install replaces an older one in-place. We
Disallow downgrades; users should uninstall the newer first. disallow downgrades because the engine config schema only carries a
forward-migration path; downgrading would leave operators with a
config the older binary doesn't understand.
--> -->
<MajorUpgrade DowngradeErrorMessage="A newer version of TeamsISO is already installed. Uninstall it before installing this older version." <MajorUpgrade DowngradeErrorMessage="A newer version of Dragon-ISO is already installed. Uninstall it before installing this older version."
Schedule="afterInstallInitialize" /> Schedule="afterInstallInitialize" />
<!-- <!--
Single MSI feature; users see only the install/uninstall screens. Single MSI feature; users see only the install/uninstall screens.
--> -->
<Feature Id="Main" Title="TeamsISO" Level="1"> <Feature Id="Main" Title="Dragon-ISO" Level="1">
<ComponentGroupRef Id="ApplicationFiles" /> <ComponentGroupRef Id="ApplicationFiles" />
<ComponentGroupRef Id="Shortcuts" /> <ComponentGroupRef Id="Shortcuts" />
<ComponentGroupRef Id="DesktopShortcut" />
<ComponentGroupRef Id="ArpEntry" /> <ComponentGroupRef Id="ArpEntry" />
</Feature> </Feature>
<!-- <!--
Friendly install UI. WixToolset.UI.wixext provides several flavors; Minimal install UI: Welcome/License -> Progress -> Finish.
WixUI_InstallDir lets the user pick the directory. No directory picker; installs to Program Files\Wild Dragon\Dragon-ISO.
--> -->
<ui:WixUI Id="WixUI_InstallDir" /> <ui:WixUI Id="WixUI_Minimal" />
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
<!-- <!--
ARP icon + about-box link. Add/Remove Programs metadata. ARPHELPLINK is the "Help" link; ARPURLINFOABOUT
is the manufacturer/about link; ARPCONTACT is the support contact shown
when the user clicks "Support information" from the ARP entry. ARPCOMMENTS
is the long description displayed in some Settings -> Apps surfaces.
--> -->
<Property Id="ARPHELPLINK" Value="https://wilddragon.net" /> <Property Id="ARPHELPLINK" Value="https://forge.wilddragon.net/zgaetano/dragon-iso" />
<Property Id="ARPURLINFOABOUT" Value="https://wilddragon.net" /> <Property Id="ARPURLINFOABOUT" Value="https://wilddragon.net" />
<!-- ARPNOMODIFY is set by WixUI_InstallDir; don't redeclare. --> <Property Id="ARPCONTACT" Value="Wild Dragon LLC — support@wilddragon.net" />
<Property Id="ARPCOMMENTS" Value="Dragon-ISO turns Microsoft Teams' raw NDI broadcast into clean, normalized, per-participant NDI sources for ingestion by a live-production switcher (vMix, OBS, Ross, hardware capture). Each participant gets an individually-addressable source with configurable framerate, resolution, aspect mode, and audio routing." />
<!-- ARPNOMODIFY is set by WixUI_Minimal. Do not redeclare. -->
<Property Id="ARPNOREPAIR" Value="1" /> <Property Id="ARPNOREPAIR" Value="1" />
<!-- <!--
ARP icon references the same .ico the WPF host uses. WiX requires the ARP icon: references the same .ico the WPF host uses. WiX requires the
icon resource to live next to the wxs OR be reachable at build time; icon resource to live next to the wxs OR be reachable at build time;
we point at the published copy under src/TeamsISO.App/Assets so the icon we point at the source copy under src/Dragon-ISO.App/Assets so the icon
embedded in the MSI matches the icon in the running exe. embedded in the MSI matches the icon in the running exe.
--> -->
<Icon Id="TeamsISOIcon" SourceFile="$(var.AssetsDir)teamsiso.ico" /> <Icon Id="DragonISOIcon" SourceFile="$(var.AssetsDir)Dragon-ISO.ico" />
<Property Id="ARPPRODUCTICON" Value="TeamsISOIcon" /> <Property Id="ARPPRODUCTICON" Value="DragonISOIcon" />
<!--
.NET 8 Desktop Runtime detection. The .NET apphost will surface a
"framework not found" dialog naturally if the runtime is absent;
this property is available for future conditional logic.
VBScript-based install-time dialogs are deprecated in WiX v5 / Windows;
rewriting in C++ is overkill for a soft warning on a soft dependency.
-->
<Property Id="DOTNET8DESKTOPRUNTIME" Value="0">
<RegistrySearch Id="DotNet8DesktopRuntimeSearch"
Root="HKLM"
Key="SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App\8.0.0"
Name="Version"
Type="raw" />
</Property>
<!-- <!--
NDI Runtime detection. We check for NDI_RUNTIME_DIR_V6 in the system NDI Runtime detection. We check for NDI_RUNTIME_DIR_V6 in the system
environment block. Missing → warn during install, don't block. The environment block. Missing -> warn during install, don't block. The
engine surfaces a clear MessageBox with an install-NDI link at first engine surfaces a clear MessageBox with an install-NDI link at first
launch if the runtime really isn't there. launch if the runtime really isn't there.
--> -->
@ -90,11 +126,11 @@
--> -->
<!-- <!--
Install layout under Program Files\Wild Dragon\TeamsISO. Install layout under Program Files\Wild Dragon\Dragon-ISO.
--> -->
<StandardDirectory Id="ProgramFiles64Folder"> <StandardDirectory Id="ProgramFiles64Folder">
<Directory Id="ManufacturerFolder" Name="Wild Dragon"> <Directory Id="ManufacturerFolder" Name="Wild Dragon">
<Directory Id="INSTALLFOLDER" Name="TeamsISO" /> <Directory Id="INSTALLFOLDER" Name="Dragon-ISO" />
</Directory> </Directory>
</StandardDirectory> </StandardDirectory>
@ -112,22 +148,33 @@
</ComponentGroup> </ComponentGroup>
<!-- <!--
Start Menu shortcut to the WPF host. KeyPath sits on a registry Start Menu and Desktop shortcuts: direct .exe targets.
value so component identity is stable across upgrades.
Don't wrap the Target in runas.exe /trustlevel:0x20000 (or anything
else that demotes the spawned process). The SAFER-restricted token
breaks .NET 8 WPF apphost startup: the process appears alive with
a window, but no managed code past BAML parse executes. Verified
empirically 2026-05-16; letting Dragon-ISO inherit the launching
token (medium or high integrity, doesn't matter) is the correct
behavior. NDI discovery works fine at either integrity level.
Exe filename: AssemblyName=DragonISO (no hyphen), so target is
DragonISO.exe not Dragon-ISO.exe.
--> -->
<ComponentGroup Id="Shortcuts" Directory="WildDragonStartMenuFolder"> <ComponentGroup Id="Shortcuts" Directory="WildDragonStartMenuFolder">
<Component Id="StartMenuShortcut" Guid="*"> <Component Id="StartMenuShortcut" Guid="*">
<Shortcut Id="StartMenuTeamsISO" <Shortcut Id="StartMenuDragonISO"
Name="TeamsISO" Name="Dragon-ISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams" Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]TeamsISO.exe" Target="[INSTALLFOLDER]DragonISO.exe"
WorkingDirectory="INSTALLFOLDER" /> WorkingDirectory="INSTALLFOLDER"
Icon="DragonISOIcon" />
<!-- Required by ICE64: Start Menu folder must be cleaned on uninstall. --> <!-- Required by ICE64: Start Menu folder must be cleaned on uninstall. -->
<RemoveFolder Id="RemoveWildDragonStartMenuFolder" <RemoveFolder Id="RemoveWildDragonStartMenuFolder"
Directory="WildDragonStartMenuFolder" Directory="WildDragonStartMenuFolder"
On="uninstall" /> On="uninstall" />
<RegistryValue Root="HKCU" <RegistryValue Root="HKCU"
Key="Software\Wild Dragon\TeamsISO" Key="Software\Wild Dragon\Dragon-ISO"
Name="StartMenuShortcut" Name="StartMenuShortcut"
Type="integer" Type="integer"
Value="1" Value="1"
@ -135,15 +182,33 @@
</Component> </Component>
</ComponentGroup> </ComponentGroup>
<StandardDirectory Id="DesktopFolder" />
<ComponentGroup Id="DesktopShortcut" Directory="DesktopFolder">
<Component Id="DesktopShortcutComponent" Guid="*">
<Shortcut Id="DesktopDragonISO"
Name="Dragon-ISO"
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
Target="[INSTALLFOLDER]DragonISO.exe"
WorkingDirectory="INSTALLFOLDER"
Icon="DragonISOIcon" />
<RegistryValue Root="HKCU"
Key="Software\Wild Dragon\Dragon-ISO"
Name="DesktopShortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</ComponentGroup>
<!-- <!--
ARP icon registry entry. Optional — the MSI auto-fills most ARP ARP icon registry entry. Optional: the MSI auto-fills most ARP
fields from the Package element. We only need to point at the fields from the Package element. We only need to store the install
executable for the ARP icon. path for diagnostic / uninstall tooling.
--> -->
<ComponentGroup Id="ArpEntry" Directory="INSTALLFOLDER"> <ComponentGroup Id="ArpEntry" Directory="INSTALLFOLDER">
<Component Id="ArpIconRegistry" Guid="*"> <Component Id="ArpIconRegistry" Guid="*">
<RegistryValue Root="HKLM" <RegistryValue Root="HKLM"
Key="Software\Wild Dragon\TeamsISO" Key="Software\Wild Dragon\Dragon-ISO"
Name="InstallPath" Name="InstallPath"
Type="string" Type="string"
Value="[INSTALLFOLDER]" Value="[INSTALLFOLDER]"
@ -152,4 +217,4 @@
</ComponentGroup> </ComponentGroup>
</Package> </Package>
</Wix> </Wix>

View file

@ -1,9 +1,9 @@
<Window x:Class="TeamsISO.App.AboutWindow" <Window x:Class="DragonISO.App.AboutWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework" xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="About TeamsISO" Title="About DragonISO"
Icon="/Assets/teamsiso.ico" Icon="/Assets/DragonISO.ico"
Width="460" Height="500" Width="460" Height="500"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
WindowStyle="None" WindowStyle="None"
@ -36,7 +36,7 @@
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Text="About TeamsISO" <TextBlock Text="About DragonISO"
Style="{StaticResource Wd.Text.Caption}" Style="{StaticResource Wd.Text.Caption}"
Margin="20,12,0,0" Margin="20,12,0,0"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
@ -62,7 +62,7 @@
Margin="0,0,0,16" Margin="0,0,0,16"
RenderOptions.BitmapScalingMode="HighQuality"/> RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="TeamsISO" <TextBlock Text="DragonISO"
Style="{StaticResource Wd.Text.Title}" Style="{StaticResource Wd.Text.Title}"
FontSize="28" FontSize="28"
HorizontalAlignment="Center"/> HorizontalAlignment="Center"/>
@ -146,12 +146,12 @@
Click="OnOpenLogs" Click="OnOpenLogs"
Padding="14,6" Padding="14,6"
Margin="0,0,8,0" Margin="0,0,8,0"
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Logs in Explorer"/> ToolTip="Open %LOCALAPPDATA%\DragonISO\Logs in Explorer"/>
<Button Style="{StaticResource Wd.Button.Ghost}" <Button Style="{StaticResource Wd.Button.Ghost}"
Content="Notes" Content="Notes"
Click="OnOpenNotes" Click="OnOpenNotes"
Padding="14,6" Padding="14,6"
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Notes in Explorer"/> ToolTip="Open %LOCALAPPDATA%\DragonISO\Notes in Explorer"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>

View file

@ -1,13 +1,13 @@
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using System.Runtime.Versioning; using System.Runtime.Versioning;
using System.Windows; using System.Windows;
using System.Windows.Navigation; using System.Windows.Navigation;
using TeamsISO.App.Services; using DragonISO.App.Services;
using TeamsISO.Engine.NdiInterop; using DragonISO.Engine.NdiInterop;
namespace TeamsISO.App; namespace DragonISO.App;
/// <summary> /// <summary>
/// Modal "About" dialog. Surfaces enough info that a user filing a support ticket /// Modal "About" dialog. Surfaces enough info that a user filing a support ticket
@ -65,7 +65,7 @@ public partial class AboutWindow : Window
/// <summary> /// <summary>
/// Quick-jump: open a path in Explorer. Creates the directory if missing /// Quick-jump: open a path in Explorer. Creates the directory if missing
/// (operator might click "Recordings" before any have been made). Best- /// (operator might click "Recordings" before any have been made). Best-
/// effort Explorer launch failures don't surface a dialog. /// effort — Explorer launch failures don't surface a dialog.
/// </summary> /// </summary>
private static void OpenInExplorer(string path) private static void OpenInExplorer(string path)
{ {
@ -87,18 +87,18 @@ public partial class AboutWindow : Window
private void OnOpenLogs(object sender, RoutedEventArgs e) => private void OnOpenLogs(object sender, RoutedEventArgs e) =>
OpenInExplorer(Path.Combine( OpenInExplorer(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Logs")); "Dragon-ISO", "Logs"));
// OnOpenRecordings removed recording feature axed. // OnOpenRecordings removed — recording feature axed.
private void OnOpenNotes(object sender, RoutedEventArgs e) => private void OnOpenNotes(object sender, RoutedEventArgs e) =>
OpenInExplorer(Path.Combine( OpenInExplorer(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Notes")); "Dragon-ISO", "Notes"));
/// <summary> /// <summary>
/// Build the diagnostic bundle and tell the operator where it landed. The /// Build the diagnostic bundle and tell the operator where it landed. The
/// bundle is just zipped logs / config / presets no screenshots, no /// bundle is just zipped logs / config / presets — no screenshots, no
/// memory dumps. Intended to be attached to a bug report. /// memory dumps. Intended to be attached to a bug report.
/// </summary> /// </summary>
private void OnExportDiagnostics(object sender, RoutedEventArgs e) private void OnExportDiagnostics(object sender, RoutedEventArgs e)
@ -109,7 +109,7 @@ public partial class AboutWindow : Window
var open = MessageBox.Show( var open = MessageBox.Show(
this, this,
$"Diagnostic bundle written to:\n\n{path}\n\nOpen the containing folder?", $"Diagnostic bundle written to:\n\n{path}\n\nOpen the containing folder?",
"TeamsISO — Diagnostics exported", "Dragon-ISO — Diagnostics exported",
MessageBoxButton.YesNo, MessageBoxButton.YesNo,
MessageBoxImage.Information); MessageBoxImage.Information);
if (open == MessageBoxResult.Yes) if (open == MessageBoxResult.Yes)
@ -131,7 +131,7 @@ public partial class AboutWindow : Window
MessageBox.Show( MessageBox.Show(
this, this,
$"Diagnostic export failed.\n\n{ex.Message}", $"Diagnostic export failed.\n\n{ex.Message}",
"TeamsISO — Diagnostic export", "Dragon-ISO — Diagnostic export",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
} }
@ -158,7 +158,7 @@ public partial class AboutWindow : Window
$"{result.Message}\n\n" + $"{result.Message}\n\n" +
$"You're on {result.CurrentVersion}; latest is {result.LatestTag}.\n\n" + $"You're on {result.CurrentVersion}; latest is {result.LatestTag}.\n\n" +
"Open the releases page to download the new MSI?", "Open the releases page to download the new MSI?",
"TeamsISO — Update available", "Dragon-ISO — Update available",
MessageBoxButton.YesNo, MessageBoxButton.YesNo,
MessageBoxImage.Information); MessageBoxImage.Information);
if (open == MessageBoxResult.Yes) if (open == MessageBoxResult.Yes)
@ -169,7 +169,7 @@ public partial class AboutWindow : Window
MessageBox.Show( MessageBox.Show(
this, this,
result.Message ?? "You're on the latest release.", result.Message ?? "You're on the latest release.",
"TeamsISO — Up to date", "Dragon-ISO — Up to date",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Information); MessageBoxImage.Information);
break; break;
@ -179,7 +179,7 @@ public partial class AboutWindow : Window
MessageBox.Show( MessageBox.Show(
this, this,
$"Couldn't check for updates.\n\n{result.Message}", $"Couldn't check for updates.\n\n{result.Message}",
"TeamsISO — Update check failed", "Dragon-ISO — Update check failed",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
break; break;
@ -193,7 +193,7 @@ public partial class AboutWindow : Window
/// <summary> /// <summary>
/// Open the company site in the default browser. We intentionally use the /// Open the company site in the default browser. We intentionally use the
/// shell's URL handler rather than a tab inside the app this is a /// shell's URL handler rather than a tab inside the app — this is a
/// "tell me more" link, not a workflow. /// "tell me more" link, not a workflow.
/// </summary> /// </summary>
private void OnWebsiteClick(object sender, RoutedEventArgs e) private void OnWebsiteClick(object sender, RoutedEventArgs e)

View file

@ -0,0 +1,250 @@
using System.IO;
using System.Windows;
using System.Windows.Interop;
using Microsoft.Extensions.Logging;
using DragonISO.App.Services;
using DragonISO.App.ViewModels;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Interop;
using DragonISO.Engine.NdiInterop;
using DragonISO.Engine.Persistence;
using DragonISO.Engine.Pipeline;
namespace DragonISO.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 Dragon-ISO
/// instance per Windows user. Two Dragon-ISOs on the same machine for
/// the same user race over the NDI finder, the NDI senders, and
/// %APPDATA%\Dragon-ISO\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.DragonISO.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.DragonISO.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(
"Dragon-ISO 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,
"Dragon-ISO — 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),
"Dragon-ISO", "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
/// Dragon-ISO" experience. Fire-and-forget — a slow Teams launch must
/// not delay Dragon-ISO'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 Dragon-ISO" rule
// applies even when Teams was launched externally.
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
}
}
}

View file

@ -0,0 +1,93 @@
using System.IO;
using System.Windows;
using System.Windows.Threading;
using Microsoft.Extensions.Logging;
namespace DragonISO.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%\Dragon-ISO\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),
"Dragon-ISO", "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
? "Dragon-ISO encountered an unrecoverable error and will exit."
: "Dragon-ISO 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, "Dragon-ISO — 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.
}
}
}

View file

@ -0,0 +1,42 @@
using Microsoft.Extensions.Logging;
using DragonISO.App.Services;
namespace DragonISO.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");
}
});
}
}

View file

@ -1,4 +1,4 @@
<Application x:Class="TeamsISO.App.App" <Application x:Class="DragonISO.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources> <Application.Resources>

View file

@ -0,0 +1,305 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using Microsoft.Extensions.Logging;
using DragonISO.App.ViewModels;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Logging;
using DragonISO.Engine.NdiInterop;
// Application + MessageBox aliases live in GlobalUsings.cs (project-wide).
// Don't redeclare here — Roslyn errors with CS1537 on duplicate alias.
namespace DragonISO.App;
// Split across partial files by responsibility:
// • App.xaml.cs — class skeleton, OnStartup (the wiring
// pipeline that calls into the partials),
// OnExit, CLI arg parser.
// • App.Bootstrap.cs — the linear setup steps OnStartup walks
// (single-instance gate, NDI interop, engine,
// main window, control surface, tray icon,
// onboarding, Teams auto-launch).
// • App.CrashHandlers.cs — AppDomain / Dispatcher / Task exception
// handlers + crash dialog + LogDirectory.
// • App.UpdateCheckBootstrap.cs — the background update-checker
// kickoff (24h-throttled).
public partial class App : Application
{
/// <summary>
/// Per-user mutex name. Including the username (acting as a SID proxy) ensures two
/// different Windows users can each run Dragon-ISO on the same machine, while one
/// user can't spawn duplicate instances that would contend over the NDI runtime
/// and the shared %APPDATA%\Dragon-ISO\config.json.
///
/// The "Global\" prefix puts the named object in the system-wide namespace
/// (not session-local or integrity-isolated). This matters because when an
/// admin user has UAC effectively disabled, launches from different parents
/// (elevated File Explorer, non-elevated shell, etc.) can land in slightly
/// different security contexts. A "Local\" mutex was being created in
/// different views per integrity level on some boxes, letting two Dragon-ISO
/// instances run concurrently — the second's REST surface couldn't bind port
/// 9755 (already held) and its Serilog file sink couldn't open the daily log
/// (already held with shared=false), producing a window that looked like
/// the app but had no engine attached. Global\ closes that gap.
/// </summary>
private static readonly string SingleInstanceMutexName =
$"Global\\WildDragon.DragonISO.SingleInstance.{Environment.UserName}";
private System.Threading.Mutex? _singleInstanceMutex;
private bool _ownsSingleInstanceMutex;
private ThreadMessageEventHandler? _bringToFrontHandler;
private ILoggerFactory? _loggerFactory;
private NdiInteropPInvoke? _interop;
private IsoController? _controller;
private MainViewModel? _viewModel;
private DragonISO.App.Services.ControlSurfaceServer? _controlSurface;
private DragonISO.App.Services.OscBridge? _oscBridge;
// _diskSpaceWatcher removed — only existed to auto-disable recording at low free space.
private DragonISO.App.Services.TrayIconHost? _trayIcon;
/// <summary>
/// REST control surface lifetime. Lives on App so the settings VM can flip
/// it on/off without us plumbing yet another DI dependency through MainViewModel.
/// Null between process startup and the OnStartup wire-up, and after OnExit.
/// </summary>
internal DragonISO.App.Services.ControlSurfaceServer? ControlSurface => _controlSurface;
/// <summary>OSC bridge (UDP) lifetime — same lifecycle pattern as the REST surface.</summary>
internal DragonISO.App.Services.OscBridge? OscBridge => _oscBridge;
/// <summary>Tray-icon host. Exposed so the settings VM can flip the minimize-to-tray toggle.</summary>
internal DragonISO.App.Services.TrayIconHost? TrayIcon => _trayIcon;
[DllImport("user32.dll")]
private static extern uint RegisterWindowMessageW(string lpString);
[DllImport("user32.dll")]
private static extern int PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern int SendNotifyMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
private const IntPtr HWND_BROADCAST = -1;
protected override async void OnStartup(StartupEventArgs e)
{
// RAW TRACE — captures startup BEFORE Serilog comes up. Helps diagnose
// launches where the Serilog log stays empty (silent file-sink failure,
// pre-logger crash, weird parent-spawn environment, etc.). Writes to
// %LOCALAPPDATA%\Dragon-ISO\startup-trace.log.
var parentName = "(unknown)";
try { parentName = TryGetParentProcessName() ?? "(null)"; } catch { }
StartupTrace.Write($"OnStartup ENTER. exe={Environment.ProcessPath} parent={parentName} args=[{string.Join(' ', e.Args)}]");
try
{
using var id = System.Security.Principal.WindowsIdentity.GetCurrent();
var pr = new System.Security.Principal.WindowsPrincipal(id);
StartupTrace.Write($"identity user={id.Name} isAdmin={pr.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)} integrity-token={id.User}");
}
catch (Exception ex) { StartupTrace.Write($"identity probe FAILED: {ex}"); }
base.OnStartup(e);
StartupTrace.Write("base.OnStartup returned");
// De-elevation via runas /trustlevel:0x20000 was tried (commits 191b2c5,
// 54ee578) on the theory that elevated Dragon-ISO can't discover NDI
// sources. THAT THEORY WAS WRONG — verified 2026-05-16 that elevated
// Dragon-ISO discovers NDI sources fine. The SAFER-restricted token
// produced by runas /trustlevel was the ACTUAL cause of every "no
// participants" report: it breaks .NET 8 WPF startup such that the
// process appears alive with a window but the managed code never gets
// past BAML parsing. No logs, no port binds. We now skip the check
// entirely. The --keep-elevation arg, originally an opt-out, is now
// accepted but no-op'd (kept to avoid breaking any operator scripts).
if (Array.IndexOf(e.Args, "--keep-elevation") >= 0)
StartupTrace.Write("--keep-elevation flag present (no-op now; de-elevation removed)");
// Crash diagnostics — wire the three exception channels WPF leaves open by
// default to a single handler that logs Fatal to Serilog.
AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandled;
DispatcherUnhandledException += OnDispatcherUnhandled;
System.Threading.Tasks.TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
StartupTrace.Write("crash handlers registered");
try { DragonISO.App.Services.ThemeManager.Current.Apply(); StartupTrace.Write("ThemeManager.Apply OK"); }
catch (Exception ex) { StartupTrace.Write($"ThemeManager.Apply THREW: {ex}"); }
// Single-instance gate. Trace the mutex acquisition.
bool acquired = false;
try { acquired = TryAcquireSingleInstance(); } catch (Exception ex) { StartupTrace.Write($"TryAcquireSingleInstance THREW: {ex}"); }
StartupTrace.Write($"TryAcquireSingleInstance returned: {acquired}");
if (!acquired)
{
StartupTrace.Write("not first instance — Shutdown(0)");
Shutdown(0);
return;
}
try
{
StartupTrace.Write("Bootstrap try-block ENTER");
_loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
StartupTrace.Write("EngineLogging.CreateDefault OK");
var logger = _loggerFactory.CreateLogger<App>();
logger.LogInformation(
"DragonISO.App starting up. Build: {Version}. Process: {Pid}.",
typeof(App).Assembly.GetName().Version,
Environment.ProcessId);
StartupTrace.Write("Serilog first write attempted");
if (!TryBootstrapNdiInterop())
{
StartupTrace.Write("TryBootstrapNdiInterop returned false — Shutdown(2)");
Shutdown(2);
return;
}
StartupTrace.Write("TryBootstrapNdiInterop OK");
BootstrapEngine();
StartupTrace.Write("BootstrapEngine OK");
var window = ConstructAndShowMainWindow();
StartupTrace.Write("ConstructAndShowMainWindow OK (window shown)");
BootstrapControlSurfaceServices();
StartupTrace.Write("BootstrapControlSurfaceServices OK");
BootstrapTrayIcon(window);
StartupTrace.Write("BootstrapTrayIcon OK");
TryShowOnboarding(window);
StartupTrace.Write("TryShowOnboarding returned");
ApplyCommandLineArgs(e.Args);
StartupTrace.Write("ApplyCommandLineArgs OK");
StartupTrace.Write("about to await _viewModel.InitializeAsync");
await _viewModel!.InitializeAsync(CancellationToken.None);
StartupTrace.Write("_viewModel.InitializeAsync COMPLETED");
TryAutoLaunchTeams(logger);
StartBackgroundUpdateCheck(logger);
StartupTrace.Write("OnStartup COMPLETE");
// 5-second post-init participant probe — tells us whether discovery
// is actually producing rows once the engine is up.
_ = Task.Run(async () =>
{
await Task.Delay(5000);
try
{
var n = await Dispatcher.InvokeAsync(() => _viewModel?.Participants.Count ?? -1);
StartupTrace.Write($"+5s after init: vm.Participants.Count={n}");
}
catch (Exception ex) { StartupTrace.Write($"+5s probe THREW: {ex.Message}"); }
});
}
catch (Exception ex)
{
StartupTrace.Write($"OnStartup CATCH: {ex}");
try { _loggerFactory?.CreateLogger<App>().LogCritical(ex, "OnStartup failed before main loop"); }
catch { /* defensive */ }
MessageBox.Show(
"Dragon-ISO failed to start.\n\nDetails: " + ex,
"Dragon-ISO — startup error",
MessageBoxButton.OK,
MessageBoxImage.Error);
Shutdown(1);
}
}
// De-elevation helpers (ShouldDeElevate, TryDeElevateAndExit, the
// Dragon-ISO_RELAUNCHED env var) were removed 2026-05-16. The whole
// pattern was treating a symptom that wasn't actually the problem
// (elevation does NOT break NDI Find); the SAFER token produced by
// runas /trustlevel:0x20000 broke .NET 8 WPF startup itself, so the
// "fix" was the actual bug. See git log for the dead code, App.xaml.cs
// commit history around 191b2c5 / 54ee578 / removal.
/// <summary>
/// Look up our parent process's image name (without extension). Returns
/// null if it can't be determined (PID gone, denied, etc.).
/// </summary>
private static string? TryGetParentProcessName()
{
try
{
var pid = Environment.ProcessId;
using var search = new System.Management.ManagementObjectSearcher(
$"SELECT ParentProcessId FROM Win32_Process WHERE ProcessId={pid}");
foreach (var m in search.Get())
{
var ppid = Convert.ToInt32(m["ParentProcessId"]);
using var parent = System.Diagnostics.Process.GetProcessById(ppid);
return parent.ProcessName;
}
}
catch { /* fall through */ }
return null;
}
// TryDeElevateAndExit removed 2026-05-16 (see comment above ShouldDeElevate).
/// <summary>
/// Parse the supported CLI flags. Currently:
/// <c>--apply-preset NAME</c> — apply the named preset once participants
/// populate. Equivalent to running Dragon-ISO and clicking Presets → select →
/// Apply, but driven from a desktop shortcut.
/// Unrecognized flags are silently ignored — operators using shortcut.lnk
/// files don't need to fight argument parsers.
/// </summary>
private void ApplyCommandLineArgs(string[] args)
{
if (_viewModel is null) return;
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--apply-preset":
if (i + 1 < args.Length && !string.IsNullOrEmpty(args[i + 1]))
{
_viewModel.RequestApplyPresetOnStartup(args[i + 1]);
i++; // consume the value
}
break;
}
}
}
// Crash handlers (OnAppDomainUnhandled / OnDispatcherUnhandled /
// OnUnobservedTaskException / TryLogFatal / TryShowCrashDialog / LogDirectory)
// live in App.CrashHandlers.cs.
protected override async void OnExit(ExitEventArgs e)
{
try
{
_trayIcon?.Dispose();
if (_controlSurface is not null)
await _controlSurface.DisposeAsync();
if (_oscBridge is not null)
await _oscBridge.DisposeAsync();
_viewModel?.Dispose();
if (_controller is not null)
await _controller.DisposeAsync();
_interop?.Dispose();
_loggerFactory?.Dispose();
}
catch
{
// Best-effort shutdown
}
finally
{
// Unsubscribe the bring-to-front filter so the delegate doesn't outlive
// the App; ComponentDispatcher is process-static.
if (_bringToFrontHandler is not null)
{
ComponentDispatcher.ThreadFilterMessage -= _bringToFrontHandler;
_bringToFrontHandler = null;
}
// Release the Mutex iff we acquired it. The "lost the race" path above
// sets _ownsSingleInstanceMutex=false and we skip ReleaseMutex (which
// would throw ApplicationException on an unowned Mutex).
try { if (_ownsSingleInstanceMutex) _singleInstanceMutex?.ReleaseMutex(); }
catch { /* defensive: already-released or invalid handle */ }
_singleInstanceMutex?.Dispose();
}
base.OnExit(e);
}
}

View file

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View file

@ -0,0 +1,34 @@
from PIL import Image
import os
# We treat the navy-blue dragon-mark.png as a silhouette source: anything with
# nontrivial alpha is "dragon", everything else stays transparent. We emit a
# pure-black and pure-white variant, tightly cropped to the actual content
# bbox so they center cleanly when used as a watermark.
ROOT = os.path.dirname(os.path.abspath(__file__))
src_path = os.path.join(ROOT, "dragon-mark.png")
src = Image.open(src_path).convert("RGBA")
alpha = src.split()[-1]
# Threshold to drop anti-alias fringe that can fool getbbox into reporting
# the whole canvas as "in".
mask = alpha.point(lambda v: 255 if v > 16 else 0)
bbox = mask.getbbox()
print("content bbox =", bbox, "size =", (bbox[2] - bbox[0], bbox[3] - bbox[1]))
cropped = src.crop(bbox)
_, _, _, ca = cropped.split()
for name, rgb in (("black", (0, 0, 0)), ("white", (255, 255, 255))):
flat = Image.merge(
"RGBA",
(
Image.new("L", cropped.size, rgb[0]),
Image.new("L", cropped.size, rgb[1]),
Image.new("L", cropped.size, rgb[2]),
ca,
),
)
out_path = os.path.join(ROOT, f"dragon-mark-{name}.png")
flat.save(out_path, "PNG", optimize=True)
print("wrote", out_path, flat.size)

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View file

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View file

@ -1,8 +1,8 @@
using System.Globalization; using System.Globalization;
using System.Windows; using System.Windows;
using System.Windows.Data; using System.Windows.Data;
namespace TeamsISO.App.Converters; namespace DragonISO.App.Converters;
[ValueConversion(typeof(bool), typeof(Visibility))] [ValueConversion(typeof(bool), typeof(Visibility))]
public sealed class BoolToVisibilityConverter : IValueConverter public sealed class BoolToVisibilityConverter : IValueConverter

View file

@ -1,9 +1,9 @@
using System.Collections; using System.Collections;
using System.Globalization; using System.Globalization;
using System.Windows; using System.Windows;
using System.Windows.Data; using System.Windows.Data;
namespace TeamsISO.App.Converters; namespace DragonISO.App.Converters;
/// <summary> /// <summary>
/// Maps a collection (or its count) to <see cref="Visibility"/>. Pass /// Maps a collection (or its count) to <see cref="Visibility"/>. Pass

View file

@ -1,8 +1,8 @@
using System.Globalization; using System.Globalization;
using System.Windows.Data; using System.Windows.Data;
using TeamsISO.Engine.Domain; using DragonISO.Engine.Domain;
namespace TeamsISO.App.Converters; namespace DragonISO.App.Converters;
/// <summary> /// <summary>
/// Renders engine enum values into operator-friendly strings. /// Renders engine enum values into operator-friendly strings.
@ -39,7 +39,7 @@ public sealed class EnumDescriptionConverter : IValueConverter
}, },
AudioMode m => m switch AudioMode m => m switch
{ {
AudioMode.Auto => "Auto (isolated mixed fallback)", AudioMode.Auto => "Auto (isolated → mixed fallback)",
AudioMode.Isolated => "Isolated", AudioMode.Isolated => "Isolated",
AudioMode.Mixed => "Mixed", AudioMode.Mixed => "Mixed",
_ => m.ToString() _ => m.ToString()

View file

@ -1,26 +1,26 @@
using System.Globalization; using System.Globalization;
using System.Windows.Data; using System.Windows.Data;
namespace TeamsISO.App.Converters; namespace DragonISO.App.Converters;
/// <summary> /// <summary>
/// Converts a display name to up to two uppercase initials for an avatar bubble. /// Converts a display name to up to two uppercase initials for an avatar bubble.
/// "Brendon Power" → "BP". "(Local)" → "L". Falls back to "·" for empty inputs. /// "Brendon Power" → "BP". "(Local)" → "L". Falls back to "·" for empty inputs.
/// </summary> /// </summary>
public sealed class InitialsConverter : IValueConverter public sealed class InitialsConverter : IValueConverter
{ {
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{ {
var s = value as string; var s = value as string;
if (string.IsNullOrWhiteSpace(s)) return "·"; if (string.IsNullOrWhiteSpace(s)) return "·";
// Strip surrounding parens / punctuation that would otherwise become // Strip surrounding parens / punctuation that would otherwise become
// useless initials (e.g. "(Local)" should yield "L", not "("). // useless initials (e.g. "(Local)" should yield "L", not "(").
var cleaned = new string(s.Where(c => char.IsLetterOrDigit(c) || char.IsWhiteSpace(c)).ToArray()).Trim(); var cleaned = new string(s.Where(c => char.IsLetterOrDigit(c) || char.IsWhiteSpace(c)).ToArray()).Trim();
if (cleaned.Length == 0) return "·"; if (cleaned.Length == 0) return "·";
var parts = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var parts = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0) return "·"; if (parts.Length == 0) return "·";
if (parts.Length == 1) return char.ToUpperInvariant(parts[0][0]).ToString(); if (parts.Length == 1) return char.ToUpperInvariant(parts[0][0]).ToString();
return $"{char.ToUpperInvariant(parts[0][0])}{char.ToUpperInvariant(parts[^1][0])}"; return $"{char.ToUpperInvariant(parts[0][0])}{char.ToUpperInvariant(parts[^1][0])}";
} }

View file

@ -1,10 +1,10 @@
using System.Globalization; using System.Globalization;
using System.Windows.Data; using System.Windows.Data;
namespace TeamsISO.App.Converters; namespace DragonISO.App.Converters;
/// <summary> /// <summary>
/// Maps an audio level (0.01.0) to an opacity for a single audio-meter /// Maps an audio level (0.0–1.0) to an opacity for a single audio-meter
/// segment. The XAML binds five copies, each with a different /// segment. The XAML binds five copies, each with a different
/// <see cref="Binding.ConverterParameter"/> threshold (0.2, 0.4, 0.6, /// <see cref="Binding.ConverterParameter"/> threshold (0.2, 0.4, 0.6,
/// 0.8, 1.0). A segment renders at full opacity when the live level /// 0.8, 1.0). A segment renders at full opacity when the live level
@ -21,7 +21,7 @@ public sealed class LevelThresholdConverter : IValueConverter
/// <summary>Opacity for an above-threshold segment. Defaults to 1.0.</summary> /// <summary>Opacity for an above-threshold segment. Defaults to 1.0.</summary>
public double ActiveOpacity { get; set; } = 1.0; public double ActiveOpacity { get; set; } = 1.0;
/// <summary>Opacity for a below-threshold segment. Defaults to 0.18 visible enough to read the segment shape but clearly off.</summary> /// <summary>Opacity for a below-threshold segment. Defaults to 0.18 — visible enough to read the segment shape but clearly off.</summary>
public double InactiveOpacity { get; set; } = 0.18; public double InactiveOpacity { get; set; } = 0.18;
public object Convert(object value, System.Type targetType, object parameter, CultureInfo culture) public object Convert(object value, System.Type targetType, object parameter, CultureInfo culture)

View file

@ -1,11 +1,11 @@
using System.Globalization; using System.Globalization;
using System.Windows; using System.Windows;
using System.Windows.Data; using System.Windows.Data;
namespace TeamsISO.App.Converters; namespace DragonISO.App.Converters;
/// <summary> /// <summary>
/// Tiny utility converter — null or empty string ⇒ Collapsed, otherwise /// Tiny utility converter — null or empty string ⇒ Collapsed, otherwise
/// Visible. Used by the v2 command palette's optional shortcut chip /// Visible. Used by the v2 command palette's optional shortcut chip
/// (e.g. "Ctrl+R") so rows without a bound shortcut don't render the /// (e.g. "Ctrl+R") so rows without a bound shortcut don't render the
/// empty pill outline. /// empty pill outline.

View file

@ -0,0 +1,88 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<!--
WinForms in addition to WPF for the system-tray NotifyIcon — there's no
WPF equivalent. The two frameworks coexist cleanly in .NET 8; UseWindowsForms
adds System.Windows.Forms.dll without changing the application model.
-->
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>DragonISO.App</RootNamespace>
<AssemblyName>DragonISO</AssemblyName>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationIcon>Assets\Dragon-ISO.ico</ApplicationIcon>
<!--
Required by ParticipantViewModel.ScaleNearestNeighborBgra, which writes
directly into a WriteableBitmap's pinned BackBuffer (IntPtr) for 10×
better thumbnail update perf than going through Span<byte>.
-->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Dragon-ISO.Engine\Dragon-ISO.Engine.csproj" />
<ProjectReference Include="..\Dragon-ISO.Engine.NdiInterop\Dragon-ISO.Engine.NdiInterop.csproj" />
<!--
System.Management gives us Win32_Process via ManagementObjectSearcher,
used in App.xaml.cs's ShouldDeElevate() to look up the parent process
name (we re-spawn ourselves via runas /trustlevel:0x20000 when the
parent is explorer.exe AND we're elevated — that combo triggers an
NDI mDNS-isolation bug that returns zero discovered sources).
-->
<PackageReference Include="System.Management" Version="8.0.0" />
</ItemGroup>
<!--
Grant the test assembly access to internal types — specifically the
OperatorPresetStore.PathOverride hook used to redirect file IO away from
%LOCALAPPDATA% during tests. We use AssemblyAttribute rather than
AssemblyInfo.cs so it co-locates with the project's other config.
-->
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>Dragon-ISO.App.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<!--
Strings.resx — user-facing English MessageBox copy. Embedded as a
.NET resource so ResourceManager can resolve Dragon-ISO.App.Properties.Strings
by basename. Strings.Designer.cs is hand-written (see file comment).
-->
<ItemGroup>
<EmbeddedResource Update="Properties\Strings.resx">
<LogicalName>Dragon-ISO.App.Properties.Strings.resources</LogicalName>
</EmbeddedResource>
</ItemGroup>
<!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. -->
<ItemGroup>
<!-- Source navy-blue dragon-mark, kept for AboutWindow / installer iconography. -->
<Resource Include="Assets\dragon-mark.png" />
<!--
Theme-aware silhouette variants used by Theme.Dark / Theme.Light to expose
a single Wd.BrandMark.Image resource key. The dark theme picks the white
dragon (visible on #0A0A0A), the light theme picks the black dragon
(visible on #FAFAFB). Generated from dragon-mark.png via
Assets/_recolor_dragon.py — re-run if the source mark ever changes.
-->
<Resource Include="Assets\dragon-mark-white.png" />
<Resource Include="Assets\dragon-mark-black.png" />
<Resource Include="Assets\wild-dragon-wordmark.png" />
<Resource Include="Assets\Dragon-ISO.ico" />
<!--
Inter Variable from rsms/inter v3.19 (OFL). Single .ttf covers every weight
from 100 (Thin) to 900 (Black) so we don't ship a directory of duplicates.
-->
<Resource Include="Assets\Fonts\Inter.ttf" />
<!--
JetBrains Mono Variable v2.304 (OFL). Used for machine names, source IDs,
and stat counters where a fixed-width font reads better than Inter.
-->
<Resource Include="Assets\Fonts\JetBrainsMono.ttf" />
</ItemGroup>
</Project>

View file

@ -1,9 +1,9 @@
<Window x:Class="TeamsISO.App.HelpWindow" <Window x:Class="DragonISO.App.HelpWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework" xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Help" Title="Help"
Icon="/Assets/teamsiso.ico" Icon="/Assets/DragonISO.ico"
Width="540" Height="560" Width="540" Height="560"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
WindowStyle="None" WindowStyle="None"
@ -54,7 +54,7 @@
<!-- Header --> <!-- Header -->
<StackPanel Grid.Row="1" Margin="0,16,0,16"> <StackPanel Grid.Row="1" Margin="0,16,0,16">
<TextBlock Text="TeamsISO cheat sheet" <TextBlock Text="DragonISO cheat sheet"
Style="{StaticResource Wd.Text.Title}"/> Style="{StaticResource Wd.Text.Title}"/>
<TextBlock Text="Keyboard shortcuts, file locations, and quick links." <TextBlock Text="Keyboard shortcuts, file locations, and quick links."
Style="{StaticResource Wd.Text.Subtle}" Style="{StaticResource Wd.Text.Subtle}"
@ -148,22 +148,22 @@
LineHeight="20"> LineHeight="20">
<Run Text="Engine config" Foreground="{DynamicResource Wd.Text.Tertiary}"/> <Run Text="Engine config" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
<LineBreak/> <LineBreak/>
<Run Text="%APPDATA%\TeamsISO\config.json"/> <Run Text="%APPDATA%\DragonISO\config.json"/>
<LineBreak/> <LineBreak/>
<LineBreak/> <LineBreak/>
<Run Text="Operator presets" Foreground="{DynamicResource Wd.Text.Tertiary}"/> <Run Text="Operator presets" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
<LineBreak/> <LineBreak/>
<Run Text="%LOCALAPPDATA%\TeamsISO\presets.json"/> <Run Text="%LOCALAPPDATA%\DragonISO\presets.json"/>
<LineBreak/> <LineBreak/>
<LineBreak/> <LineBreak/>
<Run Text="Diagnostic logs (rolling daily)" Foreground="{DynamicResource Wd.Text.Tertiary}"/> <Run Text="Diagnostic logs (rolling daily)" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
<LineBreak/> <LineBreak/>
<Run Text="%LOCALAPPDATA%\TeamsISO\Logs\"/> <Run Text="%LOCALAPPDATA%\DragonISO\Logs\"/>
<LineBreak/> <LineBreak/>
<LineBreak/> <LineBreak/>
<Run Text="Recordings (default)" Foreground="{DynamicResource Wd.Text.Tertiary}"/> <Run Text="Recordings (default)" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
<LineBreak/> <LineBreak/>
<Run Text="%USERPROFILE%\Videos\TeamsISO\&lt;date&gt;\"/> <Run Text="%USERPROFILE%\Videos\DragonISO\&lt;date&gt;\"/>
<LineBreak/> <LineBreak/>
<LineBreak/> <LineBreak/>
<Run Text="NDI Access Manager config" Foreground="{DynamicResource Wd.Text.Tertiary}"/> <Run Text="NDI Access Manager config" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
@ -197,7 +197,7 @@
Foreground="{DynamicResource Wd.Accent.Cyan}" Foreground="{DynamicResource Wd.Accent.Cyan}"
TextDecorations="None" TextDecorations="None"
Click="OnDocsClick"> Click="OnDocsClick">
forge.wilddragon.net/zgaetano/teamsiso forge.wilddragon.net/zgaetano/DragonISO
</Hyperlink> </Hyperlink>
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>

View file

@ -1,7 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.Windows; using System.Windows;
namespace TeamsISO.App; namespace DragonISO.App;
/// <summary> /// <summary>
/// F1 cheat-sheet dialog. Lists keyboard shortcuts, file locations, and a /// F1 cheat-sheet dialog. Lists keyboard shortcuts, file locations, and a
@ -20,7 +20,7 @@ public partial class HelpWindow : Window
{ {
Process.Start(new ProcessStartInfo Process.Start(new ProcessStartInfo
{ {
FileName = "https://forge.wilddragon.net/zgaetano/teamsiso", FileName = "https://forge.wilddragon.net/zgaetano/Dragon-ISO",
UseShellExecute = true, UseShellExecute = true,
}); });
} }

View file

@ -1,12 +1,12 @@
<Window x:Class="TeamsISO.App.MainWindow" <Window x:Class="DragonISO.App.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels" xmlns:vm="clr-namespace:DragonISO.App.ViewModels"
xmlns:conv="clr-namespace:TeamsISO.App.Converters" xmlns:conv="clr-namespace:DragonISO.App.Converters"
mc:Ignorable="d" mc:Ignorable="d"
Title="TeamsISO" Title="DragonISO"
Width="1280" Height="780" Width="1280" Height="780"
MinWidth="980" MinHeight="640" MinWidth="980" MinHeight="640"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
@ -19,11 +19,12 @@
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}"> d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
<!-- <!--
v2 SHELL — "Studio Terminal" v2 SHELL — "Studio Terminal"
(Approved 2026-05-13. Shape brief at docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md) (Approved 2026-05-13. Shape brief at docs/shapes/2026-05-13-DragonISO-v2-studio-terminal.md)
Default Windows title bar (no chromeless WindowChrome). The 32px header Default Windows title bar (no chromeless WindowChrome). The 32px header
below it carries the brand mark, wordmark, and three icon buttons: below it carries the brand mark, wordmark, and two icon buttons:
⌘K (command palette), theme toggle, settings drawer. Below that, a theme toggle and settings drawer. (Command palette is still reachable
via Ctrl+K — keybinding only, no visible button.) Below that, a
single transport strip carries the operator's at-a-glance status. single transport strip carries the operator's at-a-glance status.
The participants area is the canvas — no rail, no permanent side The participants area is the canvas — no rail, no permanent side
panel, no footer. The meeting bar at the bottom renders ONLY when panel, no footer. The meeting bar at the bottom renders ONLY when
@ -104,12 +105,16 @@
Background="Transparent" Background="Transparent"
BorderThickness="0" BorderThickness="0"
Cursor="Hand" Cursor="Hand"
ToolTip="About TeamsISO"> ToolTip="About DragonISO">
<Image Source="/Assets/dragon-mark.png" <!-- Source bound to Wd.BrandMark.Image so the mark flips
white↔black with the active theme (see Theme.Dark /
Theme.Light). The PNG carries its own AA so HighQuality
scaling is preferred over NearestNeighbor at this size. -->
<Image Source="{DynamicResource Wd.BrandMark.Image}"
Width="20" Height="20" Width="20" Height="20"
RenderOptions.BitmapScalingMode="HighQuality"/> RenderOptions.BitmapScalingMode="HighQuality"/>
</Button> </Button>
<TextBlock Text="TeamsISO" <TextBlock Text="DragonISO"
FontFamily="{StaticResource Wd.Font.Sans}" FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="13" FontSize="13"
FontWeight="Medium" FontWeight="Medium"
@ -118,23 +123,16 @@
Margin="8,0,0,0"/> Margin="8,0,0,0"/>
</StackPanel> </StackPanel>
<!-- Right cluster: three icon buttons. ⌘K opens the command <!-- Right cluster: two icon buttons. The theme button cycles
palette (Ctrl+K shortcut). The theme button cycles
dark ↔ light (Ctrl+T). The gear opens the settings dark ↔ light (Ctrl+T). The gear opens the settings
drawer. That's the entire chrome. --> drawer. Ctrl+K still opens the command palette via the
keybinding above — we just dropped the visible ⌘K
button because it duplicated the keyboard affordance
and crowded the header. -->
<StackPanel Grid.Column="2" <StackPanel Grid.Column="2"
Orientation="Horizontal" Orientation="Horizontal"
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="0,0,10,0"> Margin="0,0,10,0">
<Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnCommandPaletteClick"
Padding="8,4"
Margin="0,0,2,0"
ToolTip="Command palette (Ctrl+K)"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Secondary}"
Content="⌘K"/>
<Button Style="{StaticResource Wd.Button.Ghost}" <Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding ToggleThemeCommand}" Command="{Binding ToggleThemeCommand}"
Padding="6,4" Padding="6,4"
@ -147,16 +145,21 @@
Width="14" Height="14" Width="14" Height="14"
Stretch="None"/> Stretch="None"/>
</Button> </Button>
<!-- True gear (Unicode U+2699) rendered via Segoe UI Symbol, the
same approach used by the per-row CFG button. Replaces the
earlier hand-drawn Path that read as a sun/asterisk rather
than a cog. Unicode glyph hints cleanly at the small icon
sizes the header uses and stays crisp under DPI scaling. -->
<Button Style="{StaticResource Wd.Button.Ghost}" <Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnSettingsToggleClick" Click="OnSettingsToggleClick"
Padding="6,4" Padding="6,2"
ToolTip="Settings"> ToolTip="Settings">
<Path Data="M 8,1 L 8,3 M 8,13 L 8,15 M 1,8 L 3,8 M 13,8 L 15,8 M 3,3 L 4.5,4.5 M 11.5,11.5 L 13,13 M 3,13 L 4.5,11.5 M 11.5,4.5 L 13,3 M 8,5.5 C 9.4,5.5 10.5,6.6 10.5,8 C 10.5,9.4 9.4,10.5 8,10.5 C 6.6,10.5 5.5,9.4 5.5,8 C 5.5,6.6 6.6,5.5 8,5.5" <TextBlock Text="&#x2699;"
Stroke="{DynamicResource Wd.Text.Secondary}" FontSize="16"
StrokeThickness="1.4" FontFamily="Segoe UI Symbol"
Fill="Transparent" Foreground="{DynamicResource Wd.Text.Secondary}"
Width="14" Height="14" VerticalAlignment="Center"
Stretch="None"/> HorizontalAlignment="Center"/>
</Button> </Button>
</StackPanel> </StackPanel>
</Grid> </Grid>
@ -424,7 +427,7 @@
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 130px — JetBrains Mono 12 — the NDI source 4. Output name 130px — JetBrains Mono 12 — the NDI source
name TeamsISO broadcasts as. name DragonISO broadcasts as.
5. ISO toggle pill 100px — 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.
@ -448,8 +451,30 @@
BorderBrush="{DynamicResource Wd.Border}" BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1" BorderThickness="1"
CornerRadius="{StaticResource Radius.M}" CornerRadius="{StaticResource Radius.M}"
Background="{DynamicResource Wd.Surface}"> Background="{DynamicResource Wd.Surface}"
ClipToBounds="True">
<Grid> <Grid>
<!--
Brand watermark superimposed BEHIND the participants grid.
Sits at 6% opacity so a populated grid reads cleanly over
the top while the dragon is still visible through the
transparent row backgrounds (RowBackground="Transparent"
on the DataGrid below). When the grid is empty the
watermark becomes the de-facto empty-state surface.
IsHitTestVisible=False so the watermark never absorbs
clicks meant for grid rows or the empty area below them.
Source binds to the theme-flipped Wd.BrandMark.Image
resource — white dragon in dark mode, black in light.
-->
<Image Source="{DynamicResource Wd.BrandMark.Image}"
Opacity="0.06"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="40"
IsHitTestVisible="False"
RenderOptions.BitmapScalingMode="HighQuality"/>
<DataGrid x:Name="ParticipantsGrid" <DataGrid x:Name="ParticipantsGrid"
ItemsSource="{Binding ParticipantsView}" ItemsSource="{Binding ParticipantsView}"
AutoGenerateColumns="False" AutoGenerateColumns="False"
@ -612,52 +637,70 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>
<!-- Col 4 — Output name (mono). The NDI source name TeamsISO <!-- Col 4 — Output name (mono, INLINE EDITABLE). The NDI source
will broadcast this participant as. --> name DragonISO will broadcast this participant as. Defaults
<DataGridTemplateColumn Header="Output" Width="130" IsReadOnly="True"> to the speaker's display name; type to override per-row,
clear the field to revert to the default. EditableOutputName
handles both directions (see ParticipantViewModel comment).
UpdateSourceTrigger=LostFocus so we don't restart the NDI
sender on every keystroke — only when the operator
commits by tabbing away or pressing Enter. -->
<DataGridTemplateColumn Header="Output" Width="130">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<TextBlock Text="{Binding OutputName}" <TextBox Text="{Binding EditableOutputName, UpdateSourceTrigger=LostFocus, Mode=TwoWay}"
FontFamily="{StaticResource Wd.Font.Mono}" FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12" FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}" Background="Transparent"
VerticalAlignment="Center" BorderThickness="0"
TextTrimming="CharacterEllipsis"/> Padding="0"
Foreground="{DynamicResource Wd.Text.Secondary}"
CaretBrush="{DynamicResource Wd.Text.Primary}"
VerticalAlignment="Center"
VerticalContentAlignment="Center"
ToolTip="NDI source name. Defaults to the speaker — type to override, clear to revert."/>
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>
<!-- Col 5a — Per-row gear: opens the ISO override editor for this <!-- Col 5a — Per-row gear: opens the ISO override editor for this
participant. Narrow (32px) so the table still fits inside a participant. We use the Unicode gear glyph (U+2699) instead
1280px window after the toggle column. --> of a custom Path — it renders cleanly at any size, doesn't
<DataGridTemplateColumn Header="" Width="32" IsReadOnly="True"> disappear against dark rows the way 1.4px strokes do, and
reads as "settings" at a glance. Header is "CFG" so the
affordance is discoverable even when the row hover state
isn't active. -->
<DataGridTemplateColumn Header="CFG" Width="56" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<Button Style="{StaticResource Wd.Button.Ghost}" <Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnIsoOverrideClick" Click="OnIsoOverrideClick"
Padding="6,4" Padding="6,2"
VerticalAlignment="Center" VerticalAlignment="Center"
HorizontalAlignment="Center" HorizontalAlignment="Center"
ToolTip="Override output settings for this participant"> ToolTip="Override output settings for this participant (framerate, resolution, audio)">
<Path Data="M 8,1 L 8,3 M 8,13 L 8,15 M 1,8 L 3,8 M 13,8 L 15,8 M 3,3 L 4.5,4.5 M 11.5,11.5 L 13,13 M 3,13 L 4.5,11.5 M 11.5,4.5 L 13,3 M 8,5.5 C 9.4,5.5 10.5,6.6 10.5,8 C 10.5,9.4 9.4,10.5 8,10.5 C 6.6,10.5 5.5,9.4 5.5,8 C 5.5,6.6 6.6,5.5 8,5.5" <TextBlock Text="&#x2699;"
Stroke="{DynamicResource Wd.Text.Secondary}" FontSize="16"
StrokeThickness="1.4" FontFamily="Segoe UI Symbol"
Fill="Transparent" Foreground="{DynamicResource Wd.Text.Primary}"
Width="14" Height="14" VerticalAlignment="Center"
Stretch="None"/> HorizontalAlignment="Center"/>
</Button> </Button>
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>
<!-- Col 5 — ISO toggle pill. LIVE = cyan-muted fill + cyan border + cyan text. <!-- Col 5 — ISO toggle. 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="100"> Width 124 (was 100/110) so the "Enable" / "● LIVE" content has
breathing room inside the rounded-rect — 100 was clipping the label
at the right edge once the IsoToggle stopped being a full pill. -->
<DataGridTemplateColumn Header="ISO" Width="124">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<Button Command="{Binding ToggleIsoCommand}" <Button Command="{Binding ToggleIsoCommand}"
Margin="0,0,12,0" Margin="0,0,12,0"
Padding="14,6" Padding="10,6"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Button.Style> <Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource Wd.Button.IsoToggle}"> <Style TargetType="Button" BasedOn="{StaticResource Wd.Button.IsoToggle}">
@ -682,24 +725,54 @@
<!-- Empty-state placeholder. Renders when no NDI participants <!-- Empty-state placeholder. Renders when no NDI participants
have been discovered yet. Mono sentence + one tertiary have been discovered yet. Mono sentence + one tertiary
Refresh button — no illustration, no mascot, per the v2 Refresh button — no illustration, no mascot, per the v2
shape brief's empty-states section. --> shape brief's empty-states section.
Two visual flavors gated by IsDiscovering (the VM holds
it true for ~8s after engine start, false thereafter):
- IsDiscovering=true → "Scanning for NDI sources…"
(neutral; cold-start can take
1-3s for mDNS to settle)
- IsDiscovering=false → the explanatory empty state
("open teams and start a
meeting") + Refresh CTA
This stops operators from staring at a "broken-looking"
empty table during the first second of every launch. -->
<StackPanel HorizontalAlignment="Center" <StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}, ConverterParameter=empty}"> Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}, ConverterParameter=empty}">
<TextBlock Text="no ndi sources yet — open teams and start a meeting" <!-- Discovering: cyan dot + neutral progress copy. -->
FontFamily="{StaticResource Wd.Font.Mono}" <StackPanel Orientation="Horizontal"
FontSize="12" HorizontalAlignment="Center"
Foreground="{DynamicResource Wd.Text.Tertiary}" Visibility="{Binding IsDiscovering, Converter={StaticResource BoolToVis}}">
HorizontalAlignment="Center"/> <Ellipse Width="7" Height="7"
<Button Style="{StaticResource Wd.Button.Ghost}" Fill="{DynamicResource Wd.Accent.Cyan}"
Command="{Binding RefreshDiscoveryCommand}" VerticalAlignment="Center"
Content="Refresh discovery (Ctrl+R)" Margin="0,0,10,0"/>
Padding="14,7" <TextBlock Text="scanning for ndi sources…"
Margin="0,14,0,0" FontFamily="{StaticResource Wd.Font.Mono}"
HorizontalAlignment="Center" FontSize="12"
FontFamily="{StaticResource Wd.Font.Mono}" Foreground="{DynamicResource Wd.Text.Secondary}"
FontSize="11" VerticalAlignment="Center"/>
ToolTip="Rebuild the NDI finder"/> </StackPanel>
<!-- Not discovering (grace window expired with no sources):
the explanatory empty state. -->
<StackPanel HorizontalAlignment="Center"
Visibility="{Binding IsDiscovering, Converter={StaticResource BoolToVisInverse}}">
<TextBlock Text="no ndi sources visible — is teams in 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>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
@ -934,7 +1007,7 @@
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Margin="0,16,0,0" Margin="0,16,0,0"
Padding="0,9"/> Padding="0,9"/>
<TextBlock Text="Pins Teams' raw NDI broadcasts to a private 'teamsiso-input' group so they don't pollute the Public network. Restart Teams after applying." <TextBlock Text="Pins Teams' raw NDI broadcasts to a private 'DragonISO-input' group so they don't pollute the Public network. Restart Teams after applying."
Style="{StaticResource Wd.Text.Body}" Style="{StaticResource Wd.Text.Body}"
FontSize="11" FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}" Foreground="{DynamicResource Wd.Text.Tertiary}"
@ -947,7 +1020,7 @@
<TextBox Text="{Binding Settings.OutputNameTemplate, UpdateSourceTrigger=LostFocus}" <TextBox Text="{Binding Settings.OutputNameTemplate, UpdateSourceTrigger=LostFocus}"
FontFamily="{StaticResource Wd.Font.Mono}" FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"/> FontSize="11"/>
<TextBlock Text="Tokens: {name}, {guid}, {machine}, {timestamp}. Default: TEAMSISO_{guid}." <TextBlock Text="Tokens: {name}, {guid}, {machine}, {timestamp}. Default: {name} — the speaker's display name."
Style="{StaticResource Wd.Text.Body}" Style="{StaticResource Wd.Text.Body}"
FontSize="11" FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}" Foreground="{DynamicResource Wd.Text.Tertiary}"
@ -982,7 +1055,7 @@
Height="1" Height="1"
Background="{DynamicResource Wd.Border}"/> Background="{DynamicResource Wd.Border}"/>
<CheckBox Content="Launch Microsoft Teams on TeamsISO startup" <CheckBox Content="Launch Microsoft Teams on DragonISO startup"
IsChecked="{Binding Settings.LaunchTeamsOnStartup}"/> IsChecked="{Binding Settings.LaunchTeamsOnStartup}"/>
<CheckBox Content="Auto-hide Teams windows when launched" <CheckBox Content="Auto-hide Teams windows when launched"
IsChecked="{Binding Settings.AutoHideTeamsWindows}" IsChecked="{Binding Settings.AutoHideTeamsWindows}"

View file

@ -1,11 +1,11 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Windows; using System.Windows;
using System.Windows.Input; using System.Windows.Input;
using TeamsISO.App.Services; using DragonISO.App.Services;
using TeamsISO.App.ViewModels; using DragonISO.App.ViewModels;
namespace TeamsISO.App; namespace DragonISO.App;
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
@ -43,14 +43,14 @@ public partial class MainWindow : Window
private void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e) private void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
{ {
// A failure persisting window state must NEVER block the window from // A failure persisting window state must NEVER block the window from
// closing operator's shutdown comes first. WindowStateStore.Save // closing — operator's shutdown comes first. WindowStateStore.Save
// already swallows its own IO errors; this is defense-in-depth for // already swallows its own IO errors; this is defense-in-depth for
// anything that escapes (NRE, future regression, etc.). // anything that escapes (NRE, future regression, etc.).
try { WindowStateStore.Save(this); } try { WindowStateStore.Save(this); }
catch { /* best-effort: forgo placement memory for one launch */ } 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>
private void OnAboutClick(object sender, RoutedEventArgs e) private void OnAboutClick(object sender, RoutedEventArgs e)
{ {
var about = new AboutWindow { Owner = this }; var about = new AboutWindow { Owner = this };
@ -77,7 +77,7 @@ public partial class MainWindow : Window
/// Tracks whether we have hidden Teams' windows so the next click reverses /// Tracks whether we have hidden Teams' windows so the next click reverses
/// the action. We treat this as "intent" rather than a query of OS state /// the action. We treat this as "intent" rather than a query of OS state
/// because hidden windows still report as hidden if the operator manually /// because hidden windows still report as hidden if the operator manually
/// re-opens them and we only care about TeamsISO's own toggle history. /// re-opens them and we only care about Dragon-ISO's own toggle history.
/// </summary> /// </summary>
private bool _teamsWindowsHidden; private bool _teamsWindowsHidden;
@ -116,9 +116,9 @@ public partial class MainWindow : Window
/// <summary> /// <summary>
/// Three-state click behavior matching operator intuition: /// Three-state click behavior matching operator intuition:
/// 1. Teams not running launch it via TeamsLauncher's fallback chain. /// 1. Teams not running → launch it via TeamsLauncher's fallback chain.
/// 2. Teams running but its windows are hidden restore + foreground them. /// 2. Teams running but its windows are hidden → restore + foreground them.
/// 3. Teams running with visible windows bring the most recent to front. /// 3. Teams running with visible windows → bring the most recent to front.
/// (Stopping Teams is a right-click action; see OnLaunchTeamsRightClick.) /// (Stopping Teams is a right-click action; see OnLaunchTeamsRightClick.)
/// </summary> /// </summary>
private void OnLaunchTeamsClick(object sender, RoutedEventArgs e) private void OnLaunchTeamsClick(object sender, RoutedEventArgs e)
@ -139,8 +139,8 @@ public partial class MainWindow : Window
{ {
var autoHide = (DataContext as MainViewModel)?.Settings.AutoHideTeamsWindows ?? false; var autoHide = (DataContext as MainViewModel)?.Settings.AutoHideTeamsWindows ?? false;
toast?.Show(autoHide toast?.Show(autoHide
? "Launching Microsoft Teams (will hide windows automatically)" ? "Launching Microsoft Teams (will hide windows automatically)…"
: "Launching Microsoft Teams"); : "Launching Microsoft Teams…");
if (autoHide) if (autoHide)
{ {
_ = TeamsLauncher.AutoHideAfterLaunchAsync(); _ = TeamsLauncher.AutoHideAfterLaunchAsync();
@ -157,7 +157,7 @@ public partial class MainWindow : Window
var shown = TeamsLauncher.ShowWindows(); var shown = TeamsLauncher.ShowWindows();
_teamsWindowsHidden = false; _teamsWindowsHidden = false;
toast?.Show(shown > 0 toast?.Show(shown > 0
? $"Teams is already running surfaced {shown} window(s)" ? $"Teams is already running — surfaced {shown} window(s)"
: "Teams is running but has no visible windows yet"); : "Teams is running but has no visible windows yet");
} }
@ -165,7 +165,7 @@ public partial class MainWindow : Window
/// 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". The confirmation dialog here is /// "open OR ambush you with a stop dialog". The confirmation dialog here is
/// intentional Stop Teams is a destructive mid-show action; explicit /// intentional — Stop Teams is a destructive mid-show action; explicit
/// confirmation is the right pattern, not the "ambush" anti-pattern that /// confirmation is the right pattern, not the "ambush" anti-pattern that
/// was fixed for left-click. The palette also offers Stop Teams for /// was fixed for left-click. The palette also offers Stop Teams for
/// keyboard-first operators. /// keyboard-first operators.
@ -208,8 +208,8 @@ public partial class MainWindow : Window
/// <summary> /// <summary>
/// Toggle the v2 settings drawer overlay. The header gear button and the /// Toggle the v2 settings drawer overlay. The header gear button and the
/// drawer's own Close button both call this. State is held by the /// drawer's own Close button both call this. State is held by the
/// overlay's <see cref="UIElement.Visibility"/> directly no separate /// overlay's <see cref="UIElement.Visibility"/> directly — no separate
/// flag so the toggle is idempotent regardless of how many entry /// flag — so the toggle is idempotent regardless of how many entry
/// points open / close it. /// points open / close it.
/// </summary> /// </summary>
private void OnSettingsToggleClick(object sender, RoutedEventArgs e) private void OnSettingsToggleClick(object sender, RoutedEventArgs e)
@ -221,7 +221,7 @@ public partial class MainWindow : Window
} }
/// <summary> /// <summary>
/// Clicking the scrim behind the drawer dismisses it same affordance as /// Clicking the scrim behind the drawer dismisses it — same affordance as
/// every well-behaved slide-over on every platform. /// every well-behaved slide-over on every platform.
/// </summary> /// </summary>
private void OnSettingsScrimClick(object sender, MouseButtonEventArgs e) private void OnSettingsScrimClick(object sender, MouseButtonEventArgs e)
@ -231,7 +231,7 @@ public partial class MainWindow : Window
} }
/// <summary> /// <summary>
/// Open the v2 Ctrl+K command palette. Bound to the header K button and /// Open the v2 Ctrl+K command palette. Bound to the header ⌘K button and
/// to the Ctrl+K keyboard binding. The palette is a chromeless floating /// to the Ctrl+K keyboard binding. The palette is a chromeless floating
/// window owned by this MainWindow so it centers correctly, closes on /// window owned by this MainWindow so it centers correctly, closes on
/// Deactivated (click outside), and inherits z-order. We construct a /// Deactivated (click outside), and inherits z-order. We construct a

View file

@ -1,9 +1,9 @@
<Window x:Class="TeamsISO.App.NotesWindow" <Window x:Class="DragonISO.App.NotesWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework" xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Show notes" Title="Show notes"
Icon="/Assets/teamsiso.ico" Icon="/Assets/DragonISO.ico"
Width="540" Height="560" Width="540" Height="560"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
WindowStyle="None" WindowStyle="None"

View file

@ -1,10 +1,10 @@
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Windows; using System.Windows;
using System.Windows.Threading; using System.Windows.Threading;
using TeamsISO.App.Services; using DragonISO.App.Services;
namespace TeamsISO.App; namespace DragonISO.App;
/// <summary> /// <summary>
/// Inline viewer for the daily show-notes file. Reads /// Inline viewer for the daily show-notes file. Reads
@ -12,7 +12,7 @@ namespace TeamsISO.App;
/// shown so REST/OSC-driven note appends surface live without the operator /// shown so REST/OSC-driven note appends surface live without the operator
/// having to click Refresh. /// having to click Refresh.
/// ///
/// We don't allow editing here the file is intentionally a one-way log /// We don't allow editing here — the file is intentionally a one-way log
/// (operator stamps, post-show review). If someone wants to edit, they /// (operator stamps, post-show review). If someone wants to edit, they
/// click "Open in editor" and use Notepad. /// click "Open in editor" and use Notepad.
/// </summary> /// </summary>
@ -32,7 +32,7 @@ public partial class NotesWindow : Window
_refreshTimer.Tick += (_, _) => RefreshIfChanged(); _refreshTimer.Tick += (_, _) => RefreshIfChanged();
Loaded += (_, _) => Loaded += (_, _) =>
{ {
DateLine.Text = $"{DateTimeOffset.Now:dddd, MMMM d, yyyy} · {NotesService.TodayPath}"; DateLine.Text = $"{DateTimeOffset.Now:dddd, MMMM d, yyyy} · {NotesService.TodayPath}";
ReloadFromDisk(); ReloadFromDisk();
_refreshTimer.Start(); _refreshTimer.Start();
}; };
@ -44,7 +44,7 @@ public partial class NotesWindow : Window
private void OnRefresh(object sender, RoutedEventArgs e) => ReloadFromDisk(); private void OnRefresh(object sender, RoutedEventArgs e) => ReloadFromDisk();
/// <summary> /// <summary>
/// Cheap mtime/size check only re-reads the file when something changed. /// Cheap mtime/size check — only re-reads the file when something changed.
/// Saves the textbox a flicker on every 2s tick when no notes are being /// Saves the textbox a flicker on every 2s tick when no notes are being
/// added. Falls through to a full reload if the file got smaller (operator /// added. Falls through to a full reload if the file got smaller (operator
/// might have edited externally). /// might have edited externally).
@ -79,7 +79,7 @@ public partial class NotesWindow : Window
_lastFileSize = info.Length; _lastFileSize = info.Length;
_lastFileWrite = info.LastWriteTimeUtc; _lastFileWrite = info.LastWriteTimeUtc;
NotesText.Text = File.ReadAllText(path); NotesText.Text = File.ReadAllText(path);
// Scroll to bottom so the latest stamp is visible operators are // Scroll to bottom so the latest stamp is visible — operators are
// typically reading "what just happened" not "what happened first." // typically reading "what just happened" not "what happened first."
Dispatcher.BeginInvoke(new Action(() => Dispatcher.BeginInvoke(new Action(() =>
{ {

View file

@ -1,9 +1,9 @@
<Window x:Class="TeamsISO.App.OnboardingWindow" <Window x:Class="DragonISO.App.OnboardingWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework" xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Welcome to TeamsISO" Title="Welcome to DragonISO"
Icon="/Assets/teamsiso.ico" Icon="/Assets/DragonISO.ico"
Width="560" Height="600" Width="560" Height="600"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
WindowStyle="None" WindowStyle="None"
@ -59,7 +59,7 @@
HorizontalAlignment="Left" HorizontalAlignment="Left"
Margin="0,0,0,12" Margin="0,0,0,12"
RenderOptions.BitmapScalingMode="HighQuality"/> RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="TeamsISO routes Microsoft Teams participants as isolated NDI feeds." <TextBlock Text="DragonISO routes Microsoft Teams participants as isolated NDI feeds."
Style="{StaticResource Wd.Text.Title}" Style="{StaticResource Wd.Text.Title}"
TextWrapping="Wrap"/> TextWrapping="Wrap"/>
<TextBlock Text="A few one-time setup notes before you start." <TextBlock Text="A few one-time setup notes before you start."
@ -96,7 +96,7 @@
Style="{StaticResource Wd.Text.Body}" Style="{StaticResource Wd.Text.Body}"
FontSize="12" FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}" Foreground="{DynamicResource Wd.Text.Secondary}"
Text="TeamsISO depends on NDI 6 from NewTek/Vizrt. Get it at ndi.video/tools — pick the Tools bundle for free, or use the Runtime-only installer if you have an NDI subscription."/> Text="DragonISO depends on NDI 6 from NewTek/Vizrt. Get it at ndi.video/tools — pick the Tools bundle for free, or use the Runtime-only installer if you have an NDI subscription."/>
</StackPanel> </StackPanel>
</Border> </Border>
@ -152,7 +152,7 @@
Style="{StaticResource Wd.Text.Body}" Style="{StaticResource Wd.Text.Body}"
FontSize="12" FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}" Foreground="{DynamicResource Wd.Text.Secondary}"
Text="Settings → NETWORK → Apply transcoder topology. This pins Teams' raw NDI broadcasts to a private 'teamsiso-input' group so they don't pollute the Public network. Restart Teams once after applying."/> Text="Settings → NETWORK → Apply transcoder topology. This pins Teams' raw NDI broadcasts to a private 'DragonISO-input' group so they don't pollute the Public network. Restart Teams once after applying."/>
</StackPanel> </StackPanel>
</Border> </Border>
@ -180,11 +180,11 @@
Style="{StaticResource Wd.Text.Body}" Style="{StaticResource Wd.Text.Body}"
FontSize="12" FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}" Foreground="{DynamicResource Wd.Text.Secondary}"
Text="Once you've toggled the right ISOs, click Presets in the participants header to save them by display name. Turn on 'Auto-apply last preset on launch' under DISPLAY settings and TeamsISO will restore that routing on every subsequent launch."/> Text="Once you've toggled the right ISOs, click Presets in the participants header to save them by display name. Turn on 'Auto-apply last preset on launch' under DISPLAY settings and DragonISO will restore that routing on every subsequent launch."/>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Step 5 — Headless Teams ("I only see TeamsISO") --> <!-- Step 5 — Headless Teams ("I only see DragonISO") -->
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12"> <Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
<StackPanel> <StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8"> <StackPanel Orientation="Horizontal" Margin="0,0,0,8">
@ -208,7 +208,7 @@
Style="{StaticResource Wd.Text.Body}" Style="{StaticResource Wd.Text.Body}"
FontSize="12" FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}" Foreground="{DynamicResource Wd.Text.Secondary}"
Text="To use TeamsISO as your only window: tick both 'Launch Microsoft Teams on TeamsISO startup' and 'Auto-hide Teams windows when launched' under Settings → DISPLAY. Teams runs in the background; the IN-CALL bar shows the meeting state (READY / IN CALL · meeting title), the Mute/Camera/Share/Leave buttons drive Teams via UIAutomation, and the URL paste-box joins meetings directly. Use the eye-icon button in the left rail to manually restore Teams' windows when you need them."/> Text="To use DragonISO as your only window: tick both 'Launch Microsoft Teams on DragonISO startup' and 'Auto-hide Teams windows when launched' under Settings → DISPLAY. Teams runs in the background; the IN-CALL bar shows the meeting state (READY / IN CALL · meeting title), the Mute/Camera/Share/Leave buttons drive Teams via UIAutomation, and the URL paste-box joins meetings directly. Use the eye-icon button in the left rail to manually restore Teams' windows when you need them."/>
</StackPanel> </StackPanel>
</Border> </Border>
@ -236,7 +236,7 @@
Style="{StaticResource Wd.Text.Body}" Style="{StaticResource Wd.Text.Body}"
FontSize="12" FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}" Foreground="{DynamicResource Wd.Text.Secondary}"
Text="For headless host PC + thin-client setups: tick 'Control surface' then 'LAN-reachable' under DISPLAY. TeamsISO listens on http://&lt;your-lan-ip&gt;:9755/ui — open that URL from any browser on the LAN. First-time use needs a one-shot 'netsh http add urlacl url=http://+:9755/ user=Everyone' in an Administrator PowerShell."/> Text="For headless host PC + thin-client setups: tick 'Control surface' then 'LAN-reachable' under DISPLAY. DragonISO listens on http://&lt;your-lan-ip&gt;:9755/ui — open that URL from any browser on the LAN. First-time use needs a one-shot 'netsh http add urlacl url=http://+:9755/ user=Everyone' in an Administrator PowerShell."/>
</StackPanel> </StackPanel>
</Border> </Border>
@ -264,7 +264,7 @@
Style="{StaticResource Wd.Text.Body}" Style="{StaticResource Wd.Text.Body}"
FontSize="12" FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}" Foreground="{DynamicResource Wd.Text.Secondary}"
Text="Diagnostic logs roll daily under %LOCALAPPDATA%\TeamsISO\Logs. Settings live at %APPDATA%\TeamsISO\config.json; presets at %LOCALAPPDATA%\TeamsISO\presets.json. Attach the most recent log when filing an issue at forge.wilddragon.net/zgaetano/teamsiso."/> Text="Diagnostic logs roll daily under %LOCALAPPDATA%\DragonISO\Logs. Settings live at %APPDATA%\DragonISO\config.json; presets at %LOCALAPPDATA%\DragonISO\presets.json. Attach the most recent log when filing an issue at forge.wilddragon.net/zgaetano/DragonISO."/>
</StackPanel> </StackPanel>
</Border> </Border>

View file

@ -1,7 +1,7 @@
using System.IO; using System.IO;
using System.Windows; using System.Windows;
namespace TeamsISO.App; namespace DragonISO.App;
/// <summary> /// <summary>
/// First-launch welcome dialog. Walks the user through the once-per-machine /// First-launch welcome dialog. Walks the user through the once-per-machine
@ -10,8 +10,8 @@ namespace TeamsISO.App;
/// presets live for later self-service. /// presets live for later self-service.
/// ///
/// Suppression is governed by a marker file at /// Suppression is governed by a marker file at
/// <c>%LOCALAPPDATA%\TeamsISO\onboarding.flag</c>. The presence of the file — /// <c>%LOCALAPPDATA%\Dragon-ISO\onboarding.flag</c>. The presence of the file —
/// regardless of contents means "don't show again." The user can restore /// regardless of contents — means "don't show again." The user can restore
/// the dialog by deleting that file. /// the dialog by deleting that file.
/// </summary> /// </summary>
public partial class OnboardingWindow : Window public partial class OnboardingWindow : Window
@ -19,7 +19,7 @@ public partial class OnboardingWindow : Window
private static string FlagPath => private static string FlagPath =>
Path.Combine( Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "onboarding.flag"); "Dragon-ISO", "onboarding.flag");
public OnboardingWindow() => InitializeComponent(); public OnboardingWindow() => InitializeComponent();
@ -30,7 +30,7 @@ public partial class OnboardingWindow : Window
public static bool ShouldShow() public static bool ShouldShow()
{ {
try { return !File.Exists(FlagPath); } try { return !File.Exists(FlagPath); }
catch { return false; } // permission errors assume already shown catch { return false; } // permission errors → assume already shown
} }
private void OnDismiss(object sender, RoutedEventArgs e) private void OnDismiss(object sender, RoutedEventArgs e)
@ -47,7 +47,7 @@ public partial class OnboardingWindow : Window
} }
catch catch
{ {
// Disk full / permission denied show the dialog again next launch // Disk full / permission denied — show the dialog again next launch
// rather than fail noisily. // rather than fail noisily.
} }
} }

View file

@ -1,9 +1,9 @@
<Window x:Class="TeamsISO.App.PresetsDialog" <Window x:Class="DragonISO.App.PresetsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework" xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Operator presets" Title="Operator presets"
Icon="/Assets/teamsiso.ico" Icon="/Assets/DragonISO.ico"
Width="460" Height="520" Width="460" Height="520"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
WindowStyle="None" WindowStyle="None"

View file

@ -1,11 +1,11 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using TeamsISO.App.Services; using DragonISO.App.Services;
using TeamsISO.App.ViewModels; using DragonISO.App.ViewModels;
using TeamsISO.Engine.Controller; using DragonISO.Engine.Controller;
namespace TeamsISO.App; namespace DragonISO.App;
/// <summary> /// <summary>
/// Modal dialog for saving and loading operator presets. Owned by /// Modal dialog for saving and loading operator presets. Owned by
@ -101,7 +101,7 @@ public partial class PresetsDialog : Window
var confirm = MessageBox.Show( var confirm = MessageBox.Show(
this, this,
$"A preset named \"{name}\" already exists. Overwrite it?", $"A preset named \"{name}\" already exists. Overwrite it?",
"TeamsISO — Overwrite preset", "Dragon-ISO — Overwrite preset",
MessageBoxButton.YesNo, MessageBoxButton.YesNo,
MessageBoxImage.Question, MessageBoxImage.Question,
MessageBoxResult.No); MessageBoxResult.No);
@ -122,7 +122,7 @@ public partial class PresetsDialog : Window
MessageBox.Show( MessageBox.Show(
this, this,
$"Could not save preset.\n\n{ex.Message}", $"Could not save preset.\n\n{ex.Message}",
"TeamsISO — Save preset", "Dragon-ISO — Save preset",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
} }
@ -130,7 +130,7 @@ public partial class PresetsDialog : Window
/// <summary> /// <summary>
/// Apply the selected preset: walks the current participants list, matching /// Apply the selected preset: walks the current participants list, matching
/// by display name (the only stable join key across meetings Ids are /// by display name (the only stable join key across meetings — Ids are
/// regenerated each meeting). For each match, set the custom output name and /// regenerated each meeting). For each match, set the custom output name and
/// reconcile its enabled state with the preset by calling EnableIsoAsync / /// reconcile its enabled state with the preset by calling EnableIsoAsync /
/// DisableIsoAsync as needed. Participants in the preset who aren't in the /// DisableIsoAsync as needed. Participants in the preset who aren't in the
@ -143,15 +143,15 @@ public partial class PresetsDialog : Window
ApplyButton.IsEnabled = false; ApplyButton.IsEnabled = false;
try try
{ {
// PresetApplier owns the apply loop same code path the REST control // PresetApplier owns the apply loop — same code path the REST control
// surface and auto-apply-on-launch use. Dialog passes null dispatcher // surface and auto-apply-on-launch use. Dialog passes null dispatcher
// since OnApply already runs on the UI thread. // since OnApply already runs on the UI thread.
var result = await PresetApplier.ApplyAsync( var result = await PresetApplier.ApplyAsync(
row.Preset, _participants, _controller, dispatcher: null); row.Preset, _participants, _controller, dispatcher: null);
var summary = result.Skipped > 0 var summary = result.Skipped > 0
? $"Applied \"{row.Name}\" {result.Changed} change(s); {result.Skipped} not in meeting" ? $"Applied \"{row.Name}\" — {result.Changed} change(s); {result.Skipped} not in meeting"
: $"Applied \"{row.Name}\" {result.Changed} change(s)"; : $"Applied \"{row.Name}\" — {result.Changed} change(s)";
_toast?.Show(summary); _toast?.Show(summary);
DialogResult = true; DialogResult = true;
Close(); Close();
@ -184,14 +184,14 @@ public partial class PresetsDialog : Window
var confirm = MessageBox.Show( var confirm = MessageBox.Show(
this, this,
$"A preset named \"{newName}\" already exists. Overwrite it?", $"A preset named \"{newName}\" already exists. Overwrite it?",
"TeamsISO — Duplicate preset", "Dragon-ISO — Duplicate preset",
MessageBoxButton.YesNo, MessageBoxButton.YesNo,
MessageBoxImage.Question, MessageBoxImage.Question,
MessageBoxResult.No); MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return; if (confirm != MessageBoxResult.Yes) return;
} }
// Re-using Save() with a fresh SavedAt timestamp Save's overwrite // Re-using Save() with a fresh SavedAt timestamp — Save's overwrite
// semantics handle the name-collision case cleanly. // semantics handle the name-collision case cleanly.
OperatorPresetStore.Save(new OperatorPresetStore.Preset( OperatorPresetStore.Save(new OperatorPresetStore.Preset(
Name: newName, Name: newName,
@ -204,14 +204,14 @@ public partial class PresetsDialog : Window
{ {
MessageBox.Show(this, MessageBox.Show(this,
$"Could not duplicate preset.\n\n{ex.Message}", $"Could not duplicate preset.\n\n{ex.Message}",
"TeamsISO — Duplicate preset", "Dragon-ISO — Duplicate preset",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
} }
} }
/// <summary> /// <summary>
/// Suggest a name for a copy: "Foo" → "Foo (copy)", "Foo (copy)" → "Foo (copy 2)". /// Suggest a name for a copy: "Foo" → "Foo (copy)", "Foo (copy)" → "Foo (copy 2)".
/// Bumps the digit if the operator iterates from a copy. /// Bumps the digit if the operator iterates from a copy.
/// </summary> /// </summary>
private static string SuggestCopyName(string original) private static string SuggestCopyName(string original)
@ -275,7 +275,7 @@ public partial class PresetsDialog : Window
var confirm = MessageBox.Show( var confirm = MessageBox.Show(
this, this,
$"Delete preset \"{row.Name}\"? This cannot be undone.", $"Delete preset \"{row.Name}\"? This cannot be undone.",
"TeamsISO — Delete preset", "Dragon-ISO — Delete preset",
MessageBoxButton.YesNo, MessageBoxButton.YesNo,
MessageBoxImage.Warning, MessageBoxImage.Warning,
MessageBoxResult.No); MessageBoxResult.No);
@ -292,7 +292,7 @@ public partial class PresetsDialog : Window
MessageBox.Show( MessageBox.Show(
this, this,
$"Could not delete preset.\n\n{ex.Message}", $"Could not delete preset.\n\n{ex.Message}",
"TeamsISO — Delete preset", "Dragon-ISO — Delete preset",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
} }
@ -313,9 +313,9 @@ public partial class PresetsDialog : Window
{ {
var dlg = new Microsoft.Win32.SaveFileDialog var dlg = new Microsoft.Win32.SaveFileDialog
{ {
Title = "Export TeamsISO presets", Title = "Export Dragon-ISO presets",
FileName = $"teamsiso-presets-{DateTimeOffset.Now:yyyy-MM-dd}.json", FileName = $"Dragon-ISO-presets-{DateTimeOffset.Now:yyyy-MM-dd}.json",
Filter = "TeamsISO preset bundle (*.json)|*.json", Filter = "Dragon-ISO preset bundle (*.json)|*.json",
DefaultExt = "json", DefaultExt = "json",
}; };
if (dlg.ShowDialog(this) != true) return; if (dlg.ShowDialog(this) != true) return;
@ -330,7 +330,7 @@ public partial class PresetsDialog : Window
{ {
MessageBox.Show(this, MessageBox.Show(this,
$"Could not export presets.\n\n{ex.Message}", $"Could not export presets.\n\n{ex.Message}",
"TeamsISO — Export presets", "Dragon-ISO — Export presets",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
} }
@ -338,7 +338,7 @@ public partial class PresetsDialog : Window
/// <summary> /// <summary>
/// Load a bundle from a path the user picks. On name collision we ask once /// Load a bundle from a path the user picks. On name collision we ask once
/// (covering all collisions) whether to overwrite a per-preset prompt would /// (covering all collisions) whether to overwrite — a per-preset prompt would
/// be exhausting for a 20-preset bundle. The Y/N here drives the overwrite /// be exhausting for a 20-preset bundle. The Y/N here drives the overwrite
/// flag passed to <see cref="OperatorPresetStore.ImportBundle"/>. /// flag passed to <see cref="OperatorPresetStore.ImportBundle"/>.
/// </summary> /// </summary>
@ -346,8 +346,8 @@ public partial class PresetsDialog : Window
{ {
var dlg = new Microsoft.Win32.OpenFileDialog var dlg = new Microsoft.Win32.OpenFileDialog
{ {
Title = "Import TeamsISO presets", Title = "Import Dragon-ISO presets",
Filter = "TeamsISO preset bundle (*.json)|*.json|All files (*.*)|*.*", Filter = "Dragon-ISO preset bundle (*.json)|*.json|All files (*.*)|*.*",
}; };
if (dlg.ShowDialog(this) != true) return; if (dlg.ShowDialog(this) != true) return;
@ -357,7 +357,7 @@ public partial class PresetsDialog : Window
{ {
MessageBox.Show(this, MessageBox.Show(this,
$"Could not read the file.\n\n{ex.Message}", $"Could not read the file.\n\n{ex.Message}",
"TeamsISO — Import presets", "Dragon-ISO — Import presets",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
return; return;
@ -369,8 +369,8 @@ public partial class PresetsDialog : Window
catch catch
{ {
MessageBox.Show(this, MessageBox.Show(this,
"That file isn't a valid TeamsISO preset bundle.", "That file isn't a valid Dragon-ISO preset bundle.",
"TeamsISO — Import presets", "Dragon-ISO — Import presets",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
return; return;
@ -379,7 +379,7 @@ public partial class PresetsDialog : Window
{ {
MessageBox.Show(this, MessageBox.Show(this,
"The bundle is empty.", "The bundle is empty.",
"TeamsISO — Import presets", "Dragon-ISO — Import presets",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Information); MessageBoxImage.Information);
return; return;
@ -398,7 +398,7 @@ public partial class PresetsDialog : Window
$"{collisions} preset name(s) in this bundle already exist on this machine.\n\n" + $"{collisions} preset name(s) in this bundle already exist on this machine.\n\n" +
"Yes = overwrite local copies with the bundle's versions.\n" + "Yes = overwrite local copies with the bundle's versions.\n" +
"No = keep local copies; only import new presets.", "No = keep local copies; only import new presets.",
"TeamsISO — Import presets", "Dragon-ISO — Import presets",
MessageBoxButton.YesNoCancel, MessageBoxButton.YesNoCancel,
MessageBoxImage.Question, MessageBoxImage.Question,
MessageBoxResult.No); MessageBoxResult.No);
@ -411,13 +411,13 @@ public partial class PresetsDialog : Window
{ {
MessageBox.Show(this, MessageBox.Show(this,
$"Import failed.\n\n{result.Error}", $"Import failed.\n\n{result.Error}",
"TeamsISO — Import presets", "Dragon-ISO — Import presets",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
return; return;
} }
var summary = $"Imported {result.Added} new"; var summary = $"Imported — {result.Added} new";
if (result.Overwritten > 0) summary += $", {result.Overwritten} overwritten"; if (result.Overwritten > 0) summary += $", {result.Overwritten} overwritten";
if (result.Skipped > 0) summary += $", {result.Skipped} skipped"; if (result.Skipped > 0) summary += $", {result.Skipped} skipped";
_toast?.Show(summary); _toast?.Show(summary);

View file

@ -1,9 +1,9 @@
<Window x:Class="TeamsISO.App.PreviewWindow" <Window x:Class="DragonISO.App.PreviewWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework" xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Preview" Title="Preview"
Icon="/Assets/teamsiso.ico" Icon="/Assets/DragonISO.ico"
Width="640" Height="400" Width="640" Height="400"
MinWidth="320" MinHeight="200" MinWidth="320" MinHeight="200"
Background="Black" Background="Black"

View file

@ -1,21 +1,21 @@
using System.Windows; using System.Windows;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using System.Windows.Threading; using System.Windows.Threading;
using TeamsISO.Engine.Controller; using DragonISO.Engine.Controller;
using TeamsISO.Engine.Pipeline; using DragonISO.Engine.Pipeline;
namespace TeamsISO.App; namespace DragonISO.App;
/// <summary> /// <summary>
/// Non-modal floating preview window for a single participant. Shows the /// Non-modal floating preview window for a single participant. Shows the
/// engine's most recent <see cref="ProcessedFrame"/> at a higher refresh /// engine's most recent <see cref="ProcessedFrame"/> at a higher refresh
/// rate (~20Hz) than the participants DataGrid thumbnails (1Hz). Multi- /// rate (~20Hz) than the participants DataGrid thumbnails (1Hz). Multi-
/// monitor friendly: operator drags it to a second display, leaves the /// monitor friendly: operator drags it to a second display, leaves the
/// main TeamsISO window on the primary. /// main Dragon-ISO window on the primary.
/// ///
/// Uses <see cref="WriteableBitmap"/> with <see cref="WriteableBitmap.WritePixels(Int32Rect, IntPtr, int, int)"/> /// Uses <see cref="WriteableBitmap"/> with <see cref="WriteableBitmap.WritePixels(Int32Rect, IntPtr, int, int)"/>
/// the engine produces full-resolution BGRA frames so we can write them /// — the engine produces full-resolution BGRA frames so we can write them
/// straight into the bitmap without scaling. WPF's Image control with /// straight into the bitmap without scaling. WPF's Image control with
/// Stretch=Uniform handles aspect-correct fit to the window size. /// Stretch=Uniform handles aspect-correct fit to the window size.
/// </summary> /// </summary>
@ -71,12 +71,12 @@ public partial class PreviewWindow : Window
PreviewImage.Source = _bitmap; PreviewImage.Source = _bitmap;
_lastWidth = frame.Width; _lastWidth = frame.Width;
_lastHeight = frame.Height; _lastHeight = frame.Height;
ResolutionText.Text = $"{frame.Width}×{frame.Height}"; ResolutionText.Text = $"{frame.Width}×{frame.Height}";
} }
// WritePixels takes a buffer + stride + rect. Stride = width * 4 for // WritePixels takes a buffer + stride + rect. Stride = width * 4 for
// BGRA. We slice the ProcessedFrame's ReadOnlyMemory<byte> via .Span // BGRA. We slice the ProcessedFrame's ReadOnlyMemory<byte> via .Span
// and use the IntPtr overload via MemoryMarshal but the // and use the IntPtr overload via MemoryMarshal — but the
// byte-array overload is simpler and the compiler picks the right // byte-array overload is simpler and the compiler picks the right
// ToArray-free path because the engine already allocates a fresh // ToArray-free path because the engine already allocates a fresh
// array per frame. // array per frame.

View file

@ -1,21 +1,21 @@
// Hand-written strongly-typed accessor for Properties/Strings.resx. Kept // Hand-written strongly-typed accessor for Properties/Strings.resx. Kept
// out of Visual Studio's "ResXFileCodeGenerator" auto-regeneration loop // out of Visual Studio's "ResXFileCodeGenerator" auto-regeneration loop
// so the .csproj stays simple and the file doesn't churn on every save. // 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. // If you add a key in Strings.resx, add a matching property here.
// The compiler treats `*.Designer.cs` as auto-generated and refuses // The compiler treats `*.Designer.cs` as auto-generated and refuses
// nullable annotations without an explicit directive opt in. // nullable annotations without an explicit directive — opt in.
#nullable enable #nullable enable
using System.Globalization; using System.Globalization;
using System.Resources; using System.Resources;
namespace TeamsISO.App.Properties; namespace DragonISO.App.Properties;
internal static class Strings internal static class Strings
{ {
private static readonly ResourceManager ResourceManager = new( private static readonly ResourceManager ResourceManager = new(
baseName: "TeamsISO.App.Properties.Strings", baseName: "DragonISO.App.Properties.Strings",
assembly: typeof(Strings).Assembly); assembly: typeof(Strings).Assembly);
public static CultureInfo? Culture { get; set; } public static CultureInfo? Culture { get; set; }

View file

@ -1,8 +1,8 @@
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// The HTML / CSS / JS for the embedded control panel served at /// The HTML / CSS / JS for the embedded control panel served at
/// <c>GET /ui</c>. Single self-contained string no external CDN deps, no /// <c>GET /ui</c>. Single self-contained string — no external CDN deps, no
/// build step, no React. Phone-friendly remote that connects via WebSocket /// build step, no React. Phone-friendly remote that connects via WebSocket
/// to <c>/ws</c> and posts to the existing REST endpoints. /// to <c>/ws</c> and posts to the existing REST endpoints.
/// ///
@ -10,7 +10,7 @@ namespace TeamsISO.App.Services;
/// - Live preview tiles per participant via GET /participants/{id}/thumbnail.jpg /// - Live preview tiles per participant via GET /participants/{id}/thumbnail.jpg
/// (engine encodes the latest ProcessedFrame as a 192-wide JPEG; refreshed /// (engine encodes the latest ProcessedFrame as a 192-wide JPEG; refreshed
/// ~1Hz alongside the WebSocket state push). /// ~1Hz alongside the WebSocket state push).
/// - Topology toggle card shows whether raw Teams NDI sources are /// - Topology toggle card — shows whether raw Teams NDI sources are
/// hidden from the LAN, with Apply / Restore buttons that hit the /// hidden from the LAN, with Apply / Restore buttons that hit the
/// /topology/apply + /topology/restore REST endpoints. Operator still /// /topology/apply + /topology/restore REST endpoints. Operator still
/// has to restart Teams afterward, surfaced in a banner on apply. /// has to restart Teams afterward, surfaced in a banner on apply.
@ -22,7 +22,7 @@ internal static class ControlPanelHtml
<head> <head>
<meta charset='utf-8'> <meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'> <meta name='viewport' content='width=device-width, initial-scale=1'>
<title>TeamsISO Control</title> <title>Dragon-ISO Control</title>
<style> <style>
:root { :root {
--bg: #0a0a0a; --bg: #0a0a0a;
@ -142,11 +142,11 @@ internal static class ControlPanelHtml
</style> </style>
</head> </head>
<body> <body>
<h1>TeamsISO control surface</h1> <h1>Dragon-ISO control surface</h1>
<div class='card'> <div class='card'>
<div class='status'> <div class='status'>
<span><span id='conn' class='dot gray'></span> <span id='conn-text'>connecting</span></span> <span><span id='conn' class='dot gray'></span> <span id='conn-text'>connectingâ¦</span></span>
<span id='count' class='sub'></span> <span id='count' class='sub'></span>
</div> </div>
</div> </div>
@ -156,7 +156,7 @@ internal static class ControlPanelHtml
<span id='topo-dot' class='dot gray'></span> <span id='topo-dot' class='dot gray'></span>
<div> <div>
<div class='label-caps'>Network topology</div> <div class='label-caps'>Network topology</div>
<strong id='topo-label'></strong> <strong id='topo-label'>â</strong>
<div id='topo-detail' class='sub' style='margin-top: 2px;'></div> <div id='topo-detail' class='sub' style='margin-top: 2px;'></div>
</div> </div>
</div> </div>
@ -173,7 +173,7 @@ internal static class ControlPanelHtml
<button onclick='post(""/teams/camera"")'>Camera</button> <button onclick='post(""/teams/camera"")'>Camera</button>
<button onclick='post(""/teams/share"")'>Share</button> <button onclick='post(""/teams/share"")'>Share</button>
<button onclick='post(""/teams/leave"")'>Leave</button> <button onclick='post(""/teams/leave"")'>Leave</button>
<button onclick='dropNote()'>Note</button> <button onclick='dropNote()'>Noteâ¦</button>
<button onclick='post(""/presets/refresh-discovery"")'>Refresh</button> <button onclick='post(""/presets/refresh-discovery"")'>Refresh</button>
<button class='danger' onclick='post(""/presets/stop-all"")'>Stop all ISOs</button> <button class='danger' onclick='post(""/presets/stop-all"")'>Stop all ISOs</button>
</div> </div>
@ -226,20 +226,20 @@ function paintTopology(t) {
topoLabel.textContent = 'Teams hidden from LAN'; topoLabel.textContent = 'Teams hidden from LAN';
} else if (t.mode === 'public') { } else if (t.mode === 'public') {
topoDot.className = 'dot amber'; topoDot.className = 'dot amber';
topoLabel.textContent = 'Public raw Teams visible'; topoLabel.textContent = 'Public â raw Teams visible';
} else { } else {
topoDot.className = 'dot gray'; topoDot.className = 'dot gray';
topoLabel.textContent = 'Unknown'; topoLabel.textContent = 'Unknown';
} }
const sends = (t.senders || []).join(', ') || '—'; const sends = (t.senders || []).join(', ') || 'â';
const recvs = (t.receivers || []).join(', ') || '—'; const recvs = (t.receivers || []).join(', ') || 'â';
topoDetail.textContent = 'send: ' + sends + ' · recv: ' + recvs; topoDetail.textContent = 'send: ' + sends + ' · recv: ' + recvs;
} }
async function applyTopology() { async function applyTopology() {
const r = await post('/topology/apply'); const r = await post('/topology/apply');
if (r && r.ok) { if (r && r.ok) {
topoBanner.textContent = ' ' + (r.note || 'Applied. Restart Microsoft Teams for it to take effect.'); topoBanner.textContent = '✠' + (r.note || 'Applied. Restart Microsoft Teams for it to take effect.');
topoBanner.classList.add('show'); topoBanner.classList.add('show');
setTimeout(() => topoBanner.classList.remove('show'), 8000); setTimeout(() => topoBanner.classList.remove('show'), 8000);
} }
@ -250,7 +250,7 @@ async function restoreTopology() {
if (!confirm('Restore default NDI groups? Teams will broadcast on public again after you restart it.')) return; if (!confirm('Restore default NDI groups? Teams will broadcast on public again after you restart it.')) return;
const r = await post('/topology/restore'); const r = await post('/topology/restore');
if (r && r.ok) { if (r && r.ok) {
topoBanner.textContent = ' Defaults restored. Restart Microsoft Teams for it to take effect.'; topoBanner.textContent = '✠Defaults restored. Restart Microsoft Teams for it to take effect.';
topoBanner.classList.add('show'); topoBanner.classList.add('show');
setTimeout(() => topoBanner.classList.remove('show'), 8000); setTimeout(() => topoBanner.classList.remove('show'), 8000);
} }
@ -282,15 +282,15 @@ const openPanels = new Set();
function shortFps(v) { function shortFps(v) {
for (const [k, label] of FRAMERATE_OPTS) if (k === v) return label; for (const [k, label] of FRAMERATE_OPTS) if (k === v) return label;
return v || '—'; return v || 'â';
} }
function shortRes(v) { function shortRes(v) {
for (const [k, label] of RESOLUTION_OPTS) if (k === v) return label; for (const [k, label] of RESOLUTION_OPTS) if (k === v) return label;
return v || '—'; return v || 'â';
} }
function shortAudio(v) { function shortAudio(v) {
for (const [k, label] of AUDIO_OPTS) if (k === v) return label; for (const [k, label] of AUDIO_OPTS) if (k === v) return label;
return v || '—'; return v || 'â';
} }
function buildSelect(opts, current) { function buildSelect(opts, current) {
@ -323,21 +323,21 @@ function render(participants) {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'participant-row'; row.className = 'participant-row';
const stateColor = p.isEnabled ? 'cyan' : (p.isOnline ? 'gray' : 'coral'); const stateColor = p.isEnabled ? 'cyan' : (p.isOnline ? 'gray' : 'coral');
// Live preview tile cache-bust with a 1s-bucket query param so the // Live preview tile — cache-bust with a 1s-bucket query param so the
// browser refreshes the image without flickering on every WS message. // browser refreshes the image without flickering on every WS message.
const bust = Math.floor(Date.now() / 1000); const bust = Math.floor(Date.now() / 1000);
const previewUrl = '/participants/' + p.id + '/thumbnail.jpg?t=' + bust; const previewUrl = '/participants/' + p.id + '/thumbnail.jpg?t=' + bust;
row.innerHTML = row.innerHTML =
""<span class='dot "" + stateColor + ""'></span>"" + ""<span class='dot "" + stateColor + ""'></span>"" +
""<img class='preview' alt='' onerror=\""this.style.display='none'; this.nextElementSibling.style.display='flex';\"" >"" + ""<img class='preview' alt='' onerror=\""this.style.display='none'; this.nextElementSibling.style.display='flex';\"" >"" +
""<div class='preview empty' style='display:none;'></div>"" + ""<div class='preview empty' style='display:none;'>â</div>"" +
""<div class='grow'>"" + ""<div class='grow'>"" +
""<div class='name'></div>"" + ""<div class='name'></div>"" +
""<div class='sub'></div>"" + ""<div class='sub'></div>"" +
""</div>"" + ""</div>"" +
""<div class='row-right'>"" + ""<div class='row-right'>"" +
""<span class='cfg-caption'></span>"" + ""<span class='cfg-caption'></span>"" +
""<button class='gear-btn' title='Output settings'></button>"" + ""<button class='gear-btn' title='Output settings'>âš</button>"" +
""<button class='enable-btn'></button>"" + ""<button class='enable-btn'></button>"" +
""</div>""; ""</div>"";
const img = row.querySelector('img.preview'); const img = row.querySelector('img.preview');
@ -346,7 +346,7 @@ function render(participants) {
const subEl = row.querySelector('.sub'); const subEl = row.querySelector('.sub');
subEl.textContent = subEl.textContent =
(p.stateLabel || (p.isOnline ? 'online' : 'offline')) + (p.stateLabel || (p.isOnline ? 'online' : 'offline')) +
(p.customName ? ' · ' + p.customName : ''); (p.customName ? ' · ' + p.customName : '');
if (isOverride) { if (isOverride) {
const pill = document.createElement('span'); const pill = document.createElement('span');
pill.className = 'ovr-pill'; pill.className = 'ovr-pill';
@ -354,10 +354,10 @@ function render(participants) {
subEl.appendChild(pill); subEl.appendChild(pill);
} }
row.querySelector('.cfg-caption').textContent = row.querySelector('.cfg-caption').textContent =
shortFps(eff.framerate) + ' · ' + shortRes(eff.resolution) + ' · ' + shortAudio(eff.audio); shortFps(eff.framerate) + ' · ' + shortRes(eff.resolution) + ' · ' + shortAudio(eff.audio);
const enableBtn = row.querySelector('.enable-btn'); const enableBtn = row.querySelector('.enable-btn');
enableBtn.className = 'enable-btn ' + (p.isEnabled ? 'live' : ''); enableBtn.className = 'enable-btn ' + (p.isEnabled ? 'live' : '');
enableBtn.textContent = p.isEnabled ? ' LIVE' : 'Enable'; enableBtn.textContent = p.isEnabled ? '⏠LIVE' : 'Enable';
enableBtn.onclick = () => post('/participants/iso', { enableBtn.onclick = () => post('/participants/iso', {
displayName: p.displayName, displayName: p.displayName,
enabled: !p.isEnabled, enabled: !p.isEnabled,
@ -419,7 +419,7 @@ function render(participants) {
} }
function connect() { function connect() {
setConn('gray', 'connecting'); setConn('gray', 'connectingâ¦');
const ws = new WebSocket( const ws = new WebSocket(
(location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'); (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
ws.onopen = () => { setConn('green', 'live'); fetchTopology(); }; ws.onopen = () => { setConn('green', 'live'); fetchTopology(); };
@ -430,7 +430,7 @@ function connect() {
} catch (e) { console.warn(e); } } catch (e) { console.warn(e); }
}; };
ws.onclose = () => { ws.onclose = () => {
setConn('coral', 'disconnected retry in 3s'); setConn('coral', 'disconnected â retry in 3s');
setTimeout(connect, 3000); setTimeout(connect, 3000);
}; };
ws.onerror = () => setConn('coral', 'error'); ws.onerror = () => setConn('coral', 'error');
@ -438,7 +438,7 @@ function connect() {
connect(); connect();
// Re-poll topology every 30s in case the operator changes the machine NDI // Re-poll topology every 30s in case the operator changes the machine NDI
// config externally (NDI Access Manager, manual edit). Cheap one HTTP GET. // config externally (NDI Access Manager, manual edit). Cheap — one HTTP GET.
setInterval(fetchTopology, 30000); setInterval(fetchTopology, 30000);
</script> </script>
</body> </body>

View file

@ -0,0 +1,49 @@
namespace DragonISO.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 = "Dragon-ISO",
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; }
}
}

View file

@ -0,0 +1,19 @@
using System.Collections.Specialized;
using System.Text.Json;
namespace DragonISO.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 };
}
}

View file

@ -0,0 +1,191 @@
using System.Collections.Specialized;
using System.Text.Json;
using DragonISO.Engine.Domain;
namespace DragonISO.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 };
}
}

View file

@ -0,0 +1,71 @@
namespace DragonISO.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,
};
}
}

View file

@ -0,0 +1,22 @@
namespace DragonISO.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(),
};
}
}

View file

@ -0,0 +1,113 @@
using Microsoft.Extensions.Logging;
namespace DragonISO.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);
}
}

View file

@ -0,0 +1,91 @@
namespace DragonISO.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>Dragon-ISO-input</c>,
/// receivers → <c>public + Dragon-ISO-input</c>; engine groups updated to
/// match (discover from Dragon-ISO-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 DragonISO.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 DragonISO.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.",
};
}
}

View file

@ -0,0 +1,147 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace DragonISO.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);
}
}

View file

@ -0,0 +1,400 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows.Threading;
using Microsoft.Extensions.Logging;
using DragonISO.App.ViewModels;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Domain;
namespace DragonISO.App.Services;
/// <summary>
/// Localhost-only HTTP control surface. Lets external controllers (Bitfocus
/// Companion, Stream Deck plugins, Bome MIDI Translator, custom node-RED flows,
/// etc.) drive Dragon-ISO without needing to embed a UI binding.
///
/// Bound to 127.0.0.1 by default — exposing this to LAN would require auth, and
/// the typical operator workflow is "Stream Deck on the same machine as Dragon-ISO".
/// If a future user needs LAN access, add a token check + bind to a configurable
/// address; both are deliberately punted for v1.
///
/// Endpoints (all return application/json):
///
/// GET / — server info + endpoint list
/// GET /participants — list of {id, displayName, isOnline, isEnabled}
/// POST /participants/{id}/iso — body {"enabled":bool,"customName":string?}
/// POST /participants/iso — body {"displayName":string,"enabled":bool} (look up by name)
/// POST /presets/{name}/apply — apply a saved preset
/// POST /presets/refresh-discovery — rebuild NDI finder
/// POST /presets/stop-all — disable every running ISO
/// POST /teams/mute — toggle mute via UIA
/// POST /teams/camera — toggle camera via UIA
/// POST /teams/leave — leave the call via UIA
/// POST /teams/share — open share tray via UIA
/// POST /teams/raise-hand — toggle raise hand via UIA
/// POST /recording — body {"enabled":bool,"directory":string?}
///
/// All POST bodies are optional — endpoints that take parameters accept them
/// either via JSON body or via query string (?enabled=true&amp;customName=Host).
/// This is friendly to Companion's "URL with query string" mode.
/// </summary>
// Endpoint handlers live in partial files under Services/ControlSurface/Endpoints/.
// This file holds the host: listener lifecycle, accept loop, dispatch table,
// response helpers, and the WebSocket push loop.
public sealed partial class ControlSurfaceServer : IAsyncDisposable
{
public const int DefaultPort = 9755;
private readonly IIsoController _controller;
private readonly Func<MainViewModel?> _viewModel;
private readonly ILogger<ControlSurfaceServer>? _logger;
private HttpListener? _listener;
private CancellationTokenSource? _cts;
private Task? _acceptTask;
private DispatcherTimer? _pushTimer;
private readonly ConcurrentDictionary<Guid, WebSocket> _clients = new();
private string _lastPushedSnapshot = string.Empty;
public bool IsRunning { get; private set; }
public int Port { get; private set; } = DefaultPort;
/// <summary>True when the listener is bound to all interfaces (LAN-reachable) rather than just 127.0.0.1.</summary>
public bool BoundToLan { get; private set; }
/// <summary>
/// JSON serializer options shared across all responses. Camel-case property
/// naming matches Companion's request shape and what most JS clients expect.
/// </summary>
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public ControlSurfaceServer(
IIsoController controller,
Func<MainViewModel?> viewModel,
ILogger<ControlSurfaceServer>? logger = null)
{
_controller = controller;
_viewModel = viewModel;
_logger = logger;
}
/// <summary>
/// Start listening on the given port. Idempotent: if already running on the
/// same (port, bindToLan) combination, no-op; otherwise stop + restart.
/// </summary>
/// <param name="port">TCP port to listen on.</param>
/// <param name="bindToLan">
/// When true, binds to all interfaces (<c>http://+:port/</c>) so other
/// machines on the LAN can reach the control surface — typical for
/// "headless show machine + thin client controller" setups. When false
/// (default), binds to <c>127.0.0.1</c> only.
///
/// LAN binding requires either running Dragon-ISO as Administrator OR a
/// one-time URL ACL reservation at the OS level:
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
/// If neither is in place the listener throws AccessDeniedException
/// which we catch and surface as a logger warning.
/// </summary>
public void Start(int port, bool bindToLan = false)
{
if (IsRunning && Port == port && BoundToLan == bindToLan) return;
Stop();
Port = port;
BoundToLan = bindToLan;
_listener = new HttpListener();
var prefix = bindToLan
? $"http://+:{port}/"
: $"http://127.0.0.1:{port}/";
_listener.Prefixes.Add(prefix);
try
{
_listener.Start();
}
catch (HttpListenerException ex)
{
_logger?.LogWarning(ex,
"Could not start control surface on {Prefix}. " +
"If binding to LAN, run as Administrator once OR run: " +
"netsh http add urlacl url=http://+:{Port}/ user=Everyone",
prefix, port);
_listener = null;
return;
}
_cts = new CancellationTokenSource();
_acceptTask = Task.Run(() => AcceptLoopAsync(_cts.Token));
// Drive the WebSocket push loop on the UI dispatcher so we can read the
// ObservableCollection-backed Participants list without thread races. 4Hz
// is fast enough that operators see immediate feedback when they flip an
// ISO on the Stream Deck without us spamming the wire when nothing's
// changing — the snapshot serializer dedupes against the previous push.
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is not null)
{
_pushTimer = new DispatcherTimer(DispatcherPriority.Background, dispatcher)
{
Interval = TimeSpan.FromMilliseconds(250),
};
_pushTimer.Tick += async (_, _) => await PushSnapshotIfChangedAsync();
_pushTimer.Start();
}
IsRunning = true;
_logger?.LogInformation("Control surface listening on {Prefix} (REST + ws)", prefix);
}
public void Stop()
{
if (!IsRunning) return;
try { _pushTimer?.Stop(); } catch { /* ignore */ }
_pushTimer = null;
// Close + drop every connected WebSocket; clients will reconnect when the
// operator re-enables the surface.
foreach (var (id, ws) in _clients.ToArray())
{
try { ws.Abort(); } catch { /* ignore */ }
try { ws.Dispose(); } catch { /* ignore */ }
_clients.TryRemove(id, out _);
}
try { _cts?.Cancel(); } catch { /* ignore */ }
try { _listener?.Stop(); } catch { /* ignore */ }
try { _listener?.Close(); } catch { /* ignore */ }
try { _acceptTask?.Wait(TimeSpan.FromSeconds(2)); } catch { /* ignore */ }
_listener = null;
_cts?.Dispose();
_cts = null;
_acceptTask = null;
IsRunning = false;
}
public async ValueTask DisposeAsync()
{
Stop();
await Task.CompletedTask;
}
private async Task AcceptLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested && _listener is not null && _listener.IsListening)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync(); }
catch (HttpListenerException) { break; } // listener stopped
catch (ObjectDisposedException) { break; }
catch (InvalidOperationException) { break; }
// Each request gets its own task so a slow handler doesn't head-of-line block
// others. Handlers are short (no I/O beyond the controller call) so this is
// fine without explicit concurrency limits.
_ = Task.Run(() => HandleRequestAsync(ctx));
}
}
private async Task HandleRequestAsync(HttpListenerContext ctx)
{
var req = ctx.Request;
var res = ctx.Response;
// Tracks whether we should call res.Close() in the finally. WebSocket
// upgrades transfer ownership of the connection to the WebSocket
// instance — closing the response here would tear down the freshly-
// upgraded socket immediately. So we skip the finally close on that
// path.
var closeResponseInFinally = true;
try
{
res.Headers["Access-Control-Allow-Origin"] = "*";
if (req.HttpMethod == "OPTIONS")
{
res.Headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
res.Headers["Access-Control-Allow-Headers"] = "Content-Type";
res.StatusCode = 204;
return;
}
var path = req.Url?.AbsolutePath?.TrimEnd('/') ?? "";
// WebSocket upgrade: live state push for controllers that don't want
// to poll. Returns immediately after upgrading; HandleWebSocketAsync
// owns the connection until the client disconnects.
if (req.IsWebSocketRequest && path == "/ws")
{
var wsContext = await ctx.AcceptWebSocketAsync(subProtocol: null);
closeResponseInFinally = false;
_ = Task.Run(() => HandleWebSocketAsync(wsContext.WebSocket));
return;
}
var body = await ReadBodyAsync(req);
// GET /ui — embedded HTML control panel. Served as text/html
// rather than JSON so a browser renders it directly.
if (req.HttpMethod == "GET" && path == "/ui")
{
res.ContentType = "text/html; charset=utf-8";
var html = ControlPanelHtml.Get();
var bytes = System.Text.Encoding.UTF8.GetBytes(html);
res.ContentLength64 = bytes.Length;
await res.OutputStream.WriteAsync(bytes);
return;
}
// GET /participants/{id}/thumbnail.bmp — small BMP of the latest
// processed frame. Returns 404 when no pipeline is running for
// this participant. The HTML control panel uses this URL with
// a cache-busting query param every ~1s to drive live preview
// tiles. BMP (not JPEG) because WPF imaging types NRE from
// non-UI threads and BMP encodes in plain managed code; the
// 40KB payload at 192-wide compresses fine over LAN gzip.
// Old /thumbnail.jpg URL accepted for backward compat.
if (req.HttpMethod == "GET" && path.StartsWith("/participants/", StringComparison.Ordinal)
&& (path.EndsWith("/thumbnail.bmp", StringComparison.Ordinal) || path.EndsWith("/thumbnail.jpg", StringComparison.Ordinal)))
{
var ext = path.EndsWith("/thumbnail.bmp", StringComparison.Ordinal) ? "/thumbnail.bmp" : "/thumbnail.jpg";
var idSegment = path.AsSpan("/participants/".Length,
path.Length - "/participants/".Length - ext.Length).ToString();
if (!Guid.TryParse(idSegment, out var thumbId))
{
res.StatusCode = 400;
await WriteJsonAsync(res, new { error = "invalid id" });
return;
}
var bmp = TryEncodeThumbnailJpeg(thumbId);
if (bmp is null)
{
res.StatusCode = 404;
await WriteJsonAsync(res, new { error = "no frame", id = thumbId });
return;
}
res.ContentType = "image/bmp";
res.AddHeader("Cache-Control", "no-store, must-revalidate");
res.ContentLength64 = bmp.Length;
await res.OutputStream.WriteAsync(bmp);
return;
}
object? response = (req.HttpMethod, path) switch
{
("GET", "" or "/") => GetServerInfo(),
("GET", "/participants") => GetParticipants(),
("POST", "/presets/refresh-discovery") => RefreshDiscovery(),
("POST", "/presets/stop-all") => await StopAllAsync(),
("POST", "/teams/mute") => InvokeTeams(TeamsControlBridge.ToggleMute, "mute"),
("POST", "/teams/camera") => InvokeTeams(TeamsControlBridge.ToggleCamera, "camera"),
("POST", "/teams/leave") => InvokeTeams(TeamsControlBridge.LeaveCall, "leave"),
("POST", "/teams/share") => InvokeTeams(TeamsControlBridge.OpenShareTray, "share"),
("POST", "/teams/raise-hand") => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"),
// /recording routes removed alongside the rest of the recording surface.
// Topology — read the machine NDI config to report whether raw
// Teams NDI sources are hidden from the LAN, and let the
// operator apply / restore without leaving the web UI.
("GET", "/topology") => GetTopology(),
("POST", "/topology/apply") => await ApplyTopologyAsync(),
("POST", "/topology/restore") => await RestoreTopologyAsync(),
_ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal)
&& path.EndsWith("/override", StringComparison.Ordinal)
=> await SetIsoOverrideByIdAsync(path, body),
_ when req.HttpMethod == "DELETE" && path.StartsWith("/participants/", StringComparison.Ordinal)
&& path.EndsWith("/override", StringComparison.Ordinal)
=> await ClearIsoOverrideByIdAsync(path),
("POST", "/notes") => AppendNote(body, req.QueryString),
("POST", "/participants/iso") => await ToggleIsoByNameAsync(body, req.QueryString),
_ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal)
=> await ToggleIsoByIdAsync(path, body, req.QueryString),
_ when req.HttpMethod == "POST" && path.StartsWith("/presets/", StringComparison.Ordinal)
&& path.EndsWith("/apply", StringComparison.Ordinal)
=> await ApplyPresetAsync(path),
_ => NotFound(),
};
if (response is null)
{
res.StatusCode = 404;
await WriteJsonAsync(res, new { error = "not found" });
return;
}
await WriteJsonAsync(res, response);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Control surface request failed: {Path}", req.Url?.AbsolutePath);
try
{
res.StatusCode = 500;
await WriteJsonAsync(res, new { error = ex.Message });
}
catch { /* defensive */ }
}
finally
{
if (closeResponseInFinally)
{
try { res.Close(); } catch { /* defensive */ }
}
}
}
// ─── handlers ───────────────────────────────────────────────────────
//
// Endpoint bodies live in Services/ControlSurface/Endpoints/*.cs as
// partials of this class. See HomeEndpoints, ParticipantsEndpoints,
// PresetsEndpoints, TeamsEndpoints, TopologyEndpoints, NotesEndpoints,
// and ThumbnailEndpoint. The WebSocket push surface is at
// Services/ControlSurface/WebSocketHub.cs.
[SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")]
private object NotFound() => new { error = "not found" };
// ─── helpers ────────────────────────────────────────────────────────
private static async Task<JsonElement> ReadBodyAsync(HttpListenerRequest req)
{
if (req.HttpMethod != "POST" || req.ContentLength64 == 0) return default;
using var sr = new StreamReader(req.InputStream, req.ContentEncoding ?? Encoding.UTF8);
var raw = await sr.ReadToEndAsync();
if (string.IsNullOrWhiteSpace(raw)) return default;
try
{
return JsonSerializer.Deserialize<JsonElement>(raw);
}
catch
{
return default;
}
}
private static async Task WriteJsonAsync(HttpListenerResponse res, object payload)
{
res.ContentType = "application/json; charset=utf-8";
var json = JsonSerializer.Serialize(payload, JsonOpts);
var bytes = Encoding.UTF8.GetBytes(json);
res.ContentLength64 = bytes.Length;
await res.OutputStream.WriteAsync(bytes);
}
private static bool? TryGetBool(JsonElement body, System.Collections.Specialized.NameValueCollection query, string key)
{
if (body.ValueKind == JsonValueKind.Object &&
body.TryGetProperty(key, out var v) && v.ValueKind is JsonValueKind.True or JsonValueKind.False)
return v.GetBoolean();
var q = query[key];
if (q is null) return null;
return q.Equals("true", StringComparison.OrdinalIgnoreCase) || q == "1";
}
private static string? TryGetString(JsonElement body, System.Collections.Specialized.NameValueCollection query, string key)
{
if (body.ValueKind == JsonValueKind.Object &&
body.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String)
return v.GetString();
return query[key];
}
}

View file

@ -1,30 +1,30 @@
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Gathers logs + config + presets + version metadata into a single .zip the /// Gathers logs + config + presets + version metadata into a single .zip the
/// operator can attach to a bug report. Surfaced via the "Export diagnostics" /// operator can attach to a bug report. Surfaced via the "Export diagnostics"
/// button in About. /// button in About.
/// ///
/// We deliberately do NOT include screenshots or any process/memory dumps /// We deliberately do NOT include screenshots or any process/memory dumps —
/// that's outside the scope of a v1 support bundle and would raise privacy /// that's outside the scope of a v1 support bundle and would raise privacy
/// flags. The bundle has only files the user already wrote with their TeamsISO /// flags. The bundle has only files the user already wrote with their Dragon-ISO
/// usage; nothing here is hidden state. /// usage; nothing here is hidden state.
/// </summary> /// </summary>
public static class DiagnosticsBundle public static class DiagnosticsBundle
{ {
/// <summary> /// <summary>
/// Build the bundle and return the path it was written to. /// Build the bundle and return the path it was written to.
/// Throws on disk failure the caller toasts/dialogs. /// Throws on disk failure — the caller toasts/dialogs.
/// </summary> /// </summary>
public static string Export() public static string Export()
{ {
var ts = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss"); var ts = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
var fileName = $"teamsiso-diagnostics-{ts}.zip"; var fileName = $"Dragon-ISO-diagnostics-{ts}.zip";
var outDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var outDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var downloads = Path.Combine(outDir, "Downloads"); var downloads = Path.Combine(outDir, "Downloads");
if (!Directory.Exists(downloads)) downloads = outDir; // fall back to ~/ if (!Directory.Exists(downloads)) downloads = outDir; // fall back to ~/
@ -51,9 +51,9 @@ public static class DiagnosticsBundle
?? asm.GetName().Version?.ToString() ?? asm.GetName().Version?.ToString()
?? "unknown"; ?? "unknown";
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("TeamsISO diagnostic bundle"); sb.AppendLine("Dragon-ISO diagnostic bundle");
sb.AppendLine($"Generated: {DateTimeOffset.Now:o}"); sb.AppendLine($"Generated: {DateTimeOffset.Now:o}");
sb.AppendLine($"TeamsISO version: {version}"); sb.AppendLine($"Dragon-ISO version: {version}");
sb.AppendLine($".NET runtime: {Environment.Version}"); sb.AppendLine($".NET runtime: {Environment.Version}");
sb.AppendLine($"OS: {Environment.OSVersion}"); sb.AppendLine($"OS: {Environment.OSVersion}");
sb.AppendLine($"Machine: {Environment.MachineName}"); sb.AppendLine($"Machine: {Environment.MachineName}");
@ -115,17 +115,17 @@ public static class DiagnosticsBundle
private static string LogsDirectory => private static string LogsDirectory =>
Path.Combine( Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Logs"); "Dragon-ISO", "Logs");
private static string LocalAppDataPath(string fileName) => private static string LocalAppDataPath(string fileName) =>
Path.Combine( Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", fileName); "Dragon-ISO", fileName);
private static string AppDataPath(string fileName) => private static string AppDataPath(string fileName) =>
Path.Combine( Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"TeamsISO", fileName); "Dragon-ISO", fileName);
private static string NdiConfigPath() => private static string NdiConfigPath() =>
Path.Combine( Path.Combine(

View file

@ -1,19 +1,19 @@
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Reads and writes NDI Access Manager's per-user config at /// Reads and writes NDI Access Manager's per-user config at
/// <c>%APPDATA%\NDI\ndi-config.v1.json</c>. This file controls global NDI behavior for /// <c>%APPDATA%\NDI\ndi-config.v1.json</c>. This file controls global NDI behavior for
/// every NDI application on the machine sender groups, receiver groups, RUDP/TCP /// every NDI application on the machine — sender groups, receiver groups, RUDP/TCP
/// transport toggles, allowed adapters, etc. NDI applications read it on startup, so /// transport toggles, allowed adapters, etc. NDI applications read it on startup, so
/// changes here only take effect after restarting the affected app (Teams, OBS, etc.). /// changes here only take effect after restarting the affected app (Teams, OBS, etc.).
/// ///
/// We use it to implement the "transcoder topology" requested by the user: pin Teams' /// We use it to implement the "transcoder topology" requested by the user: pin Teams'
/// raw at-source-resolution NDI broadcasts to a private group (<c>teamsiso-input</c>) so /// raw at-source-resolution NDI broadcasts to a private group (<c>Dragon-ISO-input</c>) so
/// they don't pollute the production network, while TeamsISO's own clean normalized ISO /// they don't pollute the production network, while Dragon-ISO's own clean normalized ISO
/// outputs continue to broadcast on the standard <c>Public</c> group that downstream /// outputs continue to broadcast on the standard <c>Public</c> group that downstream
/// switchers and recorders default to. /// switchers and recorders default to.
/// ///
@ -36,7 +36,7 @@ public static class NdiAccessManagerConfig
/// Default name of the private group used for the transcoder topology. /// Default name of the private group used for the transcoder topology.
/// Matches the convention referenced in the NDI Network settings UI. /// Matches the convention referenced in the NDI Network settings UI.
/// </summary> /// </summary>
public const string TranscoderInputGroup = "teamsiso-input"; public const string TranscoderInputGroup = "Dragon-ISO-input";
/// <summary> /// <summary>
/// Result of an apply attempt. <see cref="Success"/> indicates the file was /// Result of an apply attempt. <see cref="Success"/> indicates the file was
@ -54,12 +54,12 @@ public static class NdiAccessManagerConfig
/// Configures the machine-wide NDI groups so: /// Configures the machine-wide NDI groups so:
/// <list type="bullet"> /// <list type="bullet">
/// <item>All local senders (Teams, anything else) broadcast on /// <item>All local senders (Teams, anything else) broadcast on
/// <paramref name="senderGroup"/> only i.e. the private input group.</item> /// <paramref name="senderGroup"/> only — i.e. the private input group.</item>
/// <item>All local receivers see both <paramref name="senderGroup"/> and /// <item>All local receivers see both <paramref name="senderGroup"/> and
/// <c>public</c> so TeamsISO can discover Teams' sources AND any /// <c>public</c> so Dragon-ISO can discover Teams' sources AND any
/// standard public sources from elsewhere on the network.</item> /// standard public sources from elsewhere on the network.</item>
/// </list> /// </list>
/// TeamsISO's own per-pipeline <c>OutputGroups</c> still overrides the per-machine /// Dragon-ISO's own per-pipeline <c>OutputGroups</c> still overrides the per-machine
/// default at the sender level, so its normalized ISO outputs go on Public. /// default at the sender level, so its normalized ISO outputs go on Public.
/// </summary> /// </summary>
/// <param name="senderGroup">Private group name for Teams' raw broadcasts.</param> /// <param name="senderGroup">Private group name for Teams' raw broadcasts.</param>

View file

@ -1,13 +1,13 @@
using System.IO; using System.IO;
using System.Text; using System.Text;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Append-only show-notes log. Each call writes a timestamped line to a daily /// Append-only show-notes log. Each call writes a timestamped line to a daily
/// markdown file at <c>%LOCALAPPDATA%\TeamsISO\Notes\&lt;YYYY-MM-DD&gt;.md</c>. /// markdown file at <c>%LOCALAPPDATA%\Dragon-ISO\Notes\&lt;YYYY-MM-DD&gt;.md</c>.
/// Operators stamp notes via the REST <c>POST /notes</c> endpoint or the OSC /// Operators stamp notes via the REST <c>POST /notes</c> endpoint or the OSC
/// <c>/teamsiso/notes "..."</c> address — typically wired to a Stream Deck /// <c>/Dragon-ISO/notes "..."</c> address — typically wired to a Stream Deck
/// button so a note can be left without leaving the show. /// button so a note can be left without leaving the show.
/// ///
/// We deliberately don't surface the notes inside the WPF UI: the file is /// We deliberately don't surface the notes inside the WPF UI: the file is
@ -19,10 +19,18 @@ 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%\Dragon-ISO\Notes path. Lets tests write to a
/// tempdir without polluting the dev's real notes folder.
/// InternalsVisibleTo grants DragonISO.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"); "Dragon-ISO", "Notes");
/// <summary>Today's notes file path (created lazily on first append).</summary> /// <summary>Today's notes file path (created lazily on first append).</summary>
public static string TodayPath => public static string TodayPath =>
@ -42,10 +50,10 @@ public static class NotesService
{ {
Directory.CreateDirectory(NotesDirectory); Directory.CreateDirectory(NotesDirectory);
var path = TodayPath; var path = TodayPath;
var line = $"- **{DateTimeOffset.Now:HH:mm:ss}** {text.Trim()}{Environment.NewLine}"; var line = $"- **{DateTimeOffset.Now:HH:mm:ss}** — {text.Trim()}{Environment.NewLine}";
if (!File.Exists(path)) if (!File.Exists(path))
{ {
var header = $"# TeamsISO show notes — {DateTimeOffset.Now:yyyy-MM-dd}{Environment.NewLine}{Environment.NewLine}"; var header = $"# Dragon-ISO show notes — {DateTimeOffset.Now:yyyy-MM-dd}{Environment.NewLine}{Environment.NewLine}";
File.WriteAllText(path, header, Encoding.UTF8); File.WriteAllText(path, header, Encoding.UTF8);
} }
File.AppendAllText(path, line, Encoding.UTF8); File.AppendAllText(path, line, Encoding.UTF8);

View file

@ -1,7 +1,7 @@
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Persistent named snapshots of which participants should have ISOs enabled and /// Persistent named snapshots of which participants should have ISOs enabled and
@ -10,7 +10,7 @@ namespace TeamsISO.App.Services;
/// meeting load the same preset and auto-enable everyone whose display name /// meeting load the same preset and auto-enable everyone whose display name
/// matches. /// matches.
/// ///
/// Persisted as JSON at <c>%LOCALAPPDATA%\TeamsISO\presets.json</c>. We key by /// Persisted as JSON at <c>%LOCALAPPDATA%\Dragon-ISO\presets.json</c>. We key by
/// participant <see cref="DisplayName"/> rather than <see cref="Guid"/> Id /// participant <see cref="DisplayName"/> rather than <see cref="Guid"/> Id
/// because the Id is freshly generated for every meeting (Teams' NDI source /// because the Id is freshly generated for every meeting (Teams' NDI source
/// identity isn't stable across sessions); display name is the operator's /// identity isn't stable across sessions); display name is the operator's
@ -29,7 +29,7 @@ public static class OperatorPresetStore
private static string PresetsPath => private static string PresetsPath =>
PathOverride ?? Path.Combine( PathOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Dragon-ISO",
"presets.json"); "presets.json");
/// <summary> /// <summary>
@ -77,7 +77,7 @@ public static class OperatorPresetStore
/// <summary> /// <summary>
/// Returns the operator's startup preference (which preset, if any, should be /// Returns the operator's startup preference (which preset, if any, should be
/// auto-applied on launch). Defaults to <c>(null, false)</c> when no file exists /// auto-applied on launch). Defaults to <c>(null, false)</c> when no file exists
/// or the file predates the field older preset.json files deserialize cleanly /// or the file predates the field — older preset.json files deserialize cleanly
/// because both fields are optional with default values. /// because both fields are optional with default values.
/// </summary> /// </summary>
public static StartupPreference GetStartupPreference() public static StartupPreference GetStartupPreference()
@ -153,8 +153,8 @@ public static class OperatorPresetStore
/// <summary> /// <summary>
/// Bundle format for the export/import surface. Wraps the preset list with /// Bundle format for the export/import surface. Wraps the preset list with
/// a version stamp + an export timestamp so a future-format-aware importer /// a version stamp + an export timestamp so a future-format-aware importer
/// can migrate the data. We deliberately export a flat preset list not /// can migrate the data. We deliberately export a flat preset list — not
/// the full <see cref="File"/> envelope because StartupPreference is /// the full <see cref="File"/> envelope — because StartupPreference is
/// machine-local (operator A's "auto-apply Friday Show" shouldn't follow /// machine-local (operator A's "auto-apply Friday Show" shouldn't follow
/// the bundle to operator B's machine). /// the bundle to operator B's machine).
/// </summary> /// </summary>
@ -163,7 +163,7 @@ public static class OperatorPresetStore
DateTimeOffset ExportedAt, DateTimeOffset ExportedAt,
IReadOnlyList<Preset> Presets) IReadOnlyList<Preset> Presets)
{ {
public const string CurrentSchema = "teamsiso-presets-bundle/v1"; public const string CurrentSchema = "Dragon-ISO-presets-bundle/v1";
} }
/// <summary> /// <summary>
@ -181,7 +181,7 @@ public static class OperatorPresetStore
} }
/// <summary> /// <summary>
/// Result of an import attempt counts so the UI can toast a clear summary. /// Result of an import attempt — counts so the UI can toast a clear summary.
/// </summary> /// </summary>
public sealed record ImportResult(int Added, int Overwritten, int Skipped, string? Error) public sealed record ImportResult(int Added, int Overwritten, int Skipped, string? Error)
{ {

View file

@ -1,39 +1,39 @@
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TeamsISO.App.ViewModels; using DragonISO.App.ViewModels;
using TeamsISO.Engine.Controller; using DragonISO.Engine.Controller;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// OSC over UDP. Companion, TouchOSC, Bome, and most lighting consoles speak /// OSC over UDP. Companion, TouchOSC, Bome, and most lighting consoles speak
/// OSC natively, so wrapping the same command surface in OSC opens the /// OSC natively, so wrapping the same command surface in OSC opens the
/// product to the broader live-show ecosystem without a Companion bridge. /// product to the broader live-show ecosystem without a Companion bridge.
/// ///
/// Protocol minimal OSC 1.0: /// Protocol — minimal OSC 1.0:
/// - Address pattern (null-terminated string, padded to 4-byte boundary) /// - Address pattern (null-terminated string, padded to 4-byte boundary)
/// - Type tag (",iiisf" etc., null-terminated, padded to 4) /// - Type tag (",iiisf" etc., null-terminated, padded to 4)
/// - Args in order /// - Args in order
/// ///
/// We don't implement bundles, time tags, blob args, or pattern matching /// We don't implement bundles, time tags, blob args, or pattern matching
/// none are needed for the verbs we support. If a sender uses bundles /// — none are needed for the verbs we support. If a sender uses bundles
/// we ignore them; if a sender uses a wildcard address ("/teamsiso/*") we /// we ignore them; if a sender uses a wildcard address ("/Dragon-ISO/*") we
/// ignore it. Operators get a clear log line in either case. /// ignore it. Operators get a clear log line in either case.
/// ///
/// Routes: /// Routes:
/// /teamsiso/iso "DisplayName" {0|1} — toggle/set ISO by display name /// /Dragon-ISO/iso "DisplayName" {0|1} — toggle/set ISO by display name
/// /teamsiso/iso/by-id "guid" {0|1} — toggle/set by Id /// /Dragon-ISO/iso/by-id "guid" {0|1} — toggle/set by Id
/// /teamsiso/preset "Name" — apply preset /// /Dragon-ISO/preset "Name" — apply preset
/// /teamsiso/teams/mute — UIA toggle mute /// /Dragon-ISO/teams/mute — UIA toggle mute
/// /teamsiso/teams/camera — UIA toggle camera /// /Dragon-ISO/teams/camera — UIA toggle camera
/// /teamsiso/teams/leave — UIA leave /// /Dragon-ISO/teams/leave — UIA leave
/// /teamsiso/teams/share — UIA share tray /// /Dragon-ISO/teams/share — UIA share tray
/// /teamsiso/teams/raise-hand — UIA raise hand /// /Dragon-ISO/teams/raise-hand — UIA raise hand
/// /teamsiso/refresh-discovery — rebuild NDI finder /// /Dragon-ISO/refresh-discovery — rebuild NDI finder
/// /teamsiso/stop-all — disable every ISO /// /Dragon-ISO/stop-all — disable every ISO
/// /teamsiso/recording {0|1} — recording on/off (default dir) /// /Dragon-ISO/recording {0|1} — recording on/off (default dir)
/// </summary> /// </summary>
public sealed class OscBridge : IAsyncDisposable public sealed class OscBridge : IAsyncDisposable
{ {
@ -63,9 +63,9 @@ public sealed class OscBridge : IAsyncDisposable
/// <summary> /// <summary>
/// Start the OSC listener on the given UDP port. <paramref name="bindToLan"/> /// Start the OSC listener on the given UDP port. <paramref name="bindToLan"/>
/// flag selects between loopback (default only this machine) and any- /// flag selects between loopback (default — only this machine) and any-
/// interface binding (LAN-reachable, for thin-client controllers). /// interface binding (LAN-reachable, for thin-client controllers).
/// Unlike the REST surface, UDP doesn't need a URL ACL binding 0.0.0.0 /// Unlike the REST surface, UDP doesn't need a URL ACL — binding 0.0.0.0
/// is just an unprivileged port reservation. /// is just an unprivileged port reservation.
/// </summary> /// </summary>
public void Start(int port, bool bindToLan = false) public void Start(int port, bool bindToLan = false)
@ -139,30 +139,33 @@ 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)
{ {
case "/teamsiso/teams/mute": InvokeTeams(TeamsControlBridge.ToggleMute); return; case "/Dragon-ISO/teams/mute": InvokeTeams(TeamsControlBridge.ToggleMute); return;
case "/teamsiso/teams/camera": InvokeTeams(TeamsControlBridge.ToggleCamera); return; case "/Dragon-ISO/teams/camera": InvokeTeams(TeamsControlBridge.ToggleCamera); return;
case "/teamsiso/teams/leave": InvokeTeams(TeamsControlBridge.LeaveCall); return; case "/Dragon-ISO/teams/leave": InvokeTeams(TeamsControlBridge.LeaveCall); return;
case "/teamsiso/teams/share": InvokeTeams(TeamsControlBridge.OpenShareTray); return; case "/Dragon-ISO/teams/share": InvokeTeams(TeamsControlBridge.OpenShareTray); return;
case "/teamsiso/teams/raise-hand":InvokeTeams(TeamsControlBridge.ToggleRaiseHand); return; case "/Dragon-ISO/teams/raise-hand":InvokeTeams(TeamsControlBridge.ToggleRaiseHand); return;
case "/teamsiso/refresh-discovery":_controller.RefreshDiscovery(); return; case "/Dragon-ISO/refresh-discovery":_controller.RefreshDiscovery(); return;
case "/teamsiso/stop-all": await StopAllAsync(); return; case "/Dragon-ISO/stop-all": await StopAllAsync(); return;
// /teamsiso/recording routes removed alongside the rest of the recording surface. // /Dragon-ISO/recording routes removed alongside the rest of the recording surface.
case "/teamsiso/notes": AppendNote(msg); return; case "/Dragon-ISO/notes": AppendNote(msg); return;
case "/teamsiso/iso": await ToggleByNameAsync(msg); return; case "/Dragon-ISO/iso": await ToggleByNameAsync(msg); return;
case "/teamsiso/iso/by-id": await ToggleByIdAsync(msg); return; case "/Dragon-ISO/iso/by-id": await ToggleByIdAsync(msg); return;
case "/teamsiso/preset": await ApplyPresetAsync(msg); return; case "/Dragon-ISO/preset": await ApplyPresetAsync(msg); return;
default: default:
_logger?.LogDebug("Ignoring unknown OSC address {Addr}", addr); _logger?.LogDebug("Ignoring unknown OSC address {Addr}", addr);
return; return;
} }
} }
// ─── handler helpers ──────────────────────────────────────────────── // ─── handler helpers ────────────────────────────────────────────────
private static void InvokeTeams(Func<TeamsControlBridge.InvokeResult> action) => _ = action(); private static void InvokeTeams(Func<TeamsControlBridge.InvokeResult> action) => _ = action();
@ -262,12 +265,12 @@ public sealed class OscBridge : IAsyncDisposable
} }
} }
// ─── OSC message parser ───────────────────────────────────────────────── // ─── OSC message parser ─────────────────────────────────────────────────
/// <summary> /// <summary>
/// Minimal OSC 1.0 message parser. Supports the subset we care about: /// Minimal OSC 1.0 message parser. Supports the subset we care about:
/// integer (i), float (f), string (s) args. Bundles / time tags / blobs are /// integer (i), float (f), string (s) args. Bundles / time tags / blobs are
/// not implemented incoming packets that look like bundles return null /// not implemented — incoming packets that look like bundles return null
/// and the caller logs + skips them. /// and the caller logs + skips them.
/// </summary> /// </summary>
internal sealed class OscMessage internal sealed class OscMessage
@ -280,7 +283,7 @@ internal sealed class OscMessage
public static OscMessage? TryParse(byte[] bytes) public static OscMessage? TryParse(byte[] bytes)
{ {
if (bytes.Length < 8) return null; if (bytes.Length < 8) return null;
// Bundle marker we don't support bundles. Skip. // Bundle marker — we don't support bundles. Skip.
if (bytes[0] == '#') return null; if (bytes[0] == '#') return null;
var idx = 0; var idx = 0;
@ -314,7 +317,7 @@ internal sealed class OscMessage
case 'T': args.Add(true); break; case 'T': args.Add(true); break;
case 'F': args.Add(false); break; case 'F': args.Add(false); break;
default: default:
// Unknown type bail rather than mis-aligning subsequent args. // Unknown type — bail rather than mis-aligning subsequent args.
return null; return null;
} }
} }

View file

@ -0,0 +1,150 @@
using System.IO;
using System.Linq;
using System.Text;
namespace DragonISO.App.Services;
/// <summary>
/// User-editable template for the NDI source name a participant's ISO is
/// published as. Default <c>"{name}"</c> renders the speaker's display name
/// directly, which is what downstream switchers want when they key on
/// readable identifiers. Operators can override globally to
/// <c>"Dragon-ISO_{guid}"</c> for the legacy stable-id behavior, or
/// <c>"Dragon-ISO_{machine}_{name}"</c> when multiple Dragon-ISO machines feed
/// the same NDI network and you want the source name to carry both.
/// Per-participant overrides take priority over whatever template is set.
///
/// Tokens expanded in <see cref="Render"/>:
/// <c>{name}</c> participant display name, sanitized (alphanumeric + underscore)
/// <c>{guid}</c> first 8 hex chars of the participant's Id, uppercase
/// <c>{machine}</c> sanitized PC hostname (Environment.MachineName)
/// <c>{timestamp}</c> current local time as <c>yyyyMMdd_HHmmss</c>
///
/// Empty-name fallback: if the rendered result is empty/whitespace (e.g.
/// template was <c>"{name}"</c> and the participant joined with no display
/// name yet), <see cref="Render"/> falls back to <c>Dragon-ISO_{guid}</c> so
/// the NDI sender always has a usable, unique identifier.
///
/// Persisted to <c>%LOCALAPPDATA%\Dragon-ISO\output-name-template.txt</c>.
/// </summary>
public static class OutputNameTemplate
{
/// <summary>
/// Default template — renders just the speaker's display name. Was
/// <c>"Dragon-ISO_{guid}"</c> in pre-v1 builds; switched 2026-05-16 so
/// new installs get human-readable source names out of the box.
/// </summary>
public const string DefaultTemplate = "{name}";
/// <summary>
/// Stable fallback used when the rendered template produces an empty
/// string (typically because a participant has no display name yet).
/// Mirrors the engine's legacy DefaultOutputName so the NDI sender is
/// always uniquely identifiable.
/// </summary>
private const string EmptyNameFallback = "Dragon-ISO_{guid}";
private static string TemplatePath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Dragon-ISO", "output-name-template.txt");
/// <summary>
/// Get the operator's current template, or the shipped default when no
/// override has been saved (or the override file is missing/unreadable).
/// </summary>
public static string Get()
{
try
{
if (File.Exists(TemplatePath))
{
var raw = File.ReadAllText(TemplatePath).Trim();
if (!string.IsNullOrEmpty(raw)) return raw;
}
}
catch
{
// Disk read failure → fall through to default. The next Set() call
// will overwrite cleanly.
}
return DefaultTemplate;
}
public static void Set(string template)
{
try
{
var dir = Path.GetDirectoryName(TemplatePath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(TemplatePath, template ?? string.Empty);
}
catch
{
// Best-effort persistence; the in-memory value still sticks for
// this session.
}
}
/// <summary>
/// Expand tokens in <paramref name="template"/> for a specific participant.
/// Result is sanitized into NDI-safe characters: alphanumeric, underscore,
/// hyphen, period. NDI spec allows more, but a conservative set keeps
/// downstream switchers happy.
/// </summary>
public static string Render(string template, Guid participantId, string displayName)
{
var safeName = SanitizeForNdi(displayName);
var guid = participantId.ToString("N")[..8].ToUpperInvariant();
var machine = SanitizeForNdi(Environment.MachineName);
var timestamp = DateTimeOffset.Now.ToString("yyyyMMdd_HHmmss");
var result = template
.Replace("{name}", safeName)
.Replace("{guid}", guid)
.Replace("{machine}", machine)
.Replace("{timestamp}", timestamp);
// Final sanitize on the rendered result — protects against a template
// that includes literal characters NDI doesn't accept.
var sanitized = SanitizeForNdi(result);
// Empty-name fallback. The default template "{name}" can render to
// an unusable result for participants whose DisplayName hasn't been
// populated yet (Teams sometimes delivers the displayName a tick
// after the participant join event). Two failure modes to catch:
//
// • DisplayName == "" → "{name}" expands to "" → sanitized "".
// • DisplayName == " " → "{name}" expands to "___" because the
// sanitizer converts whitespace to underscores.
//
// Neither is a meaningful NDI source identifier, so we substitute
// Dragon-ISO_{guid}. The Any(char.IsLetterOrDigit) check covers both
// cases — anything without at least one alphanumeric is unusable.
// We apply this AFTER token expansion (not on the raw input) so a
// template like "PFX_{name}" with empty displayName still works:
// it renders to "PFX_" which contains alphanumerics and is left
// alone.
if (string.IsNullOrWhiteSpace(sanitized) || !sanitized.Any(char.IsLetterOrDigit))
{
sanitized = SanitizeForNdi(EmptyNameFallback.Replace("{guid}", guid));
}
return sanitized;
}
private static string SanitizeForNdi(string s)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
var sb = new StringBuilder(s.Length);
foreach (var c in s)
{
if (char.IsLetterOrDigit(c) || c is '_' or '-' or '.')
sb.Append(c);
else if (char.IsWhiteSpace(c))
sb.Append('_');
// else: skip
}
return sb.ToString();
}
}

View file

@ -1,8 +1,8 @@
using System.Windows.Threading; using System.Windows.Threading;
using TeamsISO.App.ViewModels; using DragonISO.App.ViewModels;
using TeamsISO.Engine.Controller; using DragonISO.Engine.Controller;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Shared preset-application logic. Originally lived inline in /// Shared preset-application logic. Originally lived inline in
@ -36,7 +36,7 @@ public static class PresetApplier
Dispatcher? dispatcher = null, Dispatcher? dispatcher = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
// Build the lookup once, case-insensitive Teams display names are // Build the lookup once, case-insensitive — Teams display names are
// human-typed, so "Jane" and "jane" should match the same row. // human-typed, so "Jane" and "jane" should match the same row.
var byName = preset.Assignments.ToDictionary( var byName = preset.Assignments.ToDictionary(
a => a.DisplayName, a => a.DisplayName,

View file

@ -1,10 +1,10 @@
using System.Diagnostics; using System.Diagnostics;
using System.Windows.Automation; using System.Windows.Automation;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Phase E.3 UIAutomation bridge for the in-call controls (mute, camera, /// Phase E.3 — UIAutomation bridge for the in-call controls (mute, camera,
/// leave, share screen). Walks Teams' automation tree to locate the relevant /// leave, share screen). Walks Teams' automation tree to locate the relevant
/// buttons and invokes their <see cref="InvokePattern"/> or <see cref="TogglePattern"/>. /// buttons and invokes their <see cref="InvokePattern"/> or <see cref="TogglePattern"/>.
/// ///
@ -21,20 +21,20 @@ namespace TeamsISO.App.Services;
/// window; we enumerate every top-level window owned by every Teams process, /// window; we enumerate every top-level window owned by every Teams process,
/// so we'll find it wherever it lives. /// so we'll find it wherever it lives.
/// - Hidden windows (after <see cref="TeamsLauncher.HideWindows"/>) are still /// - Hidden windows (after <see cref="TeamsLauncher.HideWindows"/>) are still
/// traversable by UIAutomation the buttons exist in the automation tree /// traversable by UIAutomation — the buttons exist in the automation tree
/// even when their HWND is SW_HIDDEN. This is what makes the "hide Teams, /// even when their HWND is SW_HIDDEN. This is what makes the "hide Teams,
/// drive it from TeamsISO" workflow viable. /// drive it from Dragon-ISO" workflow viable.
/// </summary> /// </summary>
public static class TeamsControlBridge public static class TeamsControlBridge
{ {
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
// Localized candidate-name lists. // Localized candidate-name lists.
// //
// Teams localizes the AutomationElement.Name we match against. The lookup // Teams localizes the AutomationElement.Name we match against. The lookup
// strategy is: ALL candidate strings across all locales are tried for each // strategy is: ALL candidate strings across all locales are tried for each
// command, and the first match wins. This gives us a single binary that // command, and the first match wins. This gives us a single binary that
// works regardless of the Teams UI language without needing to detect it // works regardless of the Teams UI language without needing to detect it
// at the cost of a slightly broader match surface (a non-mute button // — at the cost of a slightly broader match surface (a non-mute button
// with the German word "Stumm" in its name would false-positive). In // with the German word "Stumm" in its name would false-positive). In
// practice Teams' button Names are highly distinctive and we haven't seen // practice Teams' button Names are highly distinctive and we haven't seen
// false positives during development. // false positives during development.
@ -42,7 +42,7 @@ public static class TeamsControlBridge
// Adding a locale: append the localized strings to each command's array. // Adding a locale: append the localized strings to each command's array.
// Order doesn't matter for correctness; for performance we put the most // Order doesn't matter for correctness; for performance we put the most
// common locales first since the array is iterated in order. // common locales first since the array is iterated in order.
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
private static readonly string[] MuteCandidates = private static readonly string[] MuteCandidates =
{ {
@ -51,13 +51,13 @@ public static class TeamsControlBridge
// German // German
"Stumm", "Stummschaltung", "Stummschalten", "Stummschaltung aufheben", "Mikrofon", "Stumm", "Stummschaltung", "Stummschalten", "Stummschaltung aufheben", "Mikrofon",
// Spanish // Spanish
"Silenciar", "Activar audio", "Micrófono", "Silenciar", "Activar audio", "Micrófono",
// French // French
"Désactiver le micro", "Activer le micro", "Micro", "Microphone", "Désactiver le micro", "Activer le micro", "Micro", "Microphone",
// Portuguese // Portuguese
"Desativar áudio", "Ativar áudio", "Microfone", "Desativar áudio", "Ativar áudio", "Microfone",
// Japanese // Japanese
"ミュート", "ミュート解除", "マイク", "ミュート", "ミュート解除", "マイク",
}; };
private static readonly string[] CameraCandidates = private static readonly string[] CameraCandidates =
@ -66,13 +66,13 @@ public static class TeamsControlBridge
// German // German
"Kamera", "Kamera einschalten", "Kamera ausschalten", "Video", "Kamera", "Kamera einschalten", "Kamera ausschalten", "Video",
// Spanish // Spanish
"Cámara", "Activar cámara", "Desactivar cámara", "Vídeo", "Cámara", "Activar cámara", "Desactivar cámara", "Vídeo",
// French // French
"Caméra", "Activer la caméra", "Désactiver la caméra", "Vidéo", "Caméra", "Activer la caméra", "Désactiver la caméra", "Vidéo",
// Portuguese // Portuguese
"Câmera", "Ativar câmera", "Desativar câmera", "Câmera", "Ativar câmera", "Desativar câmera",
// Japanese // Japanese
"カメラ", "ビデオ", "カメラ", "ビデオ",
}; };
private static readonly string[] LeaveCandidates = private static readonly string[] LeaveCandidates =
@ -87,7 +87,7 @@ public static class TeamsControlBridge
// Portuguese // Portuguese
"Sair", "Desligar", "Encerrar chamada", "Sair", "Desligar", "Encerrar chamada",
// Japanese // Japanese
"退出", "通話を終了", "退出", "通話を終了",
}; };
private static readonly string[] ShareCandidates = private static readonly string[] ShareCandidates =
@ -98,11 +98,11 @@ public static class TeamsControlBridge
// Spanish // Spanish
"Compartir", "Compartir contenido", "Compartir pantalla", "Compartir", "Compartir contenido", "Compartir pantalla",
// French // French
"Partager", "Partager du contenu", "Partager l'écran", "Partager", "Partager du contenu", "Partager l'écran",
// Portuguese // Portuguese
"Compartilhar", "Compartilhar conteúdo", "Compartilhar tela", "Compartilhar", "Compartilhar conteúdo", "Compartilhar tela",
// Japanese // Japanese
"共有", "コンテンツの共有", "画面を共有", "共有", "コンテンツの共有", "画面を共有",
}; };
private static readonly string[] RaiseHandCandidates = private static readonly string[] RaiseHandCandidates =
@ -115,9 +115,9 @@ public static class TeamsControlBridge
// French // French
"Lever la main", "Baisser la main", "Lever la main", "Baisser la main",
// Portuguese // Portuguese
"Levantar a mão", "Abaixar a mão", "Levantar a mão", "Abaixar a mão",
// Japanese // Japanese
"手を挙げる", "手を下ろす", "手を挙げる", "手を下ろす",
}; };
private static readonly string[] ToggleChatCandidates = private static readonly string[] ToggleChatCandidates =
@ -126,13 +126,13 @@ public static class TeamsControlBridge
// German // German
"Unterhaltung anzeigen", "Unterhaltung ausblenden", "Chat", "Unterhaltung anzeigen", "Unterhaltung ausblenden", "Chat",
// Spanish // Spanish
"Mostrar conversación", "Ocultar conversación", "Chat", "Mostrar conversación", "Ocultar conversación", "Chat",
// French // French
"Afficher la conversation", "Masquer la conversation", "Conversation", "Afficher la conversation", "Masquer la conversation", "Conversation",
// Portuguese // Portuguese
"Mostrar conversa", "Ocultar conversa", "Chat", "Mostrar conversa", "Ocultar conversa", "Chat",
// Japanese // Japanese
"会話を表示", "会話を非表示", "チャット", "会話を表示", "会話を非表示", "チャット",
}; };
private static readonly string[] BackgroundBlurCandidates = private static readonly string[] BackgroundBlurCandidates =
@ -143,11 +143,11 @@ public static class TeamsControlBridge
// Spanish // Spanish
"Efectos de fondo", "Filtros de fondo", "Efectos de fondo", "Filtros de fondo",
// French // French
"Effets d'arrière-plan", "Filtres d'arrière-plan", "Effets d'arrière-plan", "Filtres d'arrière-plan",
// Portuguese // Portuguese
"Efeitos de plano de fundo", "Filtros de plano de fundo", "Efeitos de plano de fundo", "Filtros de plano de fundo",
// Japanese // Japanese
"背景効果", "背景フィルター", "背景効果", "背景フィルター",
}; };
/// <summary>Result of attempting one of the in-call commands.</summary> /// <summary>Result of attempting one of the in-call commands.</summary>
@ -186,7 +186,7 @@ public static class TeamsControlBridge
/// ///
/// Returns IsInCall=false if Teams isn't running or no Leave button /// Returns IsInCall=false if Teams isn't running or no Leave button
/// exists. Returns IsMuted/IsCameraOff as null if those buttons aren't /// exists. Returns IsMuted/IsCameraOff as null if those buttons aren't
/// found in this build (defensive Teams sometimes uses different /// found in this build (defensive — Teams sometimes uses different
/// candidate names across locales). /// candidate names across locales).
/// </summary> /// </summary>
public static CallStateSnapshot DetectCallState() public static CallStateSnapshot DetectCallState()
@ -225,11 +225,11 @@ public static class TeamsControlBridge
{ {
if (lower.Contains("unmute") || lower.Contains("stummschaltung aufheben") || if (lower.Contains("unmute") || lower.Contains("stummschaltung aufheben") ||
lower.Contains("activar audio") || lower.Contains("activer le micro") || lower.Contains("activar audio") || lower.Contains("activer le micro") ||
lower.Contains("ativar áudio") || lower.Contains("ミュート解除")) lower.Contains("ativar áudio") || lower.Contains("ミュート解除"))
muted = true; muted = true;
else if (lower.Contains("mute") || lower.Contains("stummschalten") || else if (lower.Contains("mute") || lower.Contains("stummschalten") ||
lower.Contains("silenciar") || lower.Contains("désactiver le micro") || lower.Contains("silenciar") || lower.Contains("désactiver le micro") ||
lower.Contains("desativar áudio") || lower.Contains("ミュート")) lower.Contains("desativar áudio") || lower.Contains("ミュート"))
muted = false; muted = false;
} }
@ -238,12 +238,12 @@ public static class TeamsControlBridge
if (camOff is null) if (camOff is null)
{ {
if (lower.Contains("turn camera on") || lower.Contains("kamera einschalten") || if (lower.Contains("turn camera on") || lower.Contains("kamera einschalten") ||
lower.Contains("activar cámara") || lower.Contains("activer la caméra") || lower.Contains("activar cámara") || lower.Contains("activer la caméra") ||
lower.Contains("ativar câmera")) lower.Contains("ativar câmera"))
camOff = true; camOff = true;
else if (lower.Contains("turn camera off") || lower.Contains("kamera ausschalten") || else if (lower.Contains("turn camera off") || lower.Contains("kamera ausschalten") ||
lower.Contains("desactivar cámara") || lower.Contains("désactiver la caméra") || lower.Contains("desactivar cámara") || lower.Contains("désactiver la caméra") ||
lower.Contains("desativar câmera")) lower.Contains("desativar câmera"))
camOff = false; camOff = false;
} }
} }
@ -259,8 +259,8 @@ public static class TeamsControlBridge
/// UI is in a state we don't recognize. /// UI is in a state we don't recognize.
/// ///
/// This is the "tell me what Teams is doing without me having to look /// This is the "tell me what Teams is doing without me having to look
/// at it" probe operators using auto-hide Teams want a status pill /// at it" probe — operators using auto-hide Teams want a status pill
/// that says "In call · ready" without having to restore the Teams /// that says "In call · ready" without having to restore the Teams
/// window. Safe to call from any thread (UIA traversal is thread-safe); /// window. Safe to call from any thread (UIA traversal is thread-safe);
/// not free (walks the descendant tree) so callers should poll at most /// not free (walks the descendant tree) so callers should poll at most
/// a few times per second. /// a few times per second.
@ -305,7 +305,7 @@ public static class TeamsControlBridge
{ {
// Search by Name first (most common case for Teams). Use a NameProperty // Search by Name first (most common case for Teams). Use a NameProperty
// contains-style match by collecting all Buttons in the subtree and then // contains-style match by collecting all Buttons in the subtree and then
// filtering manually Condition only supports equality, and Teams' // filtering manually — Condition only supports equality, and Teams'
// labels can include trailing state ("(unmuted)") that breaks equality. // labels can include trailing state ("(unmuted)") that breaks equality.
var allButtons = root.FindAll( var allButtons = root.FindAll(
TreeScope.Descendants, TreeScope.Descendants,
@ -388,7 +388,7 @@ public static class TeamsControlBridge
} }
catch catch
{ {
// ElementNotEnabledException, ElementNotAvailableException Teams // ElementNotEnabledException, ElementNotAvailableException — Teams
// disabled the button mid-traversal (e.g. mute is disabled before // disabled the button mid-traversal (e.g. mute is disabled before
// joining a call). Treat as "found but couldn't invoke" so the // joining a call). Treat as "found but couldn't invoke" so the
// caller can surface a useful message. // caller can surface a useful message.

View file

@ -0,0 +1,177 @@
using System.Runtime.InteropServices;
namespace DragonISO.App.Services;
/// <summary>
/// Phase E.4 — Embedded Teams via SetParent.
///
/// Reparents Teams' main top-level window into a Dragon-ISO-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 Dragon-ISO 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;
}
}
}

View file

@ -1,14 +1,14 @@
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Small Win32 wrapper that launches the Microsoft Teams desktop client as a /// Small Win32 wrapper that launches the Microsoft Teams desktop client as a
/// subprocess of TeamsISO. First step toward Phase E.1 of the embedded-Teams /// subprocess of DragonISO. First step toward Phase E.1 of the embedded-Teams
/// roadmap (see docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md): /// roadmap (see docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md):
/// the operator can launch Teams from within TeamsISO so they don't have to /// the operator can launch Teams from within Dragon-ISO so they don't have to
/// switch apps to start a meeting. /// switch apps to start a meeting.
/// ///
/// The launcher tries (in order): /// The launcher tries (in order):
@ -17,7 +17,7 @@ namespace TeamsISO.App.Services;
/// 3. Teams.exe in %LOCALAPPDATA%\Microsoft\Teams\Update.exe (legacy) /// 3. Teams.exe in %LOCALAPPDATA%\Microsoft\Teams\Update.exe (legacy)
/// ///
/// Group-routing automation (writing NDI Access Manager config so Teams /// Group-routing automation (writing NDI Access Manager config so Teams
/// broadcasts on a private group) is deferred to a follow-up for v1.0 we /// broadcasts on a private group) is deferred to a follow-up — for v1.0 we
/// document the manual steps in RELEASING.md and trust the operator to set /// document the manual steps in RELEASING.md and trust the operator to set
/// them once per machine. /// them once per machine.
/// </summary> /// </summary>
@ -48,12 +48,12 @@ public static class TeamsLauncher
/// reasons each attempt was rejected so the operator can see why. /// reasons each attempt was rejected so the operator can see why.
/// ///
/// Path order matters: /// Path order matters:
/// 1. <c>ms-teams:</c> URI new Teams (MSTeams AppX) registers this /// 1. <c>ms-teams:</c> URI — new Teams (MSTeams AppX) registers this
/// handler at install. Activates through the AppX shell so the /// handler at install. Activates through the AppX shell so the
/// stub <c>ms-teams.exe</c> in WindowsApps gets the right context. /// stub <c>ms-teams.exe</c> in WindowsApps gets the right context.
/// 2. AppsFolder shell verb direct AppX activation. Belt-and-braces /// 2. AppsFolder shell verb — direct AppX activation. Belt-and-braces
/// fallback if a misconfigured registry breaks the URI handler. /// fallback if a misconfigured registry breaks the URI handler.
/// 3. Classic Teams Update.exe pre-2024 Teams installations. /// 3. Classic Teams Update.exe — pre-2024 Teams installations.
/// We deliberately DON'T try the bare <c>ms-teams.exe</c> WindowsApps /// We deliberately DON'T try the bare <c>ms-teams.exe</c> WindowsApps
/// path: it's a 0-byte AppX placeholder that fails silently when invoked /// path: it's a 0-byte AppX placeholder that fails silently when invoked
/// without AppX activation context. Looked plausible, never worked. /// without AppX activation context. Looked plausible, never worked.
@ -65,9 +65,9 @@ public static class TeamsLauncher
// Path 1: URI scheme. The shell handler picks the registered Teams // Path 1: URI scheme. The shell handler picks the registered Teams
// (new MSTeams takes priority on modern Windows). UseShellExecute=true // (new MSTeams takes priority on modern Windows). UseShellExecute=true
// is required Win32 Process creation can't open URIs directly. // is required — Win32 Process creation can't open URIs directly.
if (TryStart("ms-teams:", useShell: true, out var err1)) return true; if (TryStart("ms-teams:", useShell: true, out var err1)) return true;
attempts.Add($"ms-teams: URI {err1}"); attempts.Add($"ms-teams: URI → {err1}");
// Path 2: AppX activation via the explorer.exe shell. Modern Teams // Path 2: AppX activation via the explorer.exe shell. Modern Teams
// ships as MSTeams_8wekyb3d8bbwe; if other code on the box has // ships as MSTeams_8wekyb3d8bbwe; if other code on the box has
@ -76,7 +76,7 @@ public static class TeamsLauncher
if (TryStart("explorer.exe", false, out var err2, if (TryStart("explorer.exe", false, out var err2,
arguments: "shell:appsFolder\\MSTeams_8wekyb3d8bbwe!MSTeams")) arguments: "shell:appsFolder\\MSTeams_8wekyb3d8bbwe!MSTeams"))
return true; return true;
attempts.Add($"AppsFolder shell {err2}"); attempts.Add($"AppsFolder shell → {err2}");
// Path 3: classic Teams Update.exe with --processStart hands off to // Path 3: classic Teams Update.exe with --processStart hands off to
// the actual Teams.exe via Squirrel. // the actual Teams.exe via Squirrel.
@ -98,24 +98,24 @@ public static class TeamsLauncher
} }
catch (Exception ex) catch (Exception ex)
{ {
attempts.Add($"classic Update.exe {ex.Message}"); attempts.Add($"classic Update.exe → {ex.Message}");
} }
} }
else else
{ {
attempts.Add($"classic Update.exe not found at {classicUpdater}"); attempts.Add($"classic Update.exe → not found at {classicUpdater}");
} }
errorMessage = "No Microsoft Teams installation could be launched. " + errorMessage = "No Microsoft Teams installation could be launched. " +
"Install Teams from https://www.microsoft.com/microsoft-teams and try again.\n\n" + "Install Teams from https://www.microsoft.com/microsoft-teams and try again.\n\n" +
"Attempts:\n • " + string.Join("\n • ", attempts); "Attempts:\n • " + string.Join("\n • ", attempts);
return false; return false;
} }
/// <summary> /// <summary>
/// Asks every running Teams process to close gracefully via WM_CLOSE /// Asks every running Teams process to close gracefully via WM_CLOSE
/// (CloseMainWindow). Returns the count of processes that exited cleanly within /// (CloseMainWindow). Returns the count of processes that exited cleanly within
/// <paramref name="gracePeriod"/>. Stragglers are NOT force-killed Teams' own /// <paramref name="gracePeriod"/>. Stragglers are NOT force-killed — Teams' own
/// "are you sure" prompt may legitimately keep a process alive briefly, and we /// "are you sure" prompt may legitimately keep a process alive briefly, and we
/// don't want to nuke the user's call mid-transition. /// don't want to nuke the user's call mid-transition.
/// </summary> /// </summary>
@ -151,14 +151,14 @@ public static class TeamsLauncher
/// Hand a meeting URL off to the Teams shell handler. Accepts both the /// Hand a meeting URL off to the Teams shell handler. Accepts both the
/// <c>https://teams.microsoft.com/l/meetup-join/...</c> web format and /// <c>https://teams.microsoft.com/l/meetup-join/...</c> web format and
/// the <c>msteams:/l/meetup-join/...</c> deep-link form (either causes /// the <c>msteams:/l/meetup-join/...</c> deep-link form (either causes
/// Teams to launch + join the meeting in one shot the OS shell maps /// Teams to launch + join the meeting in one shot — the OS shell maps
/// teams.microsoft.com URLs to the registered ms-teams: handler). /// teams.microsoft.com URLs to the registered ms-teams: handler).
/// ///
/// Use case: operator pastes a meeting link they got over email / chat /// Use case: operator pastes a meeting link they got over email / chat
/// into TeamsISO's quick-join field instead of opening Teams, /// into Dragon-ISO's quick-join field instead of opening Teams,
/// hunting down the calendar entry, and clicking Join. With auto-hide /// hunting down the calendar entry, and clicking Join. With auto-hide
/// on, the Teams window flashes briefly then disappears; the operator /// on, the Teams window flashes briefly then disappears; the operator
/// is now in the meeting, driving routing from TeamsISO. /// is now in the meeting, driving routing from DragonISO.
/// ///
/// Returns true if the shell accepted the URL; false if URL is malformed /// Returns true if the shell accepted the URL; false if URL is malformed
/// or rejected. errorMessage populated on failure. /// or rejected. errorMessage populated on failure.
@ -176,7 +176,7 @@ public static class TeamsLauncher
// Defensive sanity-check: only accept URLs that obviously target // Defensive sanity-check: only accept URLs that obviously target
// Teams. We don't want to invoke arbitrary shell handlers from a // Teams. We don't want to invoke arbitrary shell handlers from a
// clipboard paste if someone pastes "calc.exe" into the input we // clipboard paste — if someone pastes "calc.exe" into the input we
// shouldn't launch it. Specifically: http(s) URLs must contain // shouldn't launch it. Specifically: http(s) URLs must contain
// "teams.microsoft.com" or "teams.live.com"; otherwise must start // "teams.microsoft.com" or "teams.live.com"; otherwise must start
// with "msteams:". // with "msteams:".
@ -221,11 +221,11 @@ public static class TeamsLauncher
} }
} }
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
// Phase E.2 window orchestration // Phase E.2 — window orchestration
// //
// Once Teams is running, we want to be able to hide its main window so the // Once Teams is running, we want to be able to hide its main window so the
// operator only sees TeamsISO. We do this by enumerating top-level windows, // operator only sees DragonISO. We do this by enumerating top-level windows,
// matching against each Teams process Id, and ShowWindow(SW_HIDE)-ing each // matching against each Teams process Id, and ShowWindow(SW_HIDE)-ing each
// match. To restore we ShowWindow(SW_SHOW) and SetForegroundWindow. // match. To restore we ShowWindow(SW_SHOW) and SetForegroundWindow.
// //
@ -234,7 +234,7 @@ public static class TeamsLauncher
// process and Process picks an inconsistent one across launches; iterating // process and Process picks an inconsistent one across launches; iterating
// via EnumWindows + GetWindowThreadProcessId catches every visible window // via EnumWindows + GetWindowThreadProcessId catches every visible window
// owned by the process. // owned by the process.
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
private const int SW_HIDE = 0; private const int SW_HIDE = 0;
private const int SW_SHOWNORMAL = 1; private const int SW_SHOWNORMAL = 1;
@ -271,176 +271,14 @@ 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
/// the meeting title in the window title while in a call ("Meeting with /// the meeting title in the window title while in a call ("Meeting with
/// Alice | Microsoft Teams"), so this is the cheapest way to surface /// Alice | Microsoft Teams"), so this is the cheapest way to surface
/// meeting context to TeamsISO's UI without burning a UIA traversal. /// meeting context to Dragon-ISO's UI without burning a UIA traversal.
/// ///
/// Includes hidden windows operators using auto-hide still get the /// Includes hidden windows — operators using auto-hide still get the
/// title surfaced, which is the whole point. /// title surfaced, which is the whole point.
/// </summary> /// </summary>
public static string GetActiveWindowTitle() public static string GetActiveWindowTitle()
@ -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;
} }
@ -518,12 +363,12 @@ public static class TeamsLauncher
/// Fire-and-forget background watcher that polls every 250ms for up to /// Fire-and-forget background watcher that polls every 250ms for up to
/// <paramref name="timeout"/> and hides any visible top-level Teams /// <paramref name="timeout"/> and hides any visible top-level Teams
/// windows it finds. Used after launch so the operator never sees the /// windows it finds. Used after launch so the operator never sees the
/// Teams UI flash on screen Teams takes 2-5s to splash + render its /// Teams UI flash on screen — Teams takes 2-5s to splash + render its
/// main window, and the splash arrives separately from the main window /// main window, and the splash arrives separately from the main window
/// (so we keep polling past the first hide to catch follow-up windows). /// (so we keep polling past the first hide to catch follow-up windows).
/// ///
/// Returns the Task so callers can await completion if they want, but /// Returns the Task so callers can await completion if they want, but
/// production code should fire-and-forget. Exceptions are swallowed /// production code should fire-and-forget. Exceptions are swallowed —
/// failure to hide is harmless (user just sees Teams briefly). /// failure to hide is harmless (user just sees Teams briefly).
/// </summary> /// </summary>
public static Task AutoHideAfterLaunchAsync(TimeSpan? timeout = null, CancellationToken ct = default) public static Task AutoHideAfterLaunchAsync(TimeSpan? timeout = null, CancellationToken ct = default)
@ -537,7 +382,7 @@ public static class TeamsLauncher
while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline) while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline)
{ {
// Poll for visible windows. Each iteration may catch new // Poll for visible windows. Each iteration may catch new
// ones Teams sometimes opens a small splash, then a // ones — Teams sometimes opens a small splash, then a
// larger main window 1-2s later, then a "What's new" // larger main window 1-2s later, then a "What's new"
// banner. Keep hiding until we've gone a full second // banner. Keep hiding until we've gone a full second
// with nothing new appearing. // with nothing new appearing.
@ -564,21 +409,21 @@ public static class TeamsLauncher
} }
} }
catch (OperationCanceledException) { /* expected on cancel */ } catch (OperationCanceledException) { /* expected on cancel */ }
catch { /* defensive auto-hide is best-effort, never breaks the app */ } catch { /* defensive — auto-hide is best-effort, never breaks the app */ }
}, ct); }, ct);
} }
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
// Keyboard-shortcut forwarding (PostMessage path). // Keyboard-shortcut forwarding (PostMessage path).
// //
// UIAutomation (TeamsControlBridge) is our preferred way to drive Teams // UIAutomation (TeamsControlBridge) is our preferred way to drive Teams
// because it works regardless of foreground/visibility state. PostMessage // because it works regardless of foreground/visibility state. PostMessage
// is a fallback for shortcuts that don't have a stable UIA-discoverable // is a fallback for shortcuts that don't have a stable UIA-discoverable
// button chat scroll, custom keymap actions, etc. Note: WebView2-hosted // button — chat scroll, custom keymap actions, etc. Note: WebView2-hosted
// Teams (the modern client) frequently ignores PostMessage(WM_KEYDOWN) at // Teams (the modern client) frequently ignores PostMessage(WM_KEYDOWN) at
// its app-shortcut layer because shortcut routing happens after focus // its app-shortcut layer because shortcut routing happens after focus
// changes, not on raw key messages. Treat this as best-effort. // changes, not on raw key messages. Treat this as best-effort.
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
private const uint WM_KEYDOWN = 0x0100; private const uint WM_KEYDOWN = 0x0100;
private const uint WM_KEYUP = 0x0101; private const uint WM_KEYUP = 0x0101;
@ -603,14 +448,14 @@ public static class TeamsLauncher
/// Sends a synthesized key press (modifier-down, key-down, key-up, /// Sends a synthesized key press (modifier-down, key-down, key-up,
/// modifier-up) to the most recently used top-level Teams window via /// modifier-up) to the most recently used top-level Teams window via
/// PostMessage. Returns true if a window was found to send to. Note that /// PostMessage. Returns true if a window was found to send to. Note that
/// returning true doesn't guarantee Teams reacted modern WebView2-based /// returning true doesn't guarantee Teams reacted — modern WebView2-based
/// Teams sometimes ignores synthesized key messages at the app-shortcut /// Teams sometimes ignores synthesized key messages at the app-shortcut
/// layer. Prefer UIA (<see cref="TeamsControlBridge"/>) when an equivalent /// layer. Prefer UIA (<see cref="TeamsControlBridge"/>) when an equivalent
/// button exists. /// button exists.
/// </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];

View file

@ -1,17 +1,17 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Windows; using System.Windows;
using Microsoft.Win32; using Microsoft.Win32;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Owns the active theme for the WPF host. Three preferences: /// Owns the active theme for the WPF host. Three preferences:
/// <list type="bullet"> /// <list type="bullet">
/// <item><c>System</c> follows the Windows app-mode setting (default for new /// <item><c>System</c> — follows the Windows app-mode setting (default for new
/// users; re-reads on <see cref="SystemEvents.UserPreferenceChanged"/>).</item> /// users; re-reads on <see cref="SystemEvents.UserPreferenceChanged"/>).</item>
/// <item><c>Dark</c> pin dark regardless of OS.</item> /// <item><c>Dark</c> — pin dark regardless of OS.</item>
/// <item><c>Light</c> pin light regardless of OS.</item> /// <item><c>Light</c> — pin light regardless of OS.</item>
/// </list> /// </list>
/// The two color files (<c>Theme.Dark.xaml</c> + <c>Theme.Light.xaml</c>) are /// The two color files (<c>Theme.Dark.xaml</c> + <c>Theme.Light.xaml</c>) are
/// kept in lockstep on the same set of brush keys; this manager swaps the /// kept in lockstep on the same set of brush keys; this manager swaps the
@ -20,40 +20,65 @@ namespace TeamsISO.App.Services;
/// so the visual tree re-resolves without an app restart. /// so the visual tree re-resolves without an app restart.
/// ///
/// Preference is persisted via <see cref="UIPreferences"/>'s <c>Theme</c> field, /// Preference is persisted via <see cref="UIPreferences"/>'s <c>Theme</c> field,
/// reserved on disk during the v1 v2 rollout so the rebuild doesn't lose the /// reserved on disk during the v1 → v2 rollout so the rebuild doesn't lose the
/// operator's choice. /// operator's choice.
/// </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 Dragon-ISO entry assembly) and from xUnit tests
// (where it's the test assembly — relative URIs would miss).
private const string DarkUri = "pack://application:,,,/DragonISO;component/Themes/Theme.Dark.xaml";
private const string LightUri = "pack://application:,,,/DragonISO;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();
} }
@ -109,7 +134,7 @@ public sealed class ThemeManager
/// <summary> /// <summary>
/// Apply the current resolved theme. Should be called once during app /// Apply the current resolved theme. Should be called once during app
/// startup (after Application.Current.Resources is initialized) and /// startup (after Application.Current.Resources is initialized) and
/// whenever <see cref="Preference"/> changes <see cref="Set"/> already /// whenever <see cref="Preference"/> changes — <see cref="Set"/> already
/// does the latter for you. /// does the latter for you.
/// </summary> /// </summary>
public void Apply() public void Apply()
@ -127,7 +152,7 @@ public sealed class ThemeManager
var dicts = app.Resources.MergedDictionaries; var dicts = app.Resources.MergedDictionaries;
// Find the existing theme color dictionary by source URI. We // Find the existing theme color dictionary by source URI. We
// distinguish "color" dictionaries from "WildDragonTheme" by name // distinguish "color" dictionaries from "WildDragonTheme" by name —
// the color files are at Theme.Dark.xaml / Theme.Light.xaml; the // the color files are at Theme.Dark.xaml / Theme.Light.xaml; the
// styles file is at WildDragonTheme.xaml. Replace in place to // styles file is at WildDragonTheme.xaml. Replace in place to
// preserve merge order so DynamicResource refs resolve to the new // preserve merge order so DynamicResource refs resolve to the new
@ -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);
@ -159,10 +184,11 @@ 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,11 +206,36 @@ public sealed class ThemeManager
return true; return true;
} }
/// <summary>
/// Load the operator's persisted theme preference from
/// %LOCALAPPDATA%\Dragon-ISO\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;
if (_preference != PreferenceKeySystem) return; if (_preference != PreferenceKeySystem) return;
// Marshal to the UI thread — registry events fire on a system pool // Marshal to the UI thread — registry events fire on a system pool
// thread and resource dictionary mutations require dispatcher access. // thread and resource dictionary mutations require dispatcher access.
Application.Current?.Dispatcher.BeginInvoke(new Action(Apply)); Application.Current?.Dispatcher.BeginInvoke(new Action(Apply));
} }

View file

@ -1,16 +1,16 @@
using System.Drawing; using System.Drawing;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using System.Runtime.Versioning; using System.Runtime.Versioning;
using System.Windows; using System.Windows;
using WinForms = System.Windows.Forms; using WinForms = System.Windows.Forms;
namespace TeamsISO.App.Services; namespace DragonISO.App.Services;
/// <summary> /// <summary>
/// Wraps a WinForms <see cref="WinForms.NotifyIcon"/> so the WPF host can /// Wraps a WinForms <see cref="WinForms.NotifyIcon"/> so the WPF host can
/// minimize-to-tray during long shows. Operators with a Stream Deck setup /// minimize-to-tray during long shows. Operators with a Stream Deck setup
/// often want TeamsISO running but invisible — the tray icon keeps the /// often want Dragon-ISO running but invisible — the tray icon keeps the
/// process alive (and the engine routing live) while the window stays /// process alive (and the engine routing live) while the window stays
/// hidden. /// hidden.
/// ///
@ -31,7 +31,7 @@ public sealed class TrayIconHost : IDisposable
_mainWindow = mainWindow; _mainWindow = mainWindow;
_notifyIcon = new WinForms.NotifyIcon _notifyIcon = new WinForms.NotifyIcon
{ {
Text = "TeamsISO", Text = "Dragon-ISO",
Icon = LoadEmbeddedIcon(), Icon = LoadEmbeddedIcon(),
Visible = false, Visible = false,
}; };
@ -76,7 +76,7 @@ public sealed class TrayIconHost : IDisposable
_notifyIcon.Visible = true; _notifyIcon.Visible = true;
_notifyIcon.ShowBalloonTip( _notifyIcon.ShowBalloonTip(
timeout: 1500, timeout: 1500,
tipTitle: "TeamsISO is still running", tipTitle: "Dragon-ISO is still running",
tipText: "Engine + control surface are live. Double-click the tray icon to restore the window.", tipText: "Engine + control surface are live. Double-click the tray icon to restore the window.",
tipIcon: WinForms.ToolTipIcon.Info); tipIcon: WinForms.ToolTipIcon.Info);
} }
@ -93,7 +93,7 @@ public sealed class TrayIconHost : IDisposable
private WinForms.ContextMenuStrip BuildMenu() private WinForms.ContextMenuStrip BuildMenu()
{ {
var menu = new WinForms.ContextMenuStrip(); var menu = new WinForms.ContextMenuStrip();
menu.Items.Add("Show TeamsISO", null, (_, _) => RestoreFromTray()); menu.Items.Add("Show Dragon-ISO", null, (_, _) => RestoreFromTray());
menu.Items.Add("-"); menu.Items.Add("-");
menu.Items.Add("Stop all ISOs", null, (_, _) => menu.Items.Add("Stop all ISOs", null, (_, _) =>
{ {
@ -106,12 +106,12 @@ public sealed class TrayIconHost : IDisposable
} }
}); });
menu.Items.Add("-"); menu.Items.Add("-");
menu.Items.Add("Exit TeamsISO", null, (_, _) => System.Windows.Application.Current.Shutdown()); menu.Items.Add("Exit Dragon-ISO", null, (_, _) => System.Windows.Application.Current.Shutdown());
return menu; return menu;
} }
/// <summary> /// <summary>
/// Load the bundled teamsiso.ico from this assembly's resources. We use /// Load the bundled DragonISO.ico from this assembly's resources. We use
/// the embedded resource rather than the file-system path because the /// the embedded resource rather than the file-system path because the
/// app may be run from any CWD (via the MSI install or a developer dotnet run). /// app may be run from any CWD (via the MSI install or a developer dotnet run).
/// </summary> /// </summary>
@ -120,7 +120,7 @@ public sealed class TrayIconHost : IDisposable
try try
{ {
var asm = Assembly.GetExecutingAssembly(); var asm = Assembly.GetExecutingAssembly();
var uri = new Uri("pack://application:,,,/Assets/teamsiso.ico"); var uri = new Uri("pack://application:,,,/Assets/DragonISO.ico");
using var stream = System.Windows.Application.GetResourceStream(uri)?.Stream; using var stream = System.Windows.Application.GetResourceStream(uri)?.Stream;
if (stream is not null) return new Icon(stream); if (stream is not null) return new Icon(stream);
} }

Some files were not shown because too many files have changed in this diff Show more