Commit graph

32 commits

Author SHA1 Message Date
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
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
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
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
5a43c9cb6a feat: per-ISO framerate/resolution/aspect/audio overrides + thumbnail BMP
Some checks failed
CI / build-and-test (push) Failing after 30s
Engine: IsoAssignment record gets optional Override (FrameProcessingSettings?). IsoController hydrates _overrides dict from config.json on startup, uses override at EnableIsoAsync, persists with assignment, exposes GetIsoOverride + SetIsoOverrideAsync. SetIsoOverrideAsync hot-swaps a running pipeline (Disable + 150ms delay + Enable) when the override changes.

REST: POST /participants/{id}/override (body: framerate/resolution/aspect/audio enum strings, all optional, missing fall back to globals); DELETE /participants/{id}/override clears. GET /participants now includes per-row effective {framerate, resolution, aspect, audio, isOverride} plus top-level globals block.

Web /ui: per-card collapsible override panel with four selects + Apply / Clear. OVR pill + cyan inset edge mark overridden rows. Open-panel state survives WS re-renders.

Desktop: per-row gear column in the v2 DataGrid opens IsoOverrideDialog (420x360) with four combos. Clear button removes the override.

Thumbnail endpoint switched from WPF JpegBitmapEncoder (NREs from non-UI HttpListener threads) to pure-managed 32bpp BMP encoder. Nearest-neighbor downscale to 192-wide. /participants/{id}/thumbnail.bmp; legacy .jpg URL still works.

Known limitation: ParticipantTracker regenerates IDs for display-name-keyed participants across process restarts, orphaning the persisted override. Override works within a session; cross-restart persistence is best-effort until the tracker is taught to use stable keys. Filed as task 43.
2026-05-15 15:31:32 -04:00
647deec304 feat(web): topology + thumbnail endpoints, redesigned /ui control panel
Some checks failed
CI / build-and-test (push) Failing after 30s
REST additions: GET /topology returns mode (hidden/public/unknown) + sender/receiver group lists. POST /topology/apply confines local senders to teamsiso-input + receivers to public+teamsiso-input. POST /topology/restore returns both to public defaults.

GET /participants/{id}/thumbnail.jpg encodes the latest engine ProcessedFrame as a 192-wide JPEG. 404 when no pipeline is running. Used by the /ui control panel for live preview tiles.

Settings: ControlSurfaceEnabled now persists across sessions via UIPreferences and auto-starts the server on app launch when previously enabled.

/ui control panel rebuilt: live thumbnail per row, topology toggle card with Hide/Restore buttons, removed dead recording marker button, larger layout (920px), participant rows in single card with hover affordances.
2026-05-15 15:06:11 -04:00
c27130302f feat(wpf): v2 'Studio Terminal' shell - theme system, header, transport strip, drawer
Some checks failed
CI / build-and-test (push) Failing after 31s
- Theme split: Theme.Dark.xaml + Theme.Light.xaml + ThemeManager

- New shell: 32px header (mark + wordmark + 3 icons), 40px transport strip, conditional meeting bar, slide-over settings drawer

- Removed: 72px rail, 380px permanent settings panel, 6-column footer, custom chromeless title bar buttons

- Ctrl+T toggles theme; follows Windows app-mode by default

- Shape doc at docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md
2026-05-14 12:46:24 -04:00
1d1ce6a2a0 feat(wpf): rollback to WPF host, axe recording, fix settings pane
Some checks failed
CI / build-and-test (push) Failing after 29s
2026-05-14 06:02:40 -04:00
d6793d8d9c Sort participants by Loudest (active speaker at top)
Some checks failed
CI / build-and-test (push) Failing after 43s
Adds a fourth participant sort mode: LoudestFirst, sorts by DisplayedAudioLevel descending so the current active speaker bubbles to the top of the DataGrid. Operators reacting to who's talking can see the active speaker without scanning the list.

Refresh-on-tick (1Hz) only fires when LoudestFirst is active — other sort modes don't change keys every tick so they skip the cost. ParticipantViewModel.DisplayedAudioLevel already has a decay envelope (max-of-new-or-decayed-old at 0.7 per tick), which prevents jittery reorder on every audio frame.

Persisted via the existing UIPreferences.ParticipantSort enum (new value tacked onto the end so older ui-prefs.json files default to JoinOrder cleanly).
2026-05-10 21:21:45 -04:00
61dce2eecd IN-CALL bar shows MUTED / CAM OFF pills
Some checks failed
CI / build-and-test (push) Failing after 34s
Operators with auto-hide Teams couldn't tell if they were muted or had their camera off — needed to restore Teams just to check. New coral pills in the IN-CALL bar surface the local-user state, populated from a single UIA traversal that also drives the IN-CALL pill (so the cost stays at one walk per stats tick, not three).

