diff --git a/src/TeamsISO.App/Services/TeamsLauncher.cs b/src/TeamsISO.App/Services/TeamsLauncher.cs index db1a72c..334faca 100644 --- a/src/TeamsISO.App/Services/TeamsLauncher.cs +++ b/src/TeamsISO.App/Services/TeamsLauncher.cs @@ -264,8 +264,58 @@ public static class TeamsLauncher [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int GetWindowTextW(IntPtr hWnd, [Out] System.Text.StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int GetWindowTextLengthW(IntPtr hWnd); + private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + /// + /// Returns the title bar text of Teams' most-recently-used top-level + /// 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 + /// Alice | Microsoft Teams"), so this is the cheapest way to surface + /// meeting context to TeamsISO's UI without burning a UIA traversal. + /// + /// Includes hidden windows — operators using auto-hide still get the + /// title surfaced, which is the whole point. + /// + public static string GetActiveWindowTitle() + { + try + { + var teamsPids = new HashSet( + TeamsProcessNames + .SelectMany(n => Process.GetProcessesByName(n)) + .Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } })); + if (teamsPids.Count == 0) return string.Empty; + + string longestTitle = string.Empty; + EnumWindows((hWnd, _) => + { + if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true; + GetWindowThreadProcessId(hWnd, out var pid); + if (!teamsPids.Contains(pid)) return true; + + var len = GetWindowTextLengthW(hWnd); + if (len <= 0) return true; + var sb = new System.Text.StringBuilder(len + 1); + GetWindowTextW(hWnd, sb, sb.Capacity); + var title = sb.ToString(); + // Teams creates a few top-level windows per process; the + // call/meeting window has the longest title (other windows + // tend to just be "Microsoft Teams"). Pick the longest one + // as a heuristic for "most informative". + if (title.Length > longestTitle.Length) longestTitle = title; + return true; + }, IntPtr.Zero); + return longestTitle; + } + catch { return string.Empty; } + } + /// /// Enumerate every visible top-level window owned by any running Teams /// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is diff --git a/src/TeamsISO.App/ViewModels/MainViewModel.cs b/src/TeamsISO.App/ViewModels/MainViewModel.cs index 7ca8f28..5f93266 100644 --- a/src/TeamsISO.App/ViewModels/MainViewModel.cs +++ b/src/TeamsISO.App/ViewModels/MainViewModel.cs @@ -524,6 +524,34 @@ public sealed class MainViewModel : ObservableObject, IDisposable Toast.Show($"Stopped {enabled.Length} ISO(s)"); } + /// + /// Pull the meaningful "meeting title" out of Teams' raw window title. + /// Teams uses formats like: + /// "Weekly Standup | Microsoft Teams" + /// "Meeting with Alice | Microsoft Teams" + /// "Microsoft Teams" (no meeting, just the app) + /// Strip the trailing " | Microsoft Teams" so the IN-CALL pill stays + /// short and readable. Truncate beyond 50 chars so a long meeting + /// subject doesn't push the rest of the IN-CALL bar off screen. + /// + internal static string ExtractMeetingTitle(string windowTitle) + { + if (string.IsNullOrWhiteSpace(windowTitle)) return string.Empty; + var t = windowTitle.Trim(); + // Common separator patterns Teams uses across locales. + foreach (var sep in new[] { " | Microsoft Teams", " | Teams", " - Microsoft Teams", " - Teams" }) + { + var idx = t.IndexOf(sep, StringComparison.Ordinal); + if (idx > 0) { t = t.Substring(0, idx).Trim(); break; } + } + // If after stripping we're left with just "Microsoft Teams" the + // window has no meeting context — return empty so the pill stays + // at "IN CALL" without a stale title. + if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty; + if (t.Length > 50) t = t.Substring(0, 47) + "…"; + return t; + } + private void OnStatsTick(object? sender, EventArgs e) { foreach (var vm in Participants) @@ -628,10 +656,13 @@ public sealed class MainViewModel : ObservableObject, IDisposable try { var inCall = TeamsControlBridge.IsInCall(); + var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null; _dispatcher.InvokeAsync(() => { IsTeamsInCall = inCall; - TeamsMeetingState = inCall ? "IN CALL" : "READY"; + TeamsMeetingState = inCall + ? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}") + : "READY"; }); } catch { /* UIA flakiness shouldn't crash the stats tick */ } diff --git a/src/tests/TeamsISO.App.Tests/MeetingTitleExtractionTests.cs b/src/tests/TeamsISO.App.Tests/MeetingTitleExtractionTests.cs new file mode 100644 index 0000000..083fc79 --- /dev/null +++ b/src/tests/TeamsISO.App.Tests/MeetingTitleExtractionTests.cs @@ -0,0 +1,67 @@ +using TeamsISO.App.ViewModels; +using Xunit; + +namespace TeamsISO.App.Tests; + +/// +/// Validates the title-stripping heuristic used by the IN-CALL bar pill. +/// Teams' raw window title is the format the user actually sees (or doesn't — +/// when auto-hide is on); we want to surface the meaningful meeting-name +/// portion without the "| Microsoft Teams" suffix bloating the pill. +/// +public class MeetingTitleExtractionTests +{ + [Theory] + [InlineData("Weekly Standup | Microsoft Teams", "Weekly Standup")] + [InlineData("Meeting with Alice | Microsoft Teams", "Meeting with Alice")] + [InlineData("Q4 Planning - Microsoft Teams", "Q4 Planning")] + [InlineData("Meeting | Teams", "Meeting")] + public void StripsTeamsSuffix(string raw, string expected) + { + Assert.Equal(expected, MainViewModel.ExtractMeetingTitle(raw)); + } + + [Fact] + public void EmptyInput_ReturnsEmpty() + { + Assert.Equal(string.Empty, MainViewModel.ExtractMeetingTitle(string.Empty)); + } + + [Fact] + public void Whitespace_ReturnsEmpty() + { + Assert.Equal(string.Empty, MainViewModel.ExtractMeetingTitle(" ")); + } + + [Fact] + public void BareAppTitle_ReturnsEmpty() + { + // "Microsoft Teams" alone means no meeting context — pill should + // stay at plain "IN CALL" rather than appending a meaningless title. + Assert.Equal(string.Empty, MainViewModel.ExtractMeetingTitle("Microsoft Teams")); + } + + [Fact] + public void LongTitle_GetsTruncated() + { + var long_ = new string('A', 100) + " | Microsoft Teams"; + var result = MainViewModel.ExtractMeetingTitle(long_); + Assert.True(result.Length <= 50); + Assert.EndsWith("…", result); + } + + [Fact] + public void OuterWhitespaceIsTrimmed() + { + // Real Teams uses single-space format " | Microsoft Teams"; we only + // promise to trim outer whitespace, not normalize internal padding. + Assert.Equal("Project sync", MainViewModel.ExtractMeetingTitle(" Project sync | Microsoft Teams ")); + } + + [Fact] + public void TitleWithoutSeparator_PassesThrough() + { + // If Teams emits an unrecognized format, return it as-is (clamped to 50). + Assert.Equal("Quick chat", MainViewModel.ExtractMeetingTitle("Quick chat")); + } +}