Peer extraction stage 3: peer cluster into MainForm.Peers.cs + heartbeat windows pinned
The god-object shrink, completed as pure code motion: the peer state (known/manual/ remembered/selected/named peers, endpoint anti-thrash) and the whole Peers section (discovery reconciliation, endpoint follow, arming refresh, manual add, named-peers dialog, remembered delete) move VERBATIM into MainForm.Peers.cs as a partial-class split - the same pattern Andre's SensorReadout form uses. Same class, same members, compiler-proven identical; MainForm.cs drops ~430 lines and the peer logic finally has its own front door. The shared LOGIC under it (PeerArming, CaptureSpecBuilder, PeerAddress) landed in stages 1-2. Plus the last flagged test gap: the heartbeat Healthy->Stale->Unreachable windows are now pinned against a CONTROLLED clock via a seam over the real derivation (SnapshotHealthLocked, made static - it was already pure): 1s = Healthy, 3s = Stale, 6s = Unreachable, never-answered = pending first, Unreachable only after sustained pinging. These are the numbers every peer's armed/pruned state hangs off in both the app and the service. Gate 61/61. This completes the review plan's Phase 4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using NAudio.CoreAudioApi;
|
||||
using RemSound.Core;
|
||||
using RemSound.Receiver;
|
||||
using RemSound.Sender;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// The PEER half of the main window — state, discovery/selection reconciliation, arming, naming and
|
||||
/// the remembered-peers plumbing — split out of MainForm.cs verbatim in the 2026-07-26 review's
|
||||
/// god-object shrink (same partial-class pattern Andre's SensorReadout form uses). Pure code motion:
|
||||
/// same class, same members, no behaviour change — the compiler proves it. The shared LOGIC these
|
||||
/// methods lean on (PeerArming, CaptureSpecBuilder, PeerAddress) already lives in Core.
|
||||
/// </summary>
|
||||
public sealed partial class MainForm
|
||||
{
|
||||
// --- Peer state ---
|
||||
private readonly Dictionary<Guid, PeerAnnouncement> knownPeers = [];
|
||||
private readonly Dictionary<Guid, PeerAnnouncement> manualPeers = [];
|
||||
private readonly Dictionary<string, Guid> rememberedPeerInstanceIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Endpoint targets the user has ticked. STICKY — once a peer is selected, its IP/port stays
|
||||
// here regardless of whether discovery currently sees it. Discovery turnover (peer briefly
|
||||
// offline, NIC blips, sleep, etc.) does NOT untick or stop the sender. UDP just keeps flowing
|
||||
// toward the cached IP; if no one's home, packets disappear, and they resume the moment the
|
||||
// peer comes back. Neither machine has to be online "first" or "in order".
|
||||
//
|
||||
// Key: peer instance Guid (or generated one for IP-only manual entries).
|
||||
// Value: last-known endpoint. If discovery sees the same instance with a new address (DHCP
|
||||
// renewal etc.) we update the value but keep the key.
|
||||
private readonly Dictionary<Guid, IPEndPoint> selectedPeerEndpoints = [];
|
||||
// Display labels for selected peers so we can render them in the dialog list even when
|
||||
// discovery has temporarily lost sight of them ("Foo (192.168.1.5) — offline").
|
||||
private readonly Dictionary<Guid, string> selectedPeerLabels = [];
|
||||
// The "named peers" book, keyed by peer identity (machine name, else address). Loaded from AppConfig
|
||||
// (machine-wide) at startup and mirrored back on change. Resolved to display names everywhere a peer
|
||||
// shows — connected/discovered lists, the volume/pan/EQ list, the status line, split recordings.
|
||||
// Only deliberately-renamed peers live here. Last address / last-seen updated as they connect.
|
||||
private Dictionary<string, NamedPeer> namedPeers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool namedPeersDirty; // an address changed this session; flush on the next tick
|
||||
// When each connected peer (by per-run InstanceId) first went healthy — for the "connected for" line.
|
||||
private readonly Dictionary<Guid, DateTime> peerConnectedSinceUtc = [];
|
||||
|
||||
// Anti-thrash state for the discovery-driven endpoint follow (see the peer-rebuild loop). A peer
|
||||
// reachable at two addresses at once (a VPN address AND a LAN address, say) announces from both,
|
||||
// and discovery reports whichever it heard last; following that blindly made the tracked endpoint
|
||||
// ping-pong between the two, and a fast ping-pong tore the receiver's audio session down and back
|
||||
// up quickly enough to crash the app (#16). A follow now needs the current endpoint to have been
|
||||
// unreachable for a sustained spell and can't fire more than once per cooldown. Keyed by peer id.
|
||||
private readonly Dictionary<Guid, DateTime> endpointUnreachableSinceUtc = [];
|
||||
private readonly Dictionary<Guid, DateTime> lastEndpointMoveUtc = [];
|
||||
private static readonly TimeSpan EndpointMoveUnreachableGrace = TimeSpan.FromSeconds(6);
|
||||
private static readonly TimeSpan EndpointMoveCooldown = TimeSpan.FromSeconds(15);
|
||||
|
||||
// ===================== Peers =====================
|
||||
|
||||
private void RefreshKnownPeers()
|
||||
{
|
||||
knownPeers.Clear();
|
||||
// Discovered peers go in first so manual peers added by IP don't shadow them.
|
||||
foreach (var peer in discovery.Peers) knownPeers[peer.InstanceId] = peer;
|
||||
foreach (var peer in manualPeers.Values) knownPeers[peer.InstanceId] = peer;
|
||||
|
||||
// Locked-to-fixed-addresses profiles (#17): the user wants RemSound to use exactly the peer
|
||||
// addresses they set and never substitute one found on the network. Skip the discovered-peer
|
||||
// merge (which would attach a computer name to their selection) and the address-follow below;
|
||||
// the selection's allow-list is still pushed so audio flows to those exact addresses, unchanged.
|
||||
if (settings.LoadLockPeerAddresses())
|
||||
{
|
||||
PushAllowedReceiveSenders();
|
||||
return;
|
||||
}
|
||||
|
||||
// Dedupe by endpoint (address:port). When a manual peer (typed by IP) and a discovered
|
||||
// peer (broadcasting hostname) point to the same machine, drop the manual entry and
|
||||
// forward any active selection to the discovered peer so the user doesn't lose it.
|
||||
// Prefer entries whose Name is NOT just the IP — those are real hostnames.
|
||||
var byEndpoint = new Dictionary<string, PeerAnnouncement>(StringComparer.OrdinalIgnoreCase);
|
||||
var redirectedSelections = new List<(Guid From, Guid To)>();
|
||||
foreach (var peer in knownPeers.Values.ToList())
|
||||
{
|
||||
var key = $"{peer.Address}:{peer.AudioPort}";
|
||||
if (!byEndpoint.TryGetValue(key, out var existing))
|
||||
{
|
||||
byEndpoint[key] = peer;
|
||||
continue;
|
||||
}
|
||||
// Prefer the one with a real hostname (Name != IP-as-string).
|
||||
var existingIsIp = existing.Name == existing.Address.ToString();
|
||||
var peerIsIp = peer.Name == peer.Address.ToString();
|
||||
var winner = existingIsIp && !peerIsIp ? peer : existing;
|
||||
var loser = winner == existing ? peer : existing;
|
||||
byEndpoint[key] = winner;
|
||||
// Move loser's selection (if any) to winner so the checkbox state survives.
|
||||
if (selectedPeerEndpoints.ContainsKey(loser.InstanceId))
|
||||
{
|
||||
redirectedSelections.Add((loser.InstanceId, winner.InstanceId));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (from, to) in redirectedSelections)
|
||||
{
|
||||
if (selectedPeerEndpoints.Remove(from, out var endpoint))
|
||||
{
|
||||
selectedPeerEndpoints[to] = endpoint;
|
||||
if (selectedPeerLabels.Remove(from, out var label))
|
||||
{
|
||||
selectedPeerLabels[to] = label;
|
||||
}
|
||||
// The "manual peer" that lost out should be removed from manualPeers too,
|
||||
// otherwise the next discovery refresh re-creates the duplicate.
|
||||
manualPeers.Remove(from);
|
||||
}
|
||||
}
|
||||
|
||||
knownPeers.Clear();
|
||||
foreach (var peer in byEndpoint.Values) knownPeers[peer.InstanceId] = peer;
|
||||
|
||||
// If a selected peer's announced address changed (DHCP renewal, network switch), follow it to
|
||||
// the new address — but conservatively. A peer reachable at two addresses at once (e.g. a VPN
|
||||
// address AND a LAN address) announces from both, and discovery reports whichever it heard
|
||||
// last. Following that blindly made the tracked endpoint ping-pong between the two; because
|
||||
// this one endpoint feeds the audio sender, the heartbeat AND the receiver's allow-list, the
|
||||
// churn was heard as crackle (Tech Singer's Win7-over-VPN report, 2026-05-31) and, when it
|
||||
// thrashed fast enough, tore the receiver's audio session down and back up quickly enough to
|
||||
// crash the app (#16, same singer). So: never move off an address that's still answering
|
||||
// heartbeats; only follow once the current one has been unreachable for a sustained spell,
|
||||
// only TO an address that is itself answering, and never more than once per cooldown. Net
|
||||
// effect — a peer you reach on a working address stays put; a genuine move (the old address
|
||||
// really went away) is still followed a few seconds later.
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
foreach (var (id, oldEndpoint) in selectedPeerEndpoints.ToList())
|
||||
{
|
||||
if (!knownPeers.TryGetValue(id, out var peer)) continue;
|
||||
selectedPeerLabels[id] = ResolvePeerDisplayName(peer);
|
||||
|
||||
var newEndpoint = new IPEndPoint(peer.Address, peer.AudioPort);
|
||||
// Same address, or the one we're on is still healthy: nothing to do — and reset the
|
||||
// unreachable-since clock so a brief future blip starts counting from zero.
|
||||
if (newEndpoint.Equals(oldEndpoint) || IsEndpointHeartbeatHealthy(oldEndpoint))
|
||||
{
|
||||
endpointUnreachableSinceUtc.Remove(id);
|
||||
continue;
|
||||
}
|
||||
// The current endpoint isn't answering. Start (or read) its unreachable-since clock, and
|
||||
// don't act on the very first unhealthy tick — wait out the grace period.
|
||||
if (!endpointUnreachableSinceUtc.TryGetValue(id, out var downSince))
|
||||
{
|
||||
endpointUnreachableSinceUtc[id] = nowUtc;
|
||||
continue;
|
||||
}
|
||||
if (nowUtc - downSince < EndpointMoveUnreachableGrace) continue; // not down long enough yet
|
||||
if (!IsEndpointHeartbeatHealthy(newEndpoint)) continue; // don't chase a dead address
|
||||
if (lastEndpointMoveUtc.TryGetValue(id, out var lastMove)
|
||||
&& nowUtc - lastMove < EndpointMoveCooldown) continue; // anti-thrash cooldown
|
||||
|
||||
selectedPeerEndpoints[id] = newEndpoint;
|
||||
lastEndpointMoveUtc[id] = nowUtc;
|
||||
endpointUnreachableSinceUtc.Remove(id);
|
||||
logFile.Event($"peer {peer.Name} endpoint moved {oldEndpoint} -> {newEndpoint} (old endpoint unreachable {(int)(nowUtc - downSince).TotalSeconds}s)");
|
||||
}
|
||||
|
||||
// Endpoints may have moved (DHCP/announcement-update path above) or selections may have
|
||||
// been redirected (manual-peer-merged-into-discovered above). Push the latest set down
|
||||
// to the receiver's allow-list so we don't keep accepting from a stale endpoint we no
|
||||
// longer recognise as a selected peer.
|
||||
PushAllowedReceiveSenders();
|
||||
}
|
||||
|
||||
private void SelectPeer(PeerAnnouncement peer) => SelectPeer(peer, fromProfileRestore: false);
|
||||
|
||||
private void SelectPeer(PeerAnnouncement peer, bool fromProfileRestore)
|
||||
{
|
||||
selectedPeerEndpoints[peer.InstanceId] = new IPEndPoint(peer.Address, peer.AudioPort);
|
||||
selectedPeerLabels[peer.InstanceId] = ResolvePeerDisplayName(peer);
|
||||
logFile.Event($"peer selected: {peer.Name} {peer.Address}:{peer.AudioPort}");
|
||||
InvalidateAutoTuneHistory();
|
||||
PushAllowedReceiveSenders();
|
||||
// fromProfileRestore=true means the call originated from auto-reconnect at startup;
|
||||
// we don't want that to flag the profile as dirty. User-initiated selects do.
|
||||
if (!fromProfileRestore) MarkProfileDirty();
|
||||
}
|
||||
|
||||
private void DeselectPeer(Guid instanceId)
|
||||
{
|
||||
if (selectedPeerEndpoints.Remove(instanceId))
|
||||
{
|
||||
selectedPeerLabels.TryGetValue(instanceId, out var label);
|
||||
selectedPeerLabels.Remove(instanceId);
|
||||
logFile.Event($"peer deselected: {label ?? instanceId.ToString()}");
|
||||
InvalidateAutoTuneHistory();
|
||||
PushAllowedReceiveSenders();
|
||||
MarkProfileDirty();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tells the receiver which sender endpoints are allowed to play audio. Same set as the
|
||||
/// peers we're sending to (the checkbox controls both directions). Called whenever the
|
||||
/// user selects/deselects a peer, and once at startup so the receiver is in a known state.
|
||||
/// Without this, anyone who can reach our UDP port (e.g. a peer who chose us first) would
|
||||
/// auto-play to our speakers — we want explicit consent via the checkbox.
|
||||
/// </summary>
|
||||
private void PushAllowedReceiveSenders()
|
||||
{
|
||||
// Accept audio from ANY source IP a selected peer is known to use, not only the single address
|
||||
// we currently target. A multi-homed sender (on a LAN and a VPN at once) can egress audio from a
|
||||
// different interface than the one we discovered or dialled; allow-listing just the one made the
|
||||
// receiver silently drop that audio while heartbeats (which skip this check) kept the peer
|
||||
// looking connected — connected but silent (#18). The SEND targets stay single-address; only the
|
||||
// accept-list widens, and only to other addresses the SAME peer (by InstanceId) announced from.
|
||||
var allowed = new List<IPEndPoint>();
|
||||
var seen = new HashSet<IPAddress>();
|
||||
foreach (var (id, ep) in selectedPeerEndpoints)
|
||||
{
|
||||
if (seen.Add(ep.Address)) allowed.Add(new IPEndPoint(ep.Address, 0));
|
||||
foreach (var addr in discovery.GetKnownAddresses(id))
|
||||
if (seen.Add(addr)) allowed.Add(new IPEndPoint(addr, 0));
|
||||
}
|
||||
receiver.SetAllowedSenders(allowed);
|
||||
}
|
||||
|
||||
/// <summary>True if the heartbeat currently considers <paramref name="endpoint"/> healthy —
|
||||
/// i.e. we're getting pongs back from exactly that address+port right now. Used to keep the
|
||||
/// audio target pinned to a proven-good endpoint instead of chasing a multi-homed peer's
|
||||
/// other (possibly unreachable) advertised address every discovery refresh. 2026-05-31.</summary>
|
||||
private bool IsEndpointHeartbeatHealthy(IPEndPoint endpoint)
|
||||
{
|
||||
if (heartbeatService is null) return false;
|
||||
foreach (var h in heartbeatService.GetAllPeerHealth())
|
||||
{
|
||||
if (h.State == PeerHealthState.Healthy && h.AudioEndpoint.Equals(endpoint))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wipes the rolling max-gap window and pushes <see cref="lastSourceChangeUtc"/> forward,
|
||||
/// so the next continuous auto-tune tick has nothing to react to. Called whenever a user
|
||||
/// action (peer (de)selection, source list toggle, manually moving the latency slider) is
|
||||
/// likely to produce a measured "gap" that doesn't reflect the network — e.g. the user
|
||||
/// reselecting localhost after a 5 s pause records a 5 s inter-arrival gap, which would
|
||||
/// otherwise pin the auto-tune to its 200 ms cap for half a minute.
|
||||
/// </summary>
|
||||
private void InvalidateAutoTuneHistory()
|
||||
{
|
||||
recentMaxGaps.Clear();
|
||||
recentRenderCbGaps.Clear();
|
||||
lastSourceChangeUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private IPEndPoint[] SelectedSendEndpoints()
|
||||
{
|
||||
// Collapse duplicates by ip:port so the same address isn't targeted twice.
|
||||
return selectedPeerEndpoints.Values
|
||||
.GroupBy(ep => $"{ep.Address}:{ep.Port}")
|
||||
.Select(g => g.First())
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>The ticked ASIO channel-pair indices in a list (parsed from the synthetic "asio:N"
|
||||
/// ids). Internal + static so the self-test can pin the per-driver tick memory round-trip.</summary>
|
||||
internal static int[] SnapshotAsioTicks(CheckedListBox list)
|
||||
{
|
||||
var pairs = new List<int>();
|
||||
for (var i = 0; i < list.Items.Count; i++)
|
||||
{
|
||||
if (list.GetItemChecked(i) && list.Items[i] is AudioDeviceChoice { DeviceId: { } id }
|
||||
&& AsioDeviceId.TryParse(id, out var pair))
|
||||
{
|
||||
pairs.Add(pair);
|
||||
}
|
||||
}
|
||||
return pairs.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Re-tick the given pair indices in a freshly rebuilt ASIO list. Pairs the new driver
|
||||
/// doesn't have (fewer channels) are silently skipped. Caller must hold suppressDeviceCheckChange.</summary>
|
||||
internal static void RestoreAsioTicks(CheckedListBox list, int[] pairs)
|
||||
{
|
||||
if (pairs.Length == 0) return;
|
||||
var wanted = new HashSet<int>(pairs);
|
||||
for (var i = 0; i < list.Items.Count; i++)
|
||||
{
|
||||
if (list.Items[i] is AudioDeviceChoice { DeviceId: { } id }
|
||||
&& AsioDeviceId.TryParse(id, out var pair) && wanted.Contains(pair))
|
||||
{
|
||||
list.SetItemChecked(i, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Address split + resolve moved to Core (PeerAddress) so the app and the service share one
|
||||
// implementation — these thin wrappers keep the existing call sites readable.
|
||||
private static Task<IPAddress?> ResolvePeerAddressAsync(string text) => PeerAddress.ResolveHostAsync(text);
|
||||
|
||||
internal static (string host, int? port) TrySplitHostPort(string text) => PeerAddress.Split(text);
|
||||
|
||||
private PeerAnnouncement CreateManualPeer(string entry, IPAddress address)
|
||||
{
|
||||
var (_, parsedPort) = TrySplitHostPort(entry);
|
||||
var label = string.IsNullOrWhiteSpace(entry) ? address.ToString() : entry.Trim();
|
||||
return new PeerAnnouncement(
|
||||
Guid.NewGuid(),
|
||||
label,
|
||||
parsedPort ?? RemPacket.DefaultPeerDialPort,
|
||||
CanSend: true,
|
||||
CanReceive: true,
|
||||
DateTime.UtcNow,
|
||||
address);
|
||||
}
|
||||
|
||||
private async Task AddManualPeerAsync(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
MessageBox.Show(this, "Enter an IP address or hostname for the other computer.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var address = await ResolvePeerAddressAsync(text);
|
||||
if (address is null)
|
||||
{
|
||||
MessageBox.Show(this, "Could not resolve that IP address or hostname.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var rememberedEntries = settings.LoadRememberedPeers()
|
||||
.Select(static value => value.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
rememberedEntries.Add(text.Trim());
|
||||
settings.SaveRememberedPeers(rememberedEntries);
|
||||
|
||||
var peer = CreateManualPeer(text, address);
|
||||
manualPeers[peer.InstanceId] = peer;
|
||||
rememberedPeerInstanceIds[text.Trim()] = peer.InstanceId;
|
||||
SelectPeer(peer);
|
||||
// New peer in remembered/manual list → tell discovery to start unicasting announcements
|
||||
// at this address so they discover us back across VPN/WAN.
|
||||
PushDiscoveryUnicastHints();
|
||||
logFile.Event($"manual peer added {address}:{peer.AudioPort} ({text.Trim()})");
|
||||
|
||||
RefreshKnownPeers();
|
||||
ApplyAudioRuntime();
|
||||
}
|
||||
|
||||
private void LoadRememberedPeersFromSettings()
|
||||
{
|
||||
// No checkboxes on the form for these — they live in the dialog. We just remember them.
|
||||
rememberedPeerInstanceIds.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a peer's identity to the persisted Remembered list (if not already present), and
|
||||
/// records the entry → instance-id mapping so the Remembered dialog can display it. Used
|
||||
/// when connecting via the Discovered list — per Ed's spec, "Remembered" is the long
|
||||
/// history of every peer ever connected to, not just manually-added ones.
|
||||
/// </summary>
|
||||
private void EnsurePeerRemembered(PeerAnnouncement peer)
|
||||
{
|
||||
var entry = string.IsNullOrWhiteSpace(peer.Name) || peer.Name == peer.Address.ToString()
|
||||
? peer.Address.ToString()
|
||||
: peer.Name;
|
||||
var existing = settings.LoadRememberedPeers().ToList();
|
||||
if (existing.Any(e => string.Equals(e, entry, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
// Already remembered — make sure the id mapping is current so
|
||||
// SyncDialogRememberedPeerList correctly hides this entry while the peer is connected.
|
||||
rememberedPeerInstanceIds[entry] = peer.InstanceId;
|
||||
PushDiscoveryUnicastHints();
|
||||
return;
|
||||
}
|
||||
existing.Add(entry);
|
||||
settings.SaveRememberedPeers(existing);
|
||||
rememberedPeerInstanceIds[entry] = peer.InstanceId;
|
||||
PushDiscoveryUnicastHints();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tells the discovery service which IPs to send unicast announcements to. LAN broadcast
|
||||
/// alone doesn't reach peers across a VPN (Tailscale, WireGuard, etc.) — so we explicitly
|
||||
/// announce to every remembered + manual peer IP on top of broadcast. Anyone in our
|
||||
/// remembered list who's running RemSound and reachable will then appear in Discovered,
|
||||
/// regardless of physical network. Sending to an offline peer is a no-op.
|
||||
/// </summary>
|
||||
private void PushDiscoveryUnicastHints()
|
||||
{
|
||||
// Snapshot the UI-thread-owned inputs HERE, then resolve hostnames OFF the UI thread.
|
||||
//
|
||||
// The comment that used to live here claimed Dns.GetHostAddresses "returns near-instantly".
|
||||
// It does for a parsed IP or an already-cached name — but for a remembered HOSTNAME that
|
||||
// can't currently resolve (an offline peer, or a Tailscale/WireGuard name while the VPN is
|
||||
// down) it BLOCKS for the system DNS timeout, seconds per entry. This method runs on the UI
|
||||
// thread on every connect / disconnect / add-peer (it's how discovery learns its VPN unicast
|
||||
// targets), so that block froze the whole window for a few seconds — which a screen-reader
|
||||
// user experiences as the entire machine locking up (issue #10). Same class of bug as the
|
||||
// v3.0.1 UPnP-on-the-UI-thread hang, in a newer feature.
|
||||
//
|
||||
// SetUnicastPeerAddresses just swaps a snapshot reference and fires an announcement, and is
|
||||
// already called from the discovery receive loop's own thread, so it's safe to call from a
|
||||
// background thread here. The hints are advisory and re-pushed frequently, so a slightly
|
||||
// stale result from an overlapping resolution is harmless.
|
||||
var seedAddresses = manualPeers.Values.Select(p => p.Address).ToList();
|
||||
var rememberedEntries = settings.LoadRememberedPeers().ToList();
|
||||
Task.Run(() =>
|
||||
{
|
||||
var hints = new HashSet<IPAddress>(seedAddresses);
|
||||
foreach (var entry in rememberedEntries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry)) continue;
|
||||
if (IPAddress.TryParse(entry, out var direct))
|
||||
{
|
||||
hints.Add(direct);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
foreach (var addr in Dns.GetHostAddresses(entry))
|
||||
{
|
||||
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
hints.Add(addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not resolvable right now — skip; re-pushed next time this method runs.
|
||||
}
|
||||
}
|
||||
discovery.SetUnicastPeerAddresses(hints);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/// <summary>After a Delete in a remembered list: focus the next item AND speak the outcome through
|
||||
/// the screen reader. The speech is load-bearing, not decoration: the next item usually lands on the
|
||||
/// SAME index the deleted one had, and the list already has focus — so no focus or selection event
|
||||
/// fires and NVDA would otherwise say nothing at all (Ed, 2026-07-26: deleting foobar gave silence).
|
||||
/// Speaks "«deleted» removed." plus the row now under focus, or that the list is empty.</summary>
|
||||
private static void FocusAndAnnounceAfterDelete(CheckedListBox list, string deletedLabel, int prevIndex)
|
||||
{
|
||||
FocusListItemAfterDelete(list, prevIndex);
|
||||
var now = list.SelectedItem?.ToString();
|
||||
ScreenReader.Speak(string.IsNullOrWhiteSpace(now)
|
||||
? $"{deletedLabel} removed. The list is empty."
|
||||
: $"{deletedLabel} removed. {now}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After deleting an item from a CheckedListBox, focus the next sensible item so NVDA
|
||||
/// announces the new selection. If something exists at the same index that the deleted
|
||||
/// item occupied, focus that (it's the next-down). Otherwise drop back to the last item.
|
||||
/// Empty list = no focus change.
|
||||
/// </summary>
|
||||
private static void FocusListItemAfterDelete(CheckedListBox list, int prevIndex)
|
||||
{
|
||||
if (list.IsDisposed) return;
|
||||
var count = list.Items.Count;
|
||||
if (count == 0) return;
|
||||
var target = Math.Clamp(prevIndex, 0, count - 1);
|
||||
list.SelectedIndex = target;
|
||||
if (!list.Focused) list.Focus();
|
||||
}
|
||||
|
||||
private void RemoveSelectedRememberedPeer(CheckedListBox list)
|
||||
{
|
||||
if (list.SelectedItem is not RememberedPeerItem selected) return;
|
||||
if (rememberedPeerInstanceIds.TryGetValue(selected.Entry, out var pid))
|
||||
{
|
||||
manualPeers.Remove(pid);
|
||||
DeselectPeer(pid);
|
||||
rememberedPeerInstanceIds.Remove(selected.Entry);
|
||||
}
|
||||
var remaining = settings.LoadRememberedPeers().Where(e => !string.Equals(e, selected.Entry, StringComparison.OrdinalIgnoreCase));
|
||||
settings.SaveRememberedPeers(remaining);
|
||||
RefreshKnownPeers();
|
||||
ApplyAudioRuntime();
|
||||
PushDiscoveryUnicastHints();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,7 +20,7 @@ namespace RemSound.App;
|
||||
/// the focused item, its checked state, position, and "Press Space to toggle".
|
||||
/// * Knob changes flow live to the audio engine — no engine restarts.
|
||||
/// </summary>
|
||||
public sealed class MainForm : Form
|
||||
public sealed partial class MainForm : Form
|
||||
{
|
||||
private const string AppName = "RemSound";
|
||||
|
||||
@@ -451,43 +451,6 @@ public sealed class MainForm : Form
|
||||
// brief settling jitter on a newly-added capture doesn't bias the recommendation upward.
|
||||
private DateTime lastSourceChangeUtc = DateTime.MinValue;
|
||||
|
||||
// --- Peer state ---
|
||||
private readonly Dictionary<Guid, PeerAnnouncement> knownPeers = [];
|
||||
private readonly Dictionary<Guid, PeerAnnouncement> manualPeers = [];
|
||||
private readonly Dictionary<string, Guid> rememberedPeerInstanceIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Endpoint targets the user has ticked. STICKY — once a peer is selected, its IP/port stays
|
||||
// here regardless of whether discovery currently sees it. Discovery turnover (peer briefly
|
||||
// offline, NIC blips, sleep, etc.) does NOT untick or stop the sender. UDP just keeps flowing
|
||||
// toward the cached IP; if no one's home, packets disappear, and they resume the moment the
|
||||
// peer comes back. Neither machine has to be online "first" or "in order".
|
||||
//
|
||||
// Key: peer instance Guid (or generated one for IP-only manual entries).
|
||||
// Value: last-known endpoint. If discovery sees the same instance with a new address (DHCP
|
||||
// renewal etc.) we update the value but keep the key.
|
||||
private readonly Dictionary<Guid, IPEndPoint> selectedPeerEndpoints = [];
|
||||
// Display labels for selected peers so we can render them in the dialog list even when
|
||||
// discovery has temporarily lost sight of them ("Foo (192.168.1.5) — offline").
|
||||
private readonly Dictionary<Guid, string> selectedPeerLabels = [];
|
||||
// The "named peers" book, keyed by peer identity (machine name, else address). Loaded from AppConfig
|
||||
// (machine-wide) at startup and mirrored back on change. Resolved to display names everywhere a peer
|
||||
// shows — connected/discovered lists, the volume/pan/EQ list, the status line, split recordings.
|
||||
// Only deliberately-renamed peers live here. Last address / last-seen updated as they connect.
|
||||
private Dictionary<string, NamedPeer> namedPeers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool namedPeersDirty; // an address changed this session; flush on the next tick
|
||||
// When each connected peer (by per-run InstanceId) first went healthy — for the "connected for" line.
|
||||
private readonly Dictionary<Guid, DateTime> peerConnectedSinceUtc = [];
|
||||
|
||||
// Anti-thrash state for the discovery-driven endpoint follow (see the peer-rebuild loop). A peer
|
||||
// reachable at two addresses at once (a VPN address AND a LAN address, say) announces from both,
|
||||
// and discovery reports whichever it heard last; following that blindly made the tracked endpoint
|
||||
// ping-pong between the two, and a fast ping-pong tore the receiver's audio session down and back
|
||||
// up quickly enough to crash the app (#16). A follow now needs the current endpoint to have been
|
||||
// unreachable for a sustained spell and can't fire more than once per cooldown. Keyed by peer id.
|
||||
private readonly Dictionary<Guid, DateTime> endpointUnreachableSinceUtc = [];
|
||||
private readonly Dictionary<Guid, DateTime> lastEndpointMoveUtc = [];
|
||||
private static readonly TimeSpan EndpointMoveUnreachableGrace = TimeSpan.FromSeconds(6);
|
||||
private static readonly TimeSpan EndpointMoveCooldown = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly Dictionary<CheckedListBox, int> lastFocusedListIndices = [];
|
||||
|
||||
@@ -7082,436 +7045,6 @@ public sealed class MainForm : Form
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== Peers =====================
|
||||
|
||||
private void RefreshKnownPeers()
|
||||
{
|
||||
knownPeers.Clear();
|
||||
// Discovered peers go in first so manual peers added by IP don't shadow them.
|
||||
foreach (var peer in discovery.Peers) knownPeers[peer.InstanceId] = peer;
|
||||
foreach (var peer in manualPeers.Values) knownPeers[peer.InstanceId] = peer;
|
||||
|
||||
// Locked-to-fixed-addresses profiles (#17): the user wants RemSound to use exactly the peer
|
||||
// addresses they set and never substitute one found on the network. Skip the discovered-peer
|
||||
// merge (which would attach a computer name to their selection) and the address-follow below;
|
||||
// the selection's allow-list is still pushed so audio flows to those exact addresses, unchanged.
|
||||
if (settings.LoadLockPeerAddresses())
|
||||
{
|
||||
PushAllowedReceiveSenders();
|
||||
return;
|
||||
}
|
||||
|
||||
// Dedupe by endpoint (address:port). When a manual peer (typed by IP) and a discovered
|
||||
// peer (broadcasting hostname) point to the same machine, drop the manual entry and
|
||||
// forward any active selection to the discovered peer so the user doesn't lose it.
|
||||
// Prefer entries whose Name is NOT just the IP — those are real hostnames.
|
||||
var byEndpoint = new Dictionary<string, PeerAnnouncement>(StringComparer.OrdinalIgnoreCase);
|
||||
var redirectedSelections = new List<(Guid From, Guid To)>();
|
||||
foreach (var peer in knownPeers.Values.ToList())
|
||||
{
|
||||
var key = $"{peer.Address}:{peer.AudioPort}";
|
||||
if (!byEndpoint.TryGetValue(key, out var existing))
|
||||
{
|
||||
byEndpoint[key] = peer;
|
||||
continue;
|
||||
}
|
||||
// Prefer the one with a real hostname (Name != IP-as-string).
|
||||
var existingIsIp = existing.Name == existing.Address.ToString();
|
||||
var peerIsIp = peer.Name == peer.Address.ToString();
|
||||
var winner = existingIsIp && !peerIsIp ? peer : existing;
|
||||
var loser = winner == existing ? peer : existing;
|
||||
byEndpoint[key] = winner;
|
||||
// Move loser's selection (if any) to winner so the checkbox state survives.
|
||||
if (selectedPeerEndpoints.ContainsKey(loser.InstanceId))
|
||||
{
|
||||
redirectedSelections.Add((loser.InstanceId, winner.InstanceId));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (from, to) in redirectedSelections)
|
||||
{
|
||||
if (selectedPeerEndpoints.Remove(from, out var endpoint))
|
||||
{
|
||||
selectedPeerEndpoints[to] = endpoint;
|
||||
if (selectedPeerLabels.Remove(from, out var label))
|
||||
{
|
||||
selectedPeerLabels[to] = label;
|
||||
}
|
||||
// The "manual peer" that lost out should be removed from manualPeers too,
|
||||
// otherwise the next discovery refresh re-creates the duplicate.
|
||||
manualPeers.Remove(from);
|
||||
}
|
||||
}
|
||||
|
||||
knownPeers.Clear();
|
||||
foreach (var peer in byEndpoint.Values) knownPeers[peer.InstanceId] = peer;
|
||||
|
||||
// If a selected peer's announced address changed (DHCP renewal, network switch), follow it to
|
||||
// the new address — but conservatively. A peer reachable at two addresses at once (e.g. a VPN
|
||||
// address AND a LAN address) announces from both, and discovery reports whichever it heard
|
||||
// last. Following that blindly made the tracked endpoint ping-pong between the two; because
|
||||
// this one endpoint feeds the audio sender, the heartbeat AND the receiver's allow-list, the
|
||||
// churn was heard as crackle (Tech Singer's Win7-over-VPN report, 2026-05-31) and, when it
|
||||
// thrashed fast enough, tore the receiver's audio session down and back up quickly enough to
|
||||
// crash the app (#16, same singer). So: never move off an address that's still answering
|
||||
// heartbeats; only follow once the current one has been unreachable for a sustained spell,
|
||||
// only TO an address that is itself answering, and never more than once per cooldown. Net
|
||||
// effect — a peer you reach on a working address stays put; a genuine move (the old address
|
||||
// really went away) is still followed a few seconds later.
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
foreach (var (id, oldEndpoint) in selectedPeerEndpoints.ToList())
|
||||
{
|
||||
if (!knownPeers.TryGetValue(id, out var peer)) continue;
|
||||
selectedPeerLabels[id] = ResolvePeerDisplayName(peer);
|
||||
|
||||
var newEndpoint = new IPEndPoint(peer.Address, peer.AudioPort);
|
||||
// Same address, or the one we're on is still healthy: nothing to do — and reset the
|
||||
// unreachable-since clock so a brief future blip starts counting from zero.
|
||||
if (newEndpoint.Equals(oldEndpoint) || IsEndpointHeartbeatHealthy(oldEndpoint))
|
||||
{
|
||||
endpointUnreachableSinceUtc.Remove(id);
|
||||
continue;
|
||||
}
|
||||
// The current endpoint isn't answering. Start (or read) its unreachable-since clock, and
|
||||
// don't act on the very first unhealthy tick — wait out the grace period.
|
||||
if (!endpointUnreachableSinceUtc.TryGetValue(id, out var downSince))
|
||||
{
|
||||
endpointUnreachableSinceUtc[id] = nowUtc;
|
||||
continue;
|
||||
}
|
||||
if (nowUtc - downSince < EndpointMoveUnreachableGrace) continue; // not down long enough yet
|
||||
if (!IsEndpointHeartbeatHealthy(newEndpoint)) continue; // don't chase a dead address
|
||||
if (lastEndpointMoveUtc.TryGetValue(id, out var lastMove)
|
||||
&& nowUtc - lastMove < EndpointMoveCooldown) continue; // anti-thrash cooldown
|
||||
|
||||
selectedPeerEndpoints[id] = newEndpoint;
|
||||
lastEndpointMoveUtc[id] = nowUtc;
|
||||
endpointUnreachableSinceUtc.Remove(id);
|
||||
logFile.Event($"peer {peer.Name} endpoint moved {oldEndpoint} -> {newEndpoint} (old endpoint unreachable {(int)(nowUtc - downSince).TotalSeconds}s)");
|
||||
}
|
||||
|
||||
// Endpoints may have moved (DHCP/announcement-update path above) or selections may have
|
||||
// been redirected (manual-peer-merged-into-discovered above). Push the latest set down
|
||||
// to the receiver's allow-list so we don't keep accepting from a stale endpoint we no
|
||||
// longer recognise as a selected peer.
|
||||
PushAllowedReceiveSenders();
|
||||
}
|
||||
|
||||
private void SelectPeer(PeerAnnouncement peer) => SelectPeer(peer, fromProfileRestore: false);
|
||||
|
||||
private void SelectPeer(PeerAnnouncement peer, bool fromProfileRestore)
|
||||
{
|
||||
selectedPeerEndpoints[peer.InstanceId] = new IPEndPoint(peer.Address, peer.AudioPort);
|
||||
selectedPeerLabels[peer.InstanceId] = ResolvePeerDisplayName(peer);
|
||||
logFile.Event($"peer selected: {peer.Name} {peer.Address}:{peer.AudioPort}");
|
||||
InvalidateAutoTuneHistory();
|
||||
PushAllowedReceiveSenders();
|
||||
// fromProfileRestore=true means the call originated from auto-reconnect at startup;
|
||||
// we don't want that to flag the profile as dirty. User-initiated selects do.
|
||||
if (!fromProfileRestore) MarkProfileDirty();
|
||||
}
|
||||
|
||||
private void DeselectPeer(Guid instanceId)
|
||||
{
|
||||
if (selectedPeerEndpoints.Remove(instanceId))
|
||||
{
|
||||
selectedPeerLabels.TryGetValue(instanceId, out var label);
|
||||
selectedPeerLabels.Remove(instanceId);
|
||||
logFile.Event($"peer deselected: {label ?? instanceId.ToString()}");
|
||||
InvalidateAutoTuneHistory();
|
||||
PushAllowedReceiveSenders();
|
||||
MarkProfileDirty();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tells the receiver which sender endpoints are allowed to play audio. Same set as the
|
||||
/// peers we're sending to (the checkbox controls both directions). Called whenever the
|
||||
/// user selects/deselects a peer, and once at startup so the receiver is in a known state.
|
||||
/// Without this, anyone who can reach our UDP port (e.g. a peer who chose us first) would
|
||||
/// auto-play to our speakers — we want explicit consent via the checkbox.
|
||||
/// </summary>
|
||||
private void PushAllowedReceiveSenders()
|
||||
{
|
||||
// Accept audio from ANY source IP a selected peer is known to use, not only the single address
|
||||
// we currently target. A multi-homed sender (on a LAN and a VPN at once) can egress audio from a
|
||||
// different interface than the one we discovered or dialled; allow-listing just the one made the
|
||||
// receiver silently drop that audio while heartbeats (which skip this check) kept the peer
|
||||
// looking connected — connected but silent (#18). The SEND targets stay single-address; only the
|
||||
// accept-list widens, and only to other addresses the SAME peer (by InstanceId) announced from.
|
||||
var allowed = new List<IPEndPoint>();
|
||||
var seen = new HashSet<IPAddress>();
|
||||
foreach (var (id, ep) in selectedPeerEndpoints)
|
||||
{
|
||||
if (seen.Add(ep.Address)) allowed.Add(new IPEndPoint(ep.Address, 0));
|
||||
foreach (var addr in discovery.GetKnownAddresses(id))
|
||||
if (seen.Add(addr)) allowed.Add(new IPEndPoint(addr, 0));
|
||||
}
|
||||
receiver.SetAllowedSenders(allowed);
|
||||
}
|
||||
|
||||
/// <summary>True if the heartbeat currently considers <paramref name="endpoint"/> healthy —
|
||||
/// i.e. we're getting pongs back from exactly that address+port right now. Used to keep the
|
||||
/// audio target pinned to a proven-good endpoint instead of chasing a multi-homed peer's
|
||||
/// other (possibly unreachable) advertised address every discovery refresh. 2026-05-31.</summary>
|
||||
private bool IsEndpointHeartbeatHealthy(IPEndPoint endpoint)
|
||||
{
|
||||
if (heartbeatService is null) return false;
|
||||
foreach (var h in heartbeatService.GetAllPeerHealth())
|
||||
{
|
||||
if (h.State == PeerHealthState.Healthy && h.AudioEndpoint.Equals(endpoint))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wipes the rolling max-gap window and pushes <see cref="lastSourceChangeUtc"/> forward,
|
||||
/// so the next continuous auto-tune tick has nothing to react to. Called whenever a user
|
||||
/// action (peer (de)selection, source list toggle, manually moving the latency slider) is
|
||||
/// likely to produce a measured "gap" that doesn't reflect the network — e.g. the user
|
||||
/// reselecting localhost after a 5 s pause records a 5 s inter-arrival gap, which would
|
||||
/// otherwise pin the auto-tune to its 200 ms cap for half a minute.
|
||||
/// </summary>
|
||||
private void InvalidateAutoTuneHistory()
|
||||
{
|
||||
recentMaxGaps.Clear();
|
||||
recentRenderCbGaps.Clear();
|
||||
lastSourceChangeUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private IPEndPoint[] SelectedSendEndpoints()
|
||||
{
|
||||
// Collapse duplicates by ip:port so the same address isn't targeted twice.
|
||||
return selectedPeerEndpoints.Values
|
||||
.GroupBy(ep => $"{ep.Address}:{ep.Port}")
|
||||
.Select(g => g.First())
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>The ticked ASIO channel-pair indices in a list (parsed from the synthetic "asio:N"
|
||||
/// ids). Internal + static so the self-test can pin the per-driver tick memory round-trip.</summary>
|
||||
internal static int[] SnapshotAsioTicks(CheckedListBox list)
|
||||
{
|
||||
var pairs = new List<int>();
|
||||
for (var i = 0; i < list.Items.Count; i++)
|
||||
{
|
||||
if (list.GetItemChecked(i) && list.Items[i] is AudioDeviceChoice { DeviceId: { } id }
|
||||
&& AsioDeviceId.TryParse(id, out var pair))
|
||||
{
|
||||
pairs.Add(pair);
|
||||
}
|
||||
}
|
||||
return pairs.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Re-tick the given pair indices in a freshly rebuilt ASIO list. Pairs the new driver
|
||||
/// doesn't have (fewer channels) are silently skipped. Caller must hold suppressDeviceCheckChange.</summary>
|
||||
internal static void RestoreAsioTicks(CheckedListBox list, int[] pairs)
|
||||
{
|
||||
if (pairs.Length == 0) return;
|
||||
var wanted = new HashSet<int>(pairs);
|
||||
for (var i = 0; i < list.Items.Count; i++)
|
||||
{
|
||||
if (list.Items[i] is AudioDeviceChoice { DeviceId: { } id }
|
||||
&& AsioDeviceId.TryParse(id, out var pair) && wanted.Contains(pair))
|
||||
{
|
||||
list.SetItemChecked(i, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Address split + resolve moved to Core (PeerAddress) so the app and the service share one
|
||||
// implementation — these thin wrappers keep the existing call sites readable.
|
||||
private static Task<IPAddress?> ResolvePeerAddressAsync(string text) => PeerAddress.ResolveHostAsync(text);
|
||||
|
||||
internal static (string host, int? port) TrySplitHostPort(string text) => PeerAddress.Split(text);
|
||||
|
||||
private PeerAnnouncement CreateManualPeer(string entry, IPAddress address)
|
||||
{
|
||||
var (_, parsedPort) = TrySplitHostPort(entry);
|
||||
var label = string.IsNullOrWhiteSpace(entry) ? address.ToString() : entry.Trim();
|
||||
return new PeerAnnouncement(
|
||||
Guid.NewGuid(),
|
||||
label,
|
||||
parsedPort ?? RemPacket.DefaultPeerDialPort,
|
||||
CanSend: true,
|
||||
CanReceive: true,
|
||||
DateTime.UtcNow,
|
||||
address);
|
||||
}
|
||||
|
||||
private async Task AddManualPeerAsync(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
MessageBox.Show(this, "Enter an IP address or hostname for the other computer.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var address = await ResolvePeerAddressAsync(text);
|
||||
if (address is null)
|
||||
{
|
||||
MessageBox.Show(this, "Could not resolve that IP address or hostname.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var rememberedEntries = settings.LoadRememberedPeers()
|
||||
.Select(static value => value.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
rememberedEntries.Add(text.Trim());
|
||||
settings.SaveRememberedPeers(rememberedEntries);
|
||||
|
||||
var peer = CreateManualPeer(text, address);
|
||||
manualPeers[peer.InstanceId] = peer;
|
||||
rememberedPeerInstanceIds[text.Trim()] = peer.InstanceId;
|
||||
SelectPeer(peer);
|
||||
// New peer in remembered/manual list → tell discovery to start unicasting announcements
|
||||
// at this address so they discover us back across VPN/WAN.
|
||||
PushDiscoveryUnicastHints();
|
||||
logFile.Event($"manual peer added {address}:{peer.AudioPort} ({text.Trim()})");
|
||||
|
||||
RefreshKnownPeers();
|
||||
ApplyAudioRuntime();
|
||||
}
|
||||
|
||||
private void LoadRememberedPeersFromSettings()
|
||||
{
|
||||
// No checkboxes on the form for these — they live in the dialog. We just remember them.
|
||||
rememberedPeerInstanceIds.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a peer's identity to the persisted Remembered list (if not already present), and
|
||||
/// records the entry → instance-id mapping so the Remembered dialog can display it. Used
|
||||
/// when connecting via the Discovered list — per Ed's spec, "Remembered" is the long
|
||||
/// history of every peer ever connected to, not just manually-added ones.
|
||||
/// </summary>
|
||||
private void EnsurePeerRemembered(PeerAnnouncement peer)
|
||||
{
|
||||
var entry = string.IsNullOrWhiteSpace(peer.Name) || peer.Name == peer.Address.ToString()
|
||||
? peer.Address.ToString()
|
||||
: peer.Name;
|
||||
var existing = settings.LoadRememberedPeers().ToList();
|
||||
if (existing.Any(e => string.Equals(e, entry, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
// Already remembered — make sure the id mapping is current so
|
||||
// SyncDialogRememberedPeerList correctly hides this entry while the peer is connected.
|
||||
rememberedPeerInstanceIds[entry] = peer.InstanceId;
|
||||
PushDiscoveryUnicastHints();
|
||||
return;
|
||||
}
|
||||
existing.Add(entry);
|
||||
settings.SaveRememberedPeers(existing);
|
||||
rememberedPeerInstanceIds[entry] = peer.InstanceId;
|
||||
PushDiscoveryUnicastHints();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tells the discovery service which IPs to send unicast announcements to. LAN broadcast
|
||||
/// alone doesn't reach peers across a VPN (Tailscale, WireGuard, etc.) — so we explicitly
|
||||
/// announce to every remembered + manual peer IP on top of broadcast. Anyone in our
|
||||
/// remembered list who's running RemSound and reachable will then appear in Discovered,
|
||||
/// regardless of physical network. Sending to an offline peer is a no-op.
|
||||
/// </summary>
|
||||
private void PushDiscoveryUnicastHints()
|
||||
{
|
||||
// Snapshot the UI-thread-owned inputs HERE, then resolve hostnames OFF the UI thread.
|
||||
//
|
||||
// The comment that used to live here claimed Dns.GetHostAddresses "returns near-instantly".
|
||||
// It does for a parsed IP or an already-cached name — but for a remembered HOSTNAME that
|
||||
// can't currently resolve (an offline peer, or a Tailscale/WireGuard name while the VPN is
|
||||
// down) it BLOCKS for the system DNS timeout, seconds per entry. This method runs on the UI
|
||||
// thread on every connect / disconnect / add-peer (it's how discovery learns its VPN unicast
|
||||
// targets), so that block froze the whole window for a few seconds — which a screen-reader
|
||||
// user experiences as the entire machine locking up (issue #10). Same class of bug as the
|
||||
// v3.0.1 UPnP-on-the-UI-thread hang, in a newer feature.
|
||||
//
|
||||
// SetUnicastPeerAddresses just swaps a snapshot reference and fires an announcement, and is
|
||||
// already called from the discovery receive loop's own thread, so it's safe to call from a
|
||||
// background thread here. The hints are advisory and re-pushed frequently, so a slightly
|
||||
// stale result from an overlapping resolution is harmless.
|
||||
var seedAddresses = manualPeers.Values.Select(p => p.Address).ToList();
|
||||
var rememberedEntries = settings.LoadRememberedPeers().ToList();
|
||||
Task.Run(() =>
|
||||
{
|
||||
var hints = new HashSet<IPAddress>(seedAddresses);
|
||||
foreach (var entry in rememberedEntries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry)) continue;
|
||||
if (IPAddress.TryParse(entry, out var direct))
|
||||
{
|
||||
hints.Add(direct);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
foreach (var addr in Dns.GetHostAddresses(entry))
|
||||
{
|
||||
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
hints.Add(addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not resolvable right now — skip; re-pushed next time this method runs.
|
||||
}
|
||||
}
|
||||
discovery.SetUnicastPeerAddresses(hints);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/// <summary>After a Delete in a remembered list: focus the next item AND speak the outcome through
|
||||
/// the screen reader. The speech is load-bearing, not decoration: the next item usually lands on the
|
||||
/// SAME index the deleted one had, and the list already has focus — so no focus or selection event
|
||||
/// fires and NVDA would otherwise say nothing at all (Ed, 2026-07-26: deleting foobar gave silence).
|
||||
/// Speaks "«deleted» removed." plus the row now under focus, or that the list is empty.</summary>
|
||||
private static void FocusAndAnnounceAfterDelete(CheckedListBox list, string deletedLabel, int prevIndex)
|
||||
{
|
||||
FocusListItemAfterDelete(list, prevIndex);
|
||||
var now = list.SelectedItem?.ToString();
|
||||
ScreenReader.Speak(string.IsNullOrWhiteSpace(now)
|
||||
? $"{deletedLabel} removed. The list is empty."
|
||||
: $"{deletedLabel} removed. {now}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After deleting an item from a CheckedListBox, focus the next sensible item so NVDA
|
||||
/// announces the new selection. If something exists at the same index that the deleted
|
||||
/// item occupied, focus that (it's the next-down). Otherwise drop back to the last item.
|
||||
/// Empty list = no focus change.
|
||||
/// </summary>
|
||||
private static void FocusListItemAfterDelete(CheckedListBox list, int prevIndex)
|
||||
{
|
||||
if (list.IsDisposed) return;
|
||||
var count = list.Items.Count;
|
||||
if (count == 0) return;
|
||||
var target = Math.Clamp(prevIndex, 0, count - 1);
|
||||
list.SelectedIndex = target;
|
||||
if (!list.Focused) list.Focus();
|
||||
}
|
||||
|
||||
private void RemoveSelectedRememberedPeer(CheckedListBox list)
|
||||
{
|
||||
if (list.SelectedItem is not RememberedPeerItem selected) return;
|
||||
if (rememberedPeerInstanceIds.TryGetValue(selected.Entry, out var pid))
|
||||
{
|
||||
manualPeers.Remove(pid);
|
||||
DeselectPeer(pid);
|
||||
rememberedPeerInstanceIds.Remove(selected.Entry);
|
||||
}
|
||||
var remaining = settings.LoadRememberedPeers().Where(e => !string.Equals(e, selected.Entry, StringComparison.OrdinalIgnoreCase));
|
||||
settings.SaveRememberedPeers(remaining);
|
||||
RefreshKnownPeers();
|
||||
ApplyAudioRuntime();
|
||||
PushDiscoveryUnicastHints();
|
||||
}
|
||||
|
||||
// ===================== Mode-change warnings =====================
|
||||
|
||||
// ShowBothModeWarning + its TaskDialog retired 2026-05-11. The popup warned about the
|
||||
|
||||
@@ -1333,7 +1333,21 @@ internal static class SelfTest
|
||||
Check(RemPacket.TryReadHeartbeat(sent.AsSpan(RemPacket.HeaderSize), out var hk, out var ot)
|
||||
&& hk == HeartbeatKind.Pong && ot == 42_000L,
|
||||
"the pong must echo the originator's tick UNCHANGED — RTT (and so peer health) is computed from it");
|
||||
return "payload round-trips; ping → pong to source with originator tick intact";
|
||||
|
||||
// The Healthy → Stale → Unreachable windows, against a CONTROLLED clock (the real derivation via
|
||||
// the seam). These are the numbers every peer's armed/pruned state hangs off — app and service.
|
||||
var ep = new IPEndPoint(IPAddress.Parse("10.5.5.5"), 47830);
|
||||
var now = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
PeerHealthState At(double secondsSincePong) =>
|
||||
HeartbeatService.SnapshotHealthForTest(ep, now.AddSeconds(-secondsSincePong), now.AddSeconds(-60), 5, now).State;
|
||||
Check(At(1) == PeerHealthState.Healthy, "a pong 1s ago must read Healthy (window 2s)");
|
||||
Check(At(3) == PeerHealthState.Stale, "a pong 3s ago must read Stale (2s–5s)");
|
||||
Check(At(6) == PeerHealthState.Unreachable, "a pong 6s ago must read Unreachable (>5s)");
|
||||
Check(HeartbeatService.SnapshotHealthForTest(ep, null, now.AddSeconds(-1), null, now).State == PeerHealthState.Unknown,
|
||||
"never-answered but only just pinged must read Unknown (pending), not dead");
|
||||
Check(HeartbeatService.SnapshotHealthForTest(ep, null, now.AddSeconds(-10), null, now).State == PeerHealthState.Unreachable,
|
||||
"never-answered after sustained pinging must read Unreachable");
|
||||
return "payload round-trips; pong echoes tick to source; health windows exact (2s/5s, pending honoured)";
|
||||
}
|
||||
|
||||
/// <summary>The SPSC ring buffer is the heart of every audio path (capture mix + playout), and until
|
||||
|
||||
@@ -180,7 +180,7 @@ public sealed class HeartbeatService : IDisposable
|
||||
|
||||
private static string KeyFor(IPEndPoint ep) => $"{ep.Address}:{ep.Port}";
|
||||
|
||||
private PeerHealth SnapshotHealthLocked(PeerState p, DateTime nowUtc)
|
||||
private static PeerHealth SnapshotHealthLocked(PeerState p, DateTime nowUtc)
|
||||
{
|
||||
if (p.LastPongUtc is null)
|
||||
{
|
||||
@@ -327,6 +327,19 @@ public sealed class HeartbeatService : IDisposable
|
||||
onDiagnostic?.Invoke($"recv pong from={remote} rtt={rttMs}ms matched={matchedCount} origTickMs={originatorTickMs} nowMs={nowMs}");
|
||||
}
|
||||
|
||||
/// <summary>Test seam: run the REAL health-state derivation (SnapshotHealthLocked) against a
|
||||
/// CONTROLLED clock, so the Healthy → Stale → Unreachable windows — the numbers every peer's armed
|
||||
/// state hangs off — are pinned without waiting real seconds in a test.</summary>
|
||||
internal static PeerHealth SnapshotHealthForTest(
|
||||
IPEndPoint endpoint, DateTime? lastPongUtc, DateTime? firstPingSentUtc, int? rttEwmaMs, DateTime nowUtc) =>
|
||||
SnapshotHealthLocked(new PeerState
|
||||
{
|
||||
AudioEndpoint = endpoint,
|
||||
LastPongUtc = lastPongUtc,
|
||||
FirstPingSentUtc = firstPingSentUtc,
|
||||
RttEwmaMs = rttEwmaMs,
|
||||
}, nowUtc);
|
||||
|
||||
private sealed class PeerState
|
||||
{
|
||||
public IPEndPoint AudioEndpoint { get; set; } = null!;
|
||||
|
||||
Reference in New Issue
Block a user