Detection: TeamsControlBridge.DetectCallState returns a CallStateSnapshot with IsInCall + IsMuted + IsCameraOff. The Mute and Camera buttons toggle their UIA Name between 'Mute'/'Unmute' and 'Turn camera off'/'Turn camera on' depending on state; check the more-specific candidate (unmute / turn camera on) first to avoid false positives from substring matching.

Localized for EN / DE / ES / FR / PT / JA — same locale list the candidate-name arrays already cover. Pills visible only when both in-call AND the corresponding state is true; once you unmute, the pill vanishes within ~1s (next stats tick).
2026-05-10 21:17:19 -04:00
cc29c503a9 Phase E.4 experimental: SetParent-embed Teams window inside TeamsISO
Some checks failed
CI / build-and-test (push) Failing after 28s
Reparents Teams' main top-level window into a TeamsISO-owned host via Win32 SetParent + window-style stripping. Operator gets Teams visually INSIDE TeamsISO instead of as a separate window — completes the 'Teams runs within this app' direction the user asked for after auto-hide.

Strictly opt-in (DISPLAY tab → 'Embed Teams window (experimental)'). Modern Teams runs WebView2 in its main window; WebView2 is sensitive to parent changes and may render glitches or refuse focus. If so, operator unticks and falls back to auto-hide mode.

Implementation:

- TeamsLauncher.EmbedTeamsInto(hostHwnd, w, h): finds Teams' main window (longest-title heuristic — same as GetActiveWindowTitle), saves original parent + WS_STYLE, SetParents into host, strips WS_CAPTION + WS_THICKFRAME + WS_BORDER + WS_DLGFRAME + WS_POPUP, adds WS_CHILD, MoveWindow to fit.

- TeamsLauncher.RestoreEmbed(): SetParent back to desktop + restore saved window styles. Idempotent — safe to call on shutdown even if nothing was embedded.

- TeamsLauncher.ResizeEmbedded(w, h): MoveWindow to new dimensions; called from host SizeChanged event.

