using System.Runtime.InteropServices; namespace RemSound.App; /// /// Owns the system-tray icon RemSound shows while it's minimised. The icon's right-click /// menu provides quick access to the most common actions without restoring the window — /// useful for users who park RemSound in the tray and never look at the main window again. /// /// Menu (2026-05-28 rewrite — the original four-item menu was just Show / Sending / /// Receiving / Exit, with no recent-profiles entry and no live state on the checkable items): /// /// * Show RemSound (Alt+W) — restore the main window /// * Enable sending (Alt+S) — checkable, reflects sendMyAudioCheckbox state /// * Enable receiving (Alt+R) — checkable, reflects receiveAudioCheckbox state /// * Profiles (Alt+P) — submenu of AppConfig.RecentProfiles, picking one /// switches the active profile /// * Exit (Alt+X) — close the app /// /// Tooltip (NotifyIcon.Text): set dynamically from MainForm's snapshot tick via /// . Default is "RemSound" until the first refresh. Capped at /// 127 characters because Windows truncates anything beyond that. /// internal sealed class MainFormTrayController : IDisposable { /// Maximum NotifyIcon.Text length on Windows 10+. Windows truncates anything /// longer; truncating ourselves means the tooltip ends with our own "..." rather than /// being chopped mid-word. private const int MaxTooltipLength = 127; private readonly Form owner; private readonly NotifyIcon trayIcon = new(); private readonly Func getSending; private readonly Action toggleSending; private readonly Func getReceiving; private readonly Action toggleReceiving; private readonly Func> getRecentProfilePaths; private readonly Action switchToProfile; private readonly Func buildTooltip; private readonly Action exit; /// The tooltip text the icon was last (re-)registered with. We compare against /// this so we only re-register on a real state change — a peer connecting or dropping, a /// lane switching, a recording starting or stopping. The tray tooltip has no per-second /// element (recording shows a plain "recording" flag, not a ticking timer), so the text /// only changes on those real events and the icon never flickers. Null until first shown. private string? lastRegistrationText; private readonly ToolStripMenuItem sendingItem; private readonly ToolStripMenuItem receivingItem; private readonly ToolStripMenuItem profilesItem; public MainFormTrayController( Form owner, Func getSending, Action toggleSending, Func getReceiving, Action toggleReceiving, Func> getRecentProfilePaths, Action switchToProfile, Func buildTooltip, Action exit) { this.owner = owner; this.getSending = getSending; this.toggleSending = toggleSending; this.getReceiving = getReceiving; this.toggleReceiving = toggleReceiving; this.getRecentProfilePaths = getRecentProfilePaths; this.switchToProfile = switchToProfile; this.buildTooltip = buildTooltip; this.exit = exit; // We deliberately don't bake any "starting up" / "running" string into the icon // here. The tooltip is computed fresh from buildTooltip() at the moment the icon // first becomes visible (in Minimize) and refreshed every second from MainForm's // snapshot tick after that. The reason: Windows' shell caches the tooltip text // it sees at NIM_ADD time and is reluctant to refresh hover text for the same // icon ID. Setting an "initial" string here meant that on a slow / minimised-at- // launch flow (e.g. the resume-after-update path with StartMinimised on), the // shell registered the icon with the stale string and kept showing it until the // user hid + re-showed the icon. By computing the right text once, just before // we set Visible = true for the first time, the shell sees the live state from // NIM_ADD onward. trayIcon.Icon = SystemIcons.Application; trayIcon.Visible = false; trayIcon.DoubleClick += (_, _) => Restore(); var menu = new ContextMenuStrip(); var showItem = new ToolStripMenuItem("Sho&w RemSound") { AccessibleName = "Show RemSound", }; showItem.Click += (_, _) => Restore(); sendingItem = new ToolStripMenuItem("Enable &sending") { CheckOnClick = false, // we set Checked manually in RefreshMenuState; toggle drives the actual app state via the callback AccessibleName = "Enable sending", }; sendingItem.Click += (_, _) => toggleSending(); receivingItem = new ToolStripMenuItem("Enable &receiving") { CheckOnClick = false, AccessibleName = "Enable receiving", }; receivingItem.Click += (_, _) => toggleReceiving(); profilesItem = new ToolStripMenuItem("&Profiles") { AccessibleName = "Profiles", }; // Populate ONCE at construction so WinForms recognises this as a real submenu // (an empty DropDownItems collection means the framework treats the item as a // plain command, never opens the submenu, and DropDownOpening never fires — // which produced "Profiles does nothing" in the first cut of this controller). // After that, every DropDownOpening rebuilds the items so a profile loaded since // the last menu open shows up immediately. RebuildProfilesSubmenu(); profilesItem.DropDownOpening += (_, _) => RebuildProfilesSubmenu(); var exitItem = new ToolStripMenuItem("E&xit") { AccessibleName = "Exit RemSound", }; exitItem.Click += (_, _) => exit(); menu.Items.Add(showItem); menu.Items.Add(sendingItem); menu.Items.Add(receivingItem); menu.Items.Add(profilesItem); menu.Items.Add(new ToolStripSeparator()); menu.Items.Add(exitItem); // Refresh the checkable items' state every time the menu opens so the visible // ticks match the current main-window state (which can have changed while the // user was clicking around elsewhere). menu.Opening += (_, _) => RefreshMenuState(); trayIcon.ContextMenuStrip = menu; } /// Replace the tooltip the OS shows over the tray icon. Called from MainForm's /// 1 Hz snapshot tick to keep the text current with peer count + send/receive state. /// Truncated to the Windows 10+ limit (127 chars) — anything longer is silently chopped /// by the shell, so chopping ourselves keeps the truncation point visible. public void SetTooltip(string text) { // Same anti-duplicate rule as the ctor: avoid a single-word "RemSound" tooltip on // the "RemSound" process, since some screen readers render that as "RemSound RemSound". if (string.IsNullOrEmpty(text)) text = "RemSound — running"; if (text.Length > MaxTooltipLength) { text = text[..(MaxTooltipLength - 1)] + "…"; } // NotifyIcon.Text throws on the same string-assigning path under some shell // conditions (rare race during a session-end). Best-effort: swallow. try { trayIcon.Text = text; } catch { /* harmless */ } // Keep the icon's *registered* name — the text a screen reader announces — in step // with the live state. Windows stamps a tray icon's accessible name at the moment the // icon is added (NIM_ADD) and afterwards only refreshes the little hover bubble, not // that stamped name. So if the icon appears at launch before the first peer has // connected, the name is frozen as "no peers"; when the peer links up a second later // the bubble updates to "1 peer" but the frozen name stays — and NVDA reads BOTH (the // stale name, then the live bubble), which is the confusing double announcement. // // The cure is to re-add the icon (hide then re-show = NIM_DELETE + NIM_ADD) whenever // the text changes, which re-stamps the name with the current text. This is exactly // what the old "show the window then minimise again" workaround did by hand. The tray // tooltip has no per-second element (recording shows a plain "recording" flag, not a // ticking timer), so the text only changes on real events and this never flickers. if (!trayIcon.Visible) { // The first call arrives from Minimize() a moment before the icon is shown — just // record the baseline so the NIM_ADD that immediately follows is our reference. lastRegistrationText = text; return; } if (text == lastRegistrationText) return; // Don't yank the icon out from under an open right-click menu (that would dismiss it // mid-navigation). Leave the record stale so the next tick re-stamps once it closes. if (trayIcon.ContextMenuStrip?.Visible == true) return; lastRegistrationText = text; try { trayIcon.Visible = false; trayIcon.Visible = true; } catch { /* harmless — the 1 Hz snapshot tick will try again next second */ } } public void Toggle() { if (owner.Visible && owner.WindowState != FormWindowState.Minimized) Minimize(); else Restore(); } public void Restore() { // Only sound the "show" cue on a genuine hidden -> shown transition, not a no-op restore. var wasHidden = !owner.Visible || owner.WindowState == FormWindowState.Minimized; owner.Show(); if (owner.WindowState == FormWindowState.Minimized) { owner.WindowState = FormWindowState.Normal; } owner.BringToFront(); owner.Activate(); // WinForms' Activate() is best-effort: Windows' foreground-lock feature blocks // arbitrary processes from stealing focus, and Activate() doesn't always win even // for a process that's clearly user-initiated. Calling SetForegroundWindow directly // bypasses the lock because the caller (a tray-menu click handler) is on a UI // thread that received recent user input — which Windows recognises as the // legitimate "user asked for this" case. Without this fix, "Show RemSound" puts // the window on screen but doesn't focus it, leaving NVDA users having to Alt+Tab // to actually hear the new content. try { SetForegroundWindow(owner.Handle); } catch { /* harmless — Restore still mostly worked */ } trayIcon.Visible = false; if (wasHidden) (owner as MainForm)?.PlayWindowVisibilityCue(show: true); } [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetForegroundWindow(IntPtr hWnd); /// Hide the window to the tray. defaults true for a genuine /// user-initiated minimise; the startup-minimise path passes false because that's not the user /// choosing to hide the window — and the usual "was it visible?" guard can't tell the two apart, /// since by the time the deferred startup minimise runs the window HAS been shown (Visible=true). public void Minimize(bool playCue = true) { // Only sound the "hide" cue on a genuine shown -> hidden transition the user asked for: not a // startup-minimise (playCue:false), and not a repeat call once already hidden (wasVisible). var wasVisible = owner.Visible; owner.Hide(); if (wasVisible && playCue) (owner as MainForm)?.PlayWindowVisibilityCue(show: false); // Refresh the tooltip BEFORE showing the icon so the shell's NIM_ADD call carries // the current live state (peer count, send / receive routing, recording timer), // not a stale "starting up" string set earlier. The shell tends to cache hover // text from NIM_ADD time and is slow to update on subsequent NIM_MODIFY calls — // computing the right text now means the first hover already reads correctly. try { SetTooltip(buildTooltip()); } catch { /* harmless — fall through to the snapshot-tick refresh */ } trayIcon.Visible = true; } public void Dispose() => trayIcon.Dispose(); private void RefreshMenuState() { // Live state of the two togglable items — read on demand from MainForm so a checkbox // change made via the main window or a global hotkey is reflected in the tray menu // the next time the user opens it. try { sendingItem.Checked = getSending(); } catch { sendingItem.Checked = false; } try { receivingItem.Checked = getReceiving(); } catch { receivingItem.Checked = false; } } private void RebuildProfilesSubmenu() { profilesItem.DropDownItems.Clear(); IReadOnlyList paths; try { paths = getRecentProfilePaths(); } catch { paths = Array.Empty(); } var slot = 1; foreach (var path in paths) { if (string.IsNullOrWhiteSpace(path)) continue; if (!File.Exists(path)) continue; // skip missing files; the AppConfig list keeps the entry in case it reappears var title = Path.GetFileNameWithoutExtension(path); // Mnemonic prefix matches the File menu's Recent profiles submenu (&1..&5) so // muscle-memory between the in-app menu and the tray menu carries over. The // visible Text carries the number; AccessibleName is just the profile name so // NVDA reads "MyProfile, menu item, one of five" rather than the noisier // "Recent profile 1: MyProfile" that the original code was reading out. var item = new ToolStripMenuItem($"&{slot} {title}") { AccessibleName = title, Tag = path, }; item.Click += (s, _) => { var sender = (ToolStripMenuItem)s!; var profilePath = (string)sender.Tag!; try { switchToProfile(profilePath); } catch { /* the switch path surfaces its own errors via MainForm */ } }; profilesItem.DropDownItems.Add(item); slot++; } if (profilesItem.DropDownItems.Count == 0) { profilesItem.DropDownItems.Add(new ToolStripMenuItem("(No recent profiles)") { Enabled = false, AccessibleName = "No recent profiles", }); } } }