- New TeamsEmbedWindow chromeless host with an EXPERIMENTAL pill in the caption. Loaded → grab HwndSource from EmbedHost Border → call EmbedTeamsInto. SizeChanged → ResizeEmbedded. Closed → RestoreEmbed (in try/finally so a crash can't leave Teams orphaned). Friendly fallback messages if no Teams window exists or HWND grab fails.

- Settings → DISPLAY → checkbox + 'Open embed window' button (gated by the checkbox). Persisted via EmbedTeamsWindow on UIPreferences.
2026-05-10 21:14:42 -04:00
aa07ad9f08 Auto-record when Teams joins a meeting
Some checks failed
CI / build-and-test (push) Failing after 26s
New AutoRecordOnCall preference (DISPLAY tab). When checked, recording auto-flips ON the moment Teams transitions into a call (UIA Leave button appears in tree), and auto-flips OFF when the call ends.

Completes the unattended-show story: with Launch + AutoHide + AutoRecord all ticked, the operator launches TeamsISO and walks away — Teams runs invisibly, recording begins/ends with the meeting, ISOs route, all done. Toast surfaces each transition so they know what's happening if they glance at the screen.

Implementation: transition detection lives in the existing UIA-probe code in OnStatsTick. previousInCall != inCall gate prevents the auto-toggle from re-firing on every poll. Direct call to _controller.SetRecording + Settings.RecordIsosToDisk = ... so the existing recording infrastructure handles the rest. Toast for visibility, swallow-on-error so a recording config issue can't break the IN-CALL pill update path.
2026-05-10 21:10:30 -04:00
7ef6b8055e IN-CALL pill shows meeting title from Teams' window text
Some checks failed
CI / build-and-test (push) Failing after 28s
The IN-CALL pill now reads 'IN CALL · Weekly Standup' (or 'IN CALL' if Teams' window doesn't expose a meeting title), so operators using auto-hide know WHICH meeting they're in without restoring the Teams window.

Implementation: TeamsLauncher.GetActiveWindowTitle uses EnumWindows + GetWindowTextW to read every Teams top-level window title (hidden windows too — title bar text is accessible even with SW_HIDE), picks the longest as a heuristic for 'most informative' (Teams creates several windows per process; the call window has the meaningful title). MainViewModel.ExtractMeetingTitle strips the ' | Microsoft Teams' / ' - Microsoft Teams' suffix variations and clamps overly long titles to 50 chars with an ellipsis.

10 new unit tests for ExtractMeetingTitle covering: standard formats with both separators, bare 'Microsoft Teams' (returns empty so the pill stays at 'IN CALL'), long-title truncation, outer-whitespace trimming, unrecognized formats passing through.

169/169 tests passing.
2026-05-10 20:47:43 -04:00
b9147183ce Quick-join Teams meetings from URL — paste link, click Join
Some checks failed
CI / build-and-test (push) Failing after 27s
Adds a small URL input + Join button to the IN-CALL bar. Operators paste a https://teams.microsoft.com/l/meetup-join/... or msteams:/l/meetup-join/... link, click Join, and Teams launches into the meeting in one shot. Eliminates the open-Teams → Calendar → find meeting → click join dance — operators get meeting links from email/Outlook and can now join straight from TeamsISO.

TeamsLauncher.TryJoinMeeting validates the URL targets Teams (only http(s) URLs containing teams.microsoft.com / teams.live.com, or msteams: deep-links — won't shell-exec arbitrary clipboard contents). On success, integrates with AutoHideTeamsWindows so the Teams meeting window briefly appears then vanishes; operator is in the call, driving routing from TeamsISO.

VM-side: MainViewModel.JoinMeetingCommand + JoinMeetingUrl two-way bound. Field clears on success; warn-toast on failure with the specific reason (empty / not-a-teams-url / launch-failed).
2026-05-10 20:45:04 -04:00
a9a10e01a4 IN-CALL bar surfaces Teams meeting state — 'READY' / 'IN CALL'
Some checks failed
CI / build-and-test (push) Failing after 27s
Operators using auto-hide Teams couldn't tell whether they were in a meeting without restoring the Teams window. New status pill in the IN-CALL bar header shows:

  • empty when Teams isn't running

  • 'READY' (gray dot) when Teams is running but not in a call

  • 'IN CALL' (cyan dot) when Teams is in an active meeting

Detection: TeamsControlBridge.IsInCall() walks Teams' UIA tree looking for the Leave / Hang-up button. Present iff in a call — works across Teams versions because Teams only exposes the Leave control while a call is active. Same candidate-name list the LeaveCall command uses, with localized strings for EN/DE/ES/FR/PT/JA already in place.

Polled at the existing 1Hz stats tick. UIA traversal can take 50-200ms in a busy call, so the probe runs off-thread; the property update is dispatched back via _dispatcher.InvokeAsync. Failure paths swallow exceptions — a flaky UIA call must never crash the stats timer.

159/159 tests passing, 0 warnings, 0 errors.
2026-05-10 20:42:57 -04:00
d8186c5eb8 Auto-launch + auto-hide Teams: 'I only see TeamsISO' experience
Some checks failed
CI / build-and-test (push) Failing after 28s
Two new persisted preferences in DISPLAY settings, paired to give operators the 'launch TeamsISO, never see Teams' experience the user asked for:

- LaunchTeamsOnStartup: TeamsISO auto-starts Teams in the background each launch (fire-and-forget background task in App.OnStartup, after the main window has materialized so a slow Teams launch doesn't delay the UI).

- AutoHideTeamsWindows: as soon as Teams' windows materialize after launch, hide them. New TeamsLauncher.AutoHideAfterLaunchAsync runs a polling loop (250ms / up to 15s) that catches the splash, main window, and any follow-up panels Teams opens. Teams takes 2-5s to render its main window and the splash arrives separately, so a one-shot hide right after launch wouldn't be enough.

When TeamsISO starts and Teams is already running (from a prior session), the auto-hide path still fires so the 'I only see TeamsISO' rule applies even when Teams was launched externally.

Operator drives everything through the IN-CALL bar (mute / camera / share / leave / marker) + participants DataGrid (ISO routing). Eye-toggle in the rail still restores Teams windows on demand.

Both toggles default to off — opt-in. Persisted via UIPreferences so they survive process restart.
2026-05-10 20:35:00 -04:00
598938ede5 Fix sidebar text cutoff + Teams launch ambush dialog
Two user-reported bugs:

1) CheckBox content was clipping in the 380px settings panel ('Control surface (Stream Deck / Companion / w...' / 'LAN-reachable (allow other machines on yo...'). The Wd.CheckBox template used a horizontal StackPanel which doesn't bound child width, so long Content strings ran off the column without wrapping. Replaced StackPanel with a Grid (Auto + *) and injected a TextBlock style with TextWrapping=Wrap into the ContentPresenter resources — when WPF auto-wraps a string Content in a TextBlock, the resource lookup gives it Wrap.

2) The rail Launch Teams button ambushed operators: clicking with Teams already running (which is common when the eye-toggle has hidden Teams' windows) opened a 'Close all Teams windows now?' dialog. Operators expect Launch to mean 'show me Teams', not 'stop Teams'. Split the actions:

   - Left-click: Teams not running → launch; Teams hidden → restore + foreground; Teams visible → bring to front. Always idempotent-progressive.

   - Right-click: ask to stop Teams (preserves the kill path for those who want it).

TeamsLauncher.TryLaunch now collects per-attempt errors instead of swallowing them — a real failure surfaces 'ms-teams: URI → <reason>' / 'AppsFolder shell → <reason>' / 'classic Update.exe → not found at <path>' so 'No Teams found' isn't a black box.

Also added a 2nd path: explorer.exe shell:appsFolder\\\\MSTeams_8wekyb3d8bbwe!MSTeams (AppX activation via the OS's own Start-menu verb) as a fallback if the URI handler is misconfigured. Removed the broken bare-stub call to %LOCALAPPDATA%\\\\Microsoft\\\\WindowsApps\\\\ms-teams.exe — that's a 0-byte AppX placeholder that never worked outside an AppX context.
2026-05-10 14:39:04 -04:00
6d9407a61f Add LAN-reachable mode to control surface and OSC bridge
Some checks failed
CI / build-and-test (push) Failing after 27s
When the new ControlSurfaceLanReachable preference is on, both the REST/WebSocket control surface and the OSC bridge bind to all interfaces (http://+:port/ via HttpListener wildcard, IPAddress.Any for OSC) instead of loopback. The settings VM persists the toggle, restarts both surfaces when flipped, and surfaces a ControlSurfaceUrl computed from the first non-loopback IPv4 + a Copy button so operators can paste the URL onto a control PC.

Use case: a headless host PC runs Teams + TeamsISO; a thin client on the same LAN drives it via /ui or a Stream Deck. Closed-network deployment, no auth — documented as a trusted-LAN-only mode in docs/CONTROL-SURFACE.md, including the one-time 'netsh http add urlacl url=http://+:9755/ user=Everyone' requirement and the firewall rule.
2026-05-10 10:01:32 -04:00
63bd93d0c2 chore: sweep orphaned files (UpdateChecker, UpdateBanner, TeamsControlBridge, helper scripts)
Some checks failed
CI / build-and-test (push) Failing after 25s
2026-05-10 09:42:29 -04:00
fdd1d1bbfc feat(ui): system tray icon + WinForms/WPF namespace disambiguation 2026-05-10 09:41:33 -04:00
b49e1abf17 feat: CLI flags, dynamic status, HTML panel, session timer, notes 2026-05-10 09:41:32 -04:00
670813f18e feat: disk space watcher + diagnostic bundle export 2026-05-10 09:41:31 -04:00
179a44adf5 feat: custom NDI output name template + enriched status bar 2026-05-10 09:41:31 -04:00
f73552a6b9 feat: preset import / export bundles 2026-05-10 09:41:30 -04:00
b8fe344c58 feat: WebSocket live-state push + OSC bridge 2026-05-10 09:41:30 -04:00
83224dbd9b feat: REST control surface + lift preset-apply into PresetApplier 2026-05-10 09:41:29 -04:00
0c82ac71f0 feat: bundle Inter font, emergency stop button, window persistence + tests
Some checks failed
CI / build-and-test (push) Failing after 27s
Four polish items + a test pass.

1. Inter Variable (rsms/inter v3.19, OFL) is bundled at Assets/Fonts/Inter.ttf (~800 KB) and registered as a WPF Resource. WildDragonTheme.xaml's Wd.Font.Sans now points at pack://application:,,,/Assets/Fonts/#Inter so the typography matches wilddragon.net regardless of whether the user has Inter installed system-wide. Falls back to Segoe UI Variable Display if the resource is missing.

2. 'Stop all ISOs' button at the right of the participants header. Bound to a new MainViewModel.StopAllIsosCommand that snapshots the enabled list, awaits DisableIsoAsync sequentially, and silently swallows per-pipeline failures (best-effort emergency stop). CanExecute gates on whether any ISO is currently enabled.

3. WindowStateStore service persists the main window's Left/Top/Width/Height/State to %LOCALAPPDATA%\\TeamsISO\\window.json on close and restores it on SourceInitialized. Multi-monitor friendly: a saved position with no corner inside any virtual screen is rejected so a disconnected monitor doesn't strand the window off-screen.

4. Two new unit tests cover FrameProcessor's drops + duplicates accounting. 76/76 unit tests pass (was 74).
2026-05-08 13:59:14 -04:00
e8f52a3153 feat: app icon, FPS, drops counter, --version, About dialog, Stop Teams toggle
Six related polish items, all building on tonight's groundwork.

1. App icon: teamsiso.ico generated from dragon-mark.png at 7 sizes (16-256), wired as ApplicationIcon in the WPF csproj, MainWindow.Icon, AboutWindow.Icon, and ARPPRODUCTICON in the WiX MSI. Taskbar / window / Add-Remove-Programs all show the dragon mark now.

2. Running incoming FPS: ring buffer of last 30 frame timestamps in IsoPipeline; ComputeFps() returns moving-average rate. Surfaced on IsoHealthStats.IncomingFps and shown in the Source column of the participants DataGrid as 'WxH · 59.94 fps'. Resets cleanly on every supervisor restart.

3. Drops counter: FrameProcessor.Stats already aggregated FramesDropped (closest-frame strategy when the receiver outpaces the processor) and FramesDuplicated; just plumbed _liveProcessor through IsoPipeline so GetStats() can read them. Exposed in the Live column under the in/out counters as a coral-tinted 'drop N'.

4. Console --version flag: prints engine version (with embedded git SHA), .NET version, OS, NDI runtime banner, expected prefix, exit-code legend, plus a wilddragon.net link. Useful for support tickets.

5. About dialog: chromeless modal with the dragon mark + version / .NET / OS / NDI runtime fields and a link to wilddragon.net. Triggered by clicking the rail logo.

6. Teams launcher Stop toggle: TeamsLauncher gains IsRunning() and StopAll(). The rail's Teams button now toggles — if Teams is up, ask to close all Teams windows via WM_CLOSE; otherwise launch as before. Confirms before stopping so we don't kill the user's call mid-transition.

Tests: 74/74 unit + 9/9 NDI integration green throughout. MSI builds clean and now embeds the dragon icon for ARP.
2026-05-08 13:50:19 -04:00
01ef4250d7 feat(ui): real Wild Dragon mark in rail + automated transcoder topology
All checks were successful
CI / build-and-test (push) Successful in 31s
Two related deliverables addressing the user's morning asks.

1. Branding: Dragon WHITE.png and Wild Dragon Logo WHITE.png from the brand kit are copied into src/TeamsISO.App/Assets/ and registered as <Resource> items in the .csproj. The rail's placeholder 'W' glyph is replaced by the real dragon mark (40x40, HighQuality bitmap scaling) with a 'Wild Dragon' caption underneath.

2. NDI Access Manager automation: NdiAccessManagerConfig service reads/writes %APPDATA%\\NDI\\ndi-config.v1.json, working in JsonNode trees so we don't clobber unrelated keys. ApplyTranscoderTopology() sets groups.send=[teamsiso-input] and groups.recv=[public, teamsiso-input] so all local senders (Teams + anything else) broadcast on the private group while local receivers can still see public sources too. Engine-side, the user's per-pipeline OutputGroups override pushes TeamsISO outputs back onto Public so downstream switchers see clean ISOs.

Atomic write: temp + replace, with timestamped backup of the prior config. ReadCurrentGroups() can be used by future UI to show what's currently configured. RestoreDefaults() reverts.

Settings panel grows an 'Apply transcoder topology' button under the NDI Network section. Click writes the system config, sets the engine's discovery=teamsiso-input / output=public, refreshes the bound text boxes, and pops a dialog with a 'restart Teams' reminder + the backup path.
2026-05-08 07:19:31 -04:00
c08b90b0b2 feat(ui): Launch Teams rail button + spec for embedded-Teams roadmap
All checks were successful
CI / build-and-test (push) Successful in 40s
First step of Phase E.1 from the new spec at docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md: a third icon in the left rail launches the Microsoft Teams desktop client as a subprocess of TeamsISO so the operator doesn't have to leave the app to start a meeting.

Services/TeamsLauncher tries the ms-teams: URI first, falls back to %LOCALAPPDATA%\\Microsoft\\WindowsApps\\ms-teams.exe (new Teams), then the classic Update.exe handoff. On failure surfaces a friendly MessageBox with the install link.

The spec doc lays out the full three-phase roadmap (launcher -> window orchestration -> in-app meeting controls via Graph API or UIAutomation) and explicitly calls out what's out of scope (replacing Teams' media stack).

_NEXT.md updated to mark Phase D done and queue Phase E + remaining polish items (code-signing, Inter/JetBrains Mono font bundling, real Wild Dragon dragon-mark, drops counter, running-fps display).
2026-05-08 01:05:26 -04:00