228 Commits
Author SHA1 Message Date
EdnunpandClaude Opus 5 2f702e9880 Latency lab: add the ASIO-lane scenario (--latency-lab asio)
Answers Ed's "and adjusting ASIO latency also happens quickly now?" with a measurement rather
than an assertion, and without needing an ASIO device: wires the engine as BothIndependent with
only the ASIO lane active, confirms the stream lands on AsioLane, and drives the raise/lower
through the ASIO slider's own route.

Measured: 54ms -> 289ms within 10s, holds at the 330ms target, lower back to 30ms within 3s —
the same fast approach as the single slider, as expected (one hard-setter path feeding whichever
lane the moved slider owns). No product change; harness only. The shipped v5.9 build is untouched.

Also made the harness faithful to the 5.9 arming change (NoteFramesQueued against the session's
own route target, not the engine-wide one) so the ASIO case arms the way the app does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 07:25:27 +01:00
EdnunpandClaude Opus 5 1ee510bf58 v5.9: the latency control governs the streams it's supposed to, and arrives in seconds
Confirmed working on Ed's two machines before shipping (his log: slider 500 with 17ms buffered
at 07:10:31 -> 214ms at +3s -> 445 at +9s -> settled ~490 by +15s; auto-tune then walked it down
in 5ms steps with the buffer tracking; a drop to 20ms took effect immediately). Both directions,
auto-tune on and off.

Third fix in this batch, found while answering "is ASIO testing essential before we ship": the
ASIO slider only exists in BothIndependent, but MainForm pushes its persisted value at startup in
EVERY mode (and its auto-tune can tick). Now that all routes resolve to the shared value in
single-slider mode, that write would silently overwrite the visible slider with a hidden control's
number. SetMaxLatencyMs now ignores AsioLane writes when !independentLanes — in single-slider mode
the ASIO box governs nothing. Gate covers it, plus the ASIO half of the wiring (a stream on the
ASIO lane reads the ASIO slider; moving one lane doesn't disturb the other) — provable without an
ASIO device, since the change is about WHICH value a lane reads, not the driver path (untouched).

Docs: manual gains an honest paragraph on changing latency mid-listen (immediate down, a few
seconds up, the slight stretch is the change happening, no gap or click); About + RELEASE_NOTES
for 5.9; MANUAL regenerated. Version 5.9. Gate 74/74 + relay 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 07:15:31 +01:00
EdnunpandClaude Opus 5 dfdc243f03 Latency slider: one slider now means one value, and a move arrives in seconds
Two bugs behind the 2026-08-14 field report ("the latency slider does nothing" — a WASAPI-only
machine, auto-tune off, a clap test showing no change either way, while Ed's own two-slider rig
audibly works). Both found by MEASURING, not reasoning: --latency-lab drives the real playout
with realtime-paced sender/device shapes and records actual buffered depth once a second.

1. The slider wrote to a value nothing read. ReconcileReplicasLocked tags every session with an
   OUTPUT LANE (WasapiLane/AsioLane — never Mixed while a device is ticked), and the render path
   resolved its target from that lane. But MainForm.MaxLatencyBoxRoute sends the single slider to
   RenderRoute.Mixed in EVERY non-BothIndependent mode. So in classic modes the slider — and the
   auto-tune with it — updated a value the audio never consumed, leaving the real target on
   LaneLatency's 30 ms default for the whole session; raise AND lower equally inert (lower also
   matched on route, so it skipped every playing session). Only BothIndependent worked, because
   there the slider writes WasapiLane, which IS what its sessions read. That's precisely "works on
   mine, dead on his". Fix: the lane tag says which DEVICE a session renders through, not which
   knob governs it. One slider now means ONE value (sharedLatency) read by every session whatever
   its lane; two sliders only in BothIndependent, seeded from the shared value on entry so audio
   doesn't jump. Arming also uses the session's own route target (matters in BothIndependent).
   Measured, classic path: -0.15ms/s (STALLED) -> +2.07ms/s (GROWS).

2. Even correctly wired, a raise crawled. Steady-state depth feedback is capped at 0.3% (~3ms of
   catch-up per second) AND only recomputed at the 10s drift-window boundary, so a 400 ms raise
   took over two minutes and still read as a dead slider (Ed: "it needs to change the value
   quickly not over minutes"). A raise can't be met by dropping audio, only by banking it, so a
   DELIBERATE move (the hard setter — never the auto-tune's soft one, leaving its parked descent
   behaviour untouched) now engages a fast approach: 5% bias, recomputed 5x/sec, self-clearing
   within 15 ms of target. Audible glide, no gap, no click — the user asked for the change and
   hears it happen. Measured: 49ms -> 324ms within 10s, then holds.

Gate: new step pins the invariant fast and deterministically — what the slider sets IS what the
session's route reads, a lower reaches the session, and two-slider mode keeps its lanes separate.
74/74 + relay. --latency-lab (six scenarios) / --latency-lab classic (the faithful app path) stay
in the tree as the measuring harness.

NOT released. Test build for the reporting user: D:\Dropbox\remsound-latency-slider-test.zip

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:01:19 +01:00
EdnunpandClaude Fable 5 72ebfb50a8 About box shows only the newest 5 releases (screen-reader crash fix)
The About box's release notes had accumulated the ENTIRE version history back to v1.0 —
~70 KB in a single TextBox — and reading a control value that size crashes some screen
readers (user reports, 2026-08-11). The box now displays only the newest five version
blocks via a pure trim (TrimToLastVersions), ending with a plain pointer to the full
history on the GitHub releases page. The full constant stays in source as the archive.

Gate: new step pins the trim logic on synthetic notes AND the real shipped text (exactly
5 versions, size well under the crashing range — 5,516 chars vs ~70,000) so the crash
can't quietly return as releases accumulate. 73/73. Readme About section updated + MANUAL
regenerated; 5.8 notes mention the change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:35:13 +01:00
EdnunpandClaude Fable 5 e0dc2fec94 v5.8: service-folder repair — fix the 5.6 file-wedging ACL bug + record the real user through elevation
Root cause found while REPRODUCING the suspected wrong-owner bug in a gate test (the repro
failed in a way the theory couldn't explain, and the empirical icacls run showed why): the
5.6 hardening's /T sweep applied (OI)(CI) grants to FILES, where such ACEs are inherit-only
— they grant the file itself NOTHING. Every file existing at harden time was left with
/inheritance:r + inherit-only ACEs = an effectively EMPTY ACL: unreadable/unwritable by the
user, admins, even SYSTEM. That is the Jonathan report (2026-08-06) end to end: profile save
"access denied" even elevated, logs unreadable in Notepad, his own icacls /T "fix" adding
useless inherit-only ACEs (137 processed, nothing healed), new files fine (echo test), and
the service failing to start (SYSTEM can't read a wedged profile).

The fix, in layers:
- BuildServiceDirAclArgs no longer sweeps /T: the lockdown applies to the FOLDER only.
  Existing children are rebuilt by a new /reset pass (BuildResetChildrenArgs) as purely-
  inherited from the hardened folder ACL — real file access again, stale/planted explicit
  ACEs removed, and it HEALS files wedged by 5.6. Regression-pinned in the gate (the folder
  args must never contain /T again).
- Second bug fixed in the same area: the elevated helper recorded ITS OWN token as the
  "installing user" — under over-the-shoulder elevation that's the separate admin account
  whose password was typed, not the person at the keyboard. Elevated verbs now carry
  --as-user <SID> from the non-elevated app (validated: real user SIDs only — service
  identities and builtin groups rejected) and install/repair re-record it, so a stale wrong
  owner can't persist through reinstalls or self-update re-hardens.
- Self-heal everywhere: new --repair-service-access verb (re-record owner + re-harden);
  "Repair service folder access" in the Service menu; a startup write-probe that offers the
  repair when the folder is broken (settled startup sequence, ForegroundDialog, skipped on
  --silent); the profile-save UnauthorizedAccessException catch offers it at the exact wall
  users hit; and the service self-update's existing re-harden now runs the FIXED sequence,
  so wedged fleet machines heal automatically when 5.8 rolls out — no user action needed.
- Logs readable again: Users get read-only on service\logs (inheritable, no /T needed —
  propagation covers existing files) and on service-events.log. The profile stays locked
  (it holds the obfuscated password). GrantUsersWriteToBin deleted — the folder ACL's
  inherited user-Modify covers bin, and the reset wiped its explicit grants anyway.
- DoStart/DoStop no longer swallow the reason: exception recorded to service events, and
  distinct exit codes (6 timeout, 7 SCM refused, 9 repair-didn't-stick) let the dialog say
  what happened instead of the bare "(code 1)" that cost this diagnosis a day.

Gate: new "Service folder repair" step reproduces BOTH bugs for real in a scratch folder —
wedges a file with the exact 5.6 spec (proves reads genuinely die), locks the folder to the
wrong owner, asserts the app's probe reports broken, runs the exact shipped repair sequence
(ApplyServiceDirAcl), and asserts folder writable + wedged file readable again. Plus a
SID pass-through step (validation, parse, arg-building, logs-grant shape). 72/72 + relay 7.

Docs: Service-menu repair item + troubleshooting entry in readme; About + RELEASE_NOTES
rewritten for 5.8; MANUAL regenerated. Version 5.8. NOT released — awaiting Ed's test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:24:44 +01:00
EdnunpandClaude Fable 5 2cf4948bfb v5.7: back out the enforced stronger passwords (restore cross-port compatibility)
The v5.6 password change went down badly and broke the other ports: raising PBKDF2 to
600k changed the key recipe, so the SAME password produced a DIFFERENT key on 5.6 vs
every other build (the iOS/TestFlight app, older desktops) — same password, no audio.
Ed: back it out so the other ports can use it again; a password is still required, just
suggest a strong one.

Reverted:
- PBKDF2 100k again (RemSoundCrypto) — the key recipe now matches the other ports, so
  the same password derives the same key and audio flows again. Pinned by a gate check
  so an accidental change can't silently re-break cross-port compat.
- ForPlainPassword no longer refuses a weak password: ANY non-empty password derives a
  key (encryption stays mandatory — empty still means no audio). Strength is not enforced.
- Removed every enforcement/nag: the streaming-tick force-strengthen, the startup
  weak-password dialog, the status-line weak warning, the app's weak-SERVICE-password
  launch + live nags, and the service-profile save-time block. The password dialogs just
  suggest a strong password in their hint now; PasswordStrength.cs deleted (now unused).

Someone who set a strong password because of 5.6 feels no difference on update — their
password still loads and works, no prompt (Ed's requirement).

Kept (separate from "stronger passwords", not backed out): signed updates, password-
sealed remote volume, service startup volume, the update time-window, the nonce widening,
and the relay address-proof. NOTE: sealed remote-volume still needs both ends on 5.6+ —
ordinary audio does not. Flagging in case full remote-volume interop with old ports is
wanted too.

Docs: About + readme + release notes rewritten for v5.7 (plain English, positive framing,
no "must update"). MANUAL.md regenerated. Version 5.7. Gate 70/70 (dropped the now-moot
streaming-strengthening step) + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 14:01:12 +01:00
EdnunpandClaude Fable 5 cce3db8120 Relay: bump VERSION to server-v2.5 (address-proof + per-IP cap, watch-only)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:59:55 +01:00
EdnunpandClaude Fable 5 2791f855a7 Release prep v5.6: regen MANUAL.md from readme.html; gate skips Store-alias python
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:55:48 +01:00
EdnunpandClaude Fable 5 d09c47432f Dialog-focus sweep: route every background-reachable dialog through ForegroundDialog
Ed flagged this as recurring. Swept all ShowDialog call sites and fixed the ones that
can appear while RemSound is minimised in the tray (a plain ShowDialog opens BEHIND
everything there — no foreground focus, NVDA can't find it):

- Quick profile switch (QuickProfileSwitchDialog): opened by a GLOBAL hotkey, so the
  app is usually minimised when it fires. Was using an ad-hoc SetForegroundWindow that
  Windows blocks from a background process (only flashes the taskbar). Now via
  ForegroundDialog; deleted the weak BringToForeground + its P/Invoke.
- Close-time unsaved-changes prompt (OnFormClosing): fires from tray -> Exit and OS
  shutdown while minimised. Now via ForegroundDialog. (Also covers the tray profile-
  switch, which routes through the same form-close path.)
- (Service-profile dialog was fixed in the previous commit.)

Verified the rest are safe: startup notices, update notices, the service/mic/Realtek/
About warnings and the password dialogs already use ForegroundDialog; every other
ShowDialog(this) is menu- or button-driven with the app already focused (Preferences,
EQ band, rename, recording settings, profile picker, hotkey capture nested in the
menu-driven shortcuts dialog). Recorded the rule in memory so it stops recurring.

Gate 71/71 + 7 relay tests; 0 warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:40:34 +01:00
EdnunpandClaude Fable 5 6a1b4075b9 Fix: service-profile dialog must open with foreground focus (NVDA)
Ed clicked Yes on the weak-service-password nag and the service-settings dialog opened
BEHIND everything — he had to Alt-Tab to find it, and the nested change-password
dialog with it. Cause: ConfigureServiceProfile opened ServiceProfileDialog with a
plain ShowDialog(this), which does NOT surface the dialog when the main window is
minimised in the tray. The app already has the fix — ForegroundDialog (the
1x1-owner + foreground-lock dance the mic/Realtek/About warnings use) — but this
dialog bypassed it, and it's now reachable from a nag that fires while minimised.

Now routed through ForegroundDialog, so it opens front-and-centre with focus wherever
RemSound is sitting. The nested "Set service profile password" dialog already uses
ForegroundDialog, so it inherits the foreground once its parent is up (that was Ed's
second complaint — a cascade from the unfocused parent, not a separate bug).

Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:26:28 +01:00
EdnunpandClaude Fable 5 bd31eda972 Live nag for a weak service password while the app is already open
Follow-on: the weak-service-password warning fired only at app launch, so a service
that went weak underneath a running app (or that the user hasn't relaunched the app
since) wouldn't be surfaced until the next launch. Now the 1 Hz status tick also polls
it — self-throttled to at most every 15s and one-shot per app session (the flag is set
before the modal so a re-entrant tick can't stack it), sharing the same
ServicePasswordNeedsStrengthening decision and the same offer-to-open-service-settings
dialog. Costs nothing once handled or once the password is strong.

(Confirmed against the service's DIAGNOSTIC log — the strength-rule flag has been
present since the 08:20 self-update; an earlier check looked in the wrong log file.)

Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:17:01 +01:00
EdnunpandClaude Fable 5 e82268f925 Weak SERVICE password: warn from the app, since the headless service can't ask
Ed's point: the send-only service auto-updates itself to 5.6, and a pre-existing weak
service-profile password then leaves it silently not streaming — the service is
headless (SYSTEM, no window) so it can't prompt, and nobody watches its log. Users
would never know. Closed from the two places a user WILL see:

1. App launch (MaybeWarnWeakServicePassword, in the settled RunStartupNotices
   sequence): if a service is installed and its profile password is set-but-weak, a
   readable, focus-clean dialog warns and offers to open the service settings to fix
   it. Self-resolving — stops once the service password is strengthened. The person
   running the service almost always has the app (that's how it's configured), and
   the app updates around the same time the service does, so this fires right when it
   matters. Decision extracted to the pure ServicePasswordNeedsStrengthening (tested:
   installed+weak warns; not-installed / unset / strong don't).

2. Saving the service profile (ServiceProfileDialog): Save now validates first — a
   weak password pops a warning offering to set a stronger one inline (requireStrong),
   Save-anyway, or Cancel. The save button no longer auto-closes; it validates then
   closes explicitly.

readme: a sentence in the service section explaining the service password follows the
same rule and how RemSound surfaces a weak one. Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:03:49 +01:00
EdnunpandClaude Fable 5 9577fc9940 Shorten the weak-password messaging everywhere (Ed's approved wording)
The password guidance was a wall of text. Trimmed to Ed's approved phrasing, applied
consistently across all three surfaces (status line left as-is per Ed):

- PasswordStrength.Critique is now ONE line — the rule + a concrete example to copy
  ("Use at least 8 characters — three unrelated words with a number, like
  kettle9tiger42moon, works well."), no scare prose. This flows into both change-
  password-box rejection dialogs, so those shortened automatically.
- Startup warning (MaybeWarnWeakPassword): "RemSound has increased its security level,
  so this profile's password must be strengthened to meet the new password rules.
  Until it is, no audio will pass." + the one-line rule + how to change it.
- Send/Receive-tick gate (EnsureStreamingPassword): same "increased its security
  level ... strengthened before audio can flow" opener + the concise rule.

Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 11:23:27 +01:00
EdnunpandClaude Fable 5 af06929a64 Weak-password warning: a real focus-getting dialog, from the settled startup sequence
Ed's follow-up: a status line is too easy to miss for something this important — a
dialog that reliably gets focus is wanted. The earlier dialog wasn't wrong in kind,
only in timing: it fired mid-connect from the crypto path, before the window had
finished showing, so it never got clean foreground focus and NVDA couldn't reach it
(and ForegroundDialog dragged the half-shown window out of the tray).

Fix: show the warning from RunStartupNotices — the SAME proven, post-OnShown,
one-dialog-at-a-time sequence the mic-blocked and Realtek warnings use, which reach
NVDA fine. New MaybeWarnWeakPassword uses ForegroundDialog.Show (fronts even from the
tray, clean focus) and fires only when the profile is actually set to stream (send or
receive on) and its password is genuinely weak. The non-modal status-line reminder
stays as the standing prompt after the dialog is dismissed, and the user-initiated
streaming-tick gate is unchanged. Skipped on --silent/muted launches like its peers.

Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 11:10:24 +01:00
EdnunpandClaude Fable 5 027ba905e7 Fix (NVDA-critical): weak-password state must never pop a modal at startup
Ed launched 5.6 with a profile carrying the old weak "Games" password and hit a
show-stopper: a dialog he could neither read nor dismiss, and the window came up on
the connectivity screen instead of minimised. Cause: ExplainWeakPasswordIfNeeded fired
a modal TaskDialog automatically from RecomputeAudioCrypto on profile load / auto-
connect, via ForegroundDialog.Show — which also drags the window to the foreground
(hence the ignored "minimised"). A modal that steals focus mid-startup, before NVDA
can reach it, locks a screen-reader user out completely.

Fix: no automatic modal on the weak-password path. The state is surfaced NON-modally
and persistently in the status line (UpdateStatus, via the new pure WeakPasswordBlocks-
Audio) — NVDA reads it at the user's own pace, and it leads the line so it's spoken
first. The guided modal prompt is kept ONLY for the user-INITIATED streaming tick
(EnsureStreamingPassword), where the user just pressed a key so focus is clean and the
dialog is reachable; that path is already suppressed during startup profile-apply, so
there is now no startup modal at all. WeakPasswordBlocksAudio correctly stays false for
a strong password still deriving off-thread (key null, Critique null), so that transient
never shows the warning.

Test: WeakPasswordBlocksAudio pinned (weak+no-key true; empty/strong-deriving/has-key
false). Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 11:02:50 +01:00
EdnunpandClaude Fable 5 93bf1d86eb Fix: service startup-volume re-punching on every restart + misleading suspend log
Ed reported the service pulling his volume to 20% repeatedly. Diagnosis from the
service logs: NOT a runaway timer - one service process ran untouched for 36 hours,
proving there's no periodic restart. The volume-to-20 punches came from GENUINE
service restarts (deploys, the one self-update, profile saves, handovers during
active use), each re-applying because the mode was "every service restart". Two real
faults found and fixed:

1. Re-apply burst guard. "Every service restart" (and boot-only as belt-and-braces)
   now skips a re-apply within ReapplyCooldown (5 min) of the last successful apply,
   persisted as startup-volume-last.txt. This kills the double-apply we saw in the
   log at 08:20:48 then 08:21:02 (a self-update restart immediately followed by a
   follow-on start, 14s apart, both punching the volume), and any rapid churn from
   self-update / profile-save / deploy. ShouldApply gains lastApplied+now params;
   future-dated stamp (clock moved back) can't wedge it.

2. Misleading log. ServiceSendHost.Suspend() hard-coded "interactive app present" on
   EVERY suspend, including plain shutdown - which sent this very investigation
   chasing phantom app-handovers. Suspend now takes a reason; the run-loop-ending
   path says so, only a real app yield says "interactive app present".

readme: recommend "first start after boot" as the set-and-forget mode and explain
that "every restart" also fires on routine internal restarts (and is now burst-
guarded). Self-test extended: cooldown skip in both modes, 14s double-apply guard,
future-stamp safety. Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:36:54 +01:00
EdnunpandClaude Fable 5 65e999466d Test gaps 4-6: relay unit tests, updater-refusal, password walk-through
Closing the coverage gaps the review flagged as blind spots we'd be relying on at
release:

4. RELAY LOGIC TESTS. server/test_relay.py (stdlib unittest + a FakeSocket, no network)
   covers the address-proof end to end: cookie issued on join, wrong cookie rejected,
   right cookie verifies once; enforce mode WITHHOLDS forwarding from an unverified
   address then delivers after it proves itself; watch-only forwards but records
   would-block; the per-IP cap counts across BOTH v1 and v2; a NAT-rebind clears
   verification (spoof-takeover guard); a forged BYE from another address can't evict
   the victim; and bad/short/unknown-version headers are refused. Wired into
   run-tests.ps1 (Start-Process from server\, SKIPs loudly if no Python) so a relay
   change can no longer ship past the gate untested. The relay had ZERO automated
   coverage before and auto-updates every user.

5. UPDATER SIGNATURE ENFORCEMENT. Extracted the two refusal branches into a pure
   VerifyStagedRelease gate and added UpdaterRefusesUnsignedRelease: no-sig refused,
   wrong-key refused, garbage refused, tamper (good sig over changed bytes) refused,
   genuine release accepted. ReleaseSigning only proved the crypto; this proves the
   updater actually REFUSES - the hijacked-release-stream threat.

6. STREAMING PASSWORD STRENGTHENING. The accept decision is now a pure
   ProfilePasswordDialog.RejectionAdviceFor shared by BOTH password dialogs (also
   fixes the App-review trim inconsistency - manager dialog compared untrimmed). Test
   pins the load-bearing rule: requireStrong DISABLES the unchanged-exemption so an
   existing weak "Games" can't keep streaming, while casual mode still grandfathers an
   unchanged password and blocks a new weak one, trim-safe.

Plus the NVDA-hang cache assertions in PasswordRules (miss->hit, same-instance repeat,
Prewarm, empty/weak = no work).

Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:11:52 +01:00
EdnunpandClaude Fable 5 1b6402f786 NVDA hang fix: background the 600k PBKDF2 + Core session key cache
The 5.6 password-strength raise made key derivation 6x slower (~1s on old hardware,
run twice for key+fingerprint), and RecomputeAudioCrypto ran it synchronously on the
UI thread on profile load / first connect - a full NVDA freeze for a screen-reader
user. Fixed two ways:

1. Core session cache (RemSoundCrypto): ForPlainPassword now caches (key,fingerprint)
   per plaintext password for the process, with Prewarm (derive off-thread without
   returning) and IsCached (is it a hit?). The slow PBKDF2 runs ONCE per distinct
   password per process, so a profile switch back to a used password is instant. This
   also fixes the efficiency-review finding that the SERVICE re-derived un-cached on
   every audio-device hot-plug - it now hits the same cache.

2. App off-thread derive (MainForm.RecomputeAudioCrypto): the fast cases (password
   unchanged / already cached / empty / weak - no PBKDF2) still apply synchronously;
   the ONLY slow case (a strong password's first use this session, with the window
   live) derives on a worker and applies on the UI thread when done. The key is held
   null until it lands - audio simply waits (mandatory encryption never streams
   keyless) rather than the UI freezing - and a generation counter discards a stale
   result if the password changed again meanwhile. Falls back to synchronous before
   the window handle exists (startup construction).

Also: the weak-password explanation is factored into ExplainWeakPasswordIfNeeded and
its one-shot flag now re-arms on a real password change, so a SECOND weak-password
profile switched to in one session is still explained (review nit #6).

Tests: PasswordRules now pins the cache (miss->hit, same-instance on repeat, Prewarm
warms, empty/weak = no work). Gate 69/69.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:02:31 +01:00
EdnunpandClaude Fable 5 26a6fbfdf6 Pre-release hardening 1-3: nonce prefix widen + status-text dedup + stale comments
From the four-agent pre-release review (all read-only), the three items that warranted
fixing before ship:

1. NONCE PREFIX WIDENED (the one security-relevant finding). The interim counter-nonce
   used a 32-bit random prefix + 64-bit counter; since a fresh instance restarts the
   counter at 0, two instances that drew the same prefix would reuse nonces under the
   same long-lived key (catastrophic for AES-GCM), and 32 bits collides at only ~2^16
   instances. Now 48-bit random prefix + 48-bit counter: birthday bound ~2^24 instances
   while 2^48 packets/session stays far beyond any real session - strictly safer than
   both the interim scheme AND the original per-packet 96-bit-random nonce. Wire
   unchanged (receiver reads the nonce off the packet). Comment corrected.

2. STATUS-TEXT DE-DUPLICATED. The CheckedListBox spoken-status builder existed in two
   copies (CheckedListAccessibility + MainForm) with a comment falsely claiming they were
   "exact" - and they had already drifted (the remembered-apps empty-state line was in the
   MainForm copy only). Folded to ONE builder (CheckedListAccessibility.ApplyStatus +
   EmptyTextFor); MainForm delegates. NVDA wording is now identical in the main window and
   every dialog by construction. No user-visible change today; removes the silent-desync trap.

3. STALE COMMENTS. RemPacket still documented the pre-5.6 plaintext control wire format
   (2 bytes / 14 on the wire); it is now sealed (50 on the wire, the 2 bytes are inner
   plaintext). Fixed that and the MainForm.Peers.cs comment that misplaced CaptureSpecBuilder
   in Core (it is App by necessity). Docs only.

Plus review-flagged test top-ups: NonceSequence layout + two-instance-distinct-prefix
assert; ControlReceiveGuard future-dated rejection + 300-command burst (no false replay
across the prune threshold); log-rotation INTEGRITY (every line survives exactly once)
+ a 4-thread concurrency test that actually fails if writeGate is removed; ReleaseSigning
now honestly SKIPs (not caveat-PASSes) when the publisher key is absent.

Gate 69/69.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:57:25 +01:00
EdnunpandClaude Fable 5 6c53fe54d1 v5.6 batch: signed releases + stronger passwords enforced + relay address-proof
The everyone-must-update release. Four coordinated changes, each from the security
discussion Ed approved 2026-07-27, plus the remembered-apps polish:

1. SIGNED RELEASES. build-release.ps1 now signs the release zip (ECDSA P-256 /
   SHA-256, --sign-update verb) with a private key that lives ONLY at Ed's chosen
   location outside the repo; the matching public key is embedded (UpdateSignature)
   and the updater REFUSES any release whose .sig asset is missing or does not
   verify - a compromised GitHub account can no longer ship code to users. The
   signing verb self-checks against the embedded key so a key/embed mismatch fails
   the pipeline, and the gate proves the on-disk key matches the embed when present.

2. STRONGER PASSWORDS, ENFORCED (BREAKING). PBKDF2 raised 100k -> 600k (both peers
   must derive the same key, so 5.6 cannot stream with pre-5.6 AT ALL - release
   notes lead with it). New PasswordStrength rule (>= 8 chars, not an infamous
   password) enforced at EVERY door: both password dialogs block weak NEW entries
   with concrete plain-English advice; the streaming gate walks an existing weak
   password through strengthening; and ForPlainPassword - the single derivation
   choke-point shared with the service - refuses weak outright, so no path streams
   on a guessable password. Headless service logs the why. Per Ed: painful once,
   and this coordinated-update release is the cheapest moment it will ever have.

3. RELAY ADDRESS-PROOF (watch-only). The relay sends every new client address a
   random cookie and marks it verified when echoed - a forged source address can
   never echo, killing the reflection attack. 5.6 clients echo automatically
   (AddrCheck type 10, verbatim, self-limiting); the relay ships watch-only
   (logs would-blocks) until the fleet updates, then one flag (--require-addr-check)
   enforces. Per-IP entry cap (4) enforced immediately. Relay changes are committed
   but NOT deployed to the Pi - they ride the v5.6 release moment.

4. Remembered-apps empty state teaches its lifecycle + manual sentence; About/
   release notes written; version bumped to 5.6.

New gate steps: signing round-trip/tamper/wrong-key/embed-match; password rules incl.
the exact "Games" case; AddrCheck verbatim echo. Gate 69/69.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 08:19:20 +01:00
EdnunpandClaude Fable 5 9574d9d08b Update install window: automatic updates only within a chosen daily time range
Feature (requested 2026-07-26): Preferences' update settings gain 'Only install
updates within this time range' - a checkbox plus Start/End time lists in 15-minute
steps (00:00-23:45, default 01:00-06:00). RemSound streams live audio, so an
automatic update mid-session kills someone's sound; with the range on, the startup
check and the background poll DEFER an available update until the range opens (a
one-shot timer retries right at the range start, so a 24-hourly poll can't keep
missing the window for days). Manual 'Check for updates now' is deliberately never
gated - asking by hand means now. End at-or-before start wraps past midnight
(22:00-06:00); start minute inclusive, end exclusive; an equal start/end means
unrestricted rather than a silent never-install trap. The service inherits the
window for free: its self-update follows the app's install.

readme: the two new Preferences rows, the service startup-volume option, and a
security note on password-locked remote volume (both ends need 5.6).

New gate step pins the window maths (same-day, wraparound, boundaries, empty-range
rule, retry arithmetic). Dialog audit covers the new controls. Gate 66/66.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:52:21 +01:00
EdnunpandClaude Fable 5 bb94a56109 Service startup volume: unmute + set level on boot or on every service start
Feature (requested 2026-07-26): Additional service options gains 'Set the machine's
volume when the service starts' - a checkbox, a percent field (0-100, also unmutes),
and a WHEN list: 'Only the first start after each boot' (default) or 'Every time the
service starts'. For an unattended machine that boots muted or turned down, the
service makes it audible again with nobody at the keyboard; boot-only mode means a
mid-day manual service restart never blasts the volume while someone's using the box.

Mechanics: settings live machine-wide beside the service logging flag (the service
reads them fresh each start - no restart needed to change them); boot identity comes
from now-minus-uptime persisted in a marker file, so 'first start after boot'
survives same-boot service restarts and re-fires after a real reboot; the marker is
only written on a SUCCESSFUL apply, so a boot-time audio-stack race retries on the
next qualifying start. Applies via the same endpoint-volume helper the remote-control
commands use (master endpoint volume is device-global, so session 0 works). Outcome
logged to the always-on service events log.

The new round-trip test immediately caught SaveLoggingEnabled clobbering the volume
fields in the shared settings file - rewired both savers to load-modify-save. The
dialog audit picks up the new controls (mnemonics + names) automatically.
Gate 65/65.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:45:55 +01:00
EdnunpandClaude Fable 5 a46af2ee51 Resource phase 2: log rotation, streaming-scoped priority mode, crash cap, table ceiling
The four findings from the 2026-07-26 resource audit (the 'is anything abusing the
hardware over long runs' scan):

1. Diagnostic log rolls at a 50 MB cap: the current file closes with a pointer line
   and a fresh timestamped file continues the session, so a multi-day always-logging
   run produces a chain of capped files (aged out by the existing startup pruning)
   instead of one unbounded giant. Filenames gain a per-instance ordinal so rolls
   within one timestamp tick stay unique.

2. Priority mode is scoped to ACTUAL streaming: the levers (keep-awake, High
   priority, EcoQoS opt-out, fine timer, working-set lock) engage when audio is
   moving (send armed to a peer, or received audio hitting a live session) and
   release after a 30 s quiet hold-down - no flapping across brief silences. An
   idle-in-tray RemSound no longer holds the machine awake for hours. The service
   already scoped this way; the app now matches. The audio loops' own fine-timer
   scopes (the overnight-lag fix) are independent of Priority mode and untouched.

3. Crash reports are capped at the newest 10, pruned unconditionally at startup -
   nothing ever matched crash-*.txt before, so they accumulated forever.

4. The receiver's peer-security status cache gets a 256-entry ceiling (the one
   IP-keyed table with no eviction; only reachable unfiltered + WAN-exposed).

New gate step: log rolls at cap into a real file chain; crash pile pruned newest-10;
priority-scope decision matrix. Gate 64/64.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:40:13 +01:00
EdnunpandClaude Fable 5 9a18d18656 Security phase 1: sealed remote control + service-folder lockdown + counter nonces
From the 2026-07-26 security audit, the two must-fix findings plus the crypto tidy:

1. Remote-control commands are now SEALED with the profile's audio key (AES-256-GCM,
   ControlSealing in Core). Previously a 2-byte plaintext payload gated only by a
   forgeable source-IP check - anyone who learned an allowed peer's address could
   drive the receiving machine's SYSTEM volume/mute, and muting a blind user's
   machine mutes their screen reader. Now only a password-holder can issue a command;
   a ControlReceiveGuard also bounds clock skew (10 min) and remembers nonces so a
   captured packet can't be replayed to re-toggle mute. Legacy plaintext control from
   pre-5.6 peers is dropped at the receiver (never acted on) - release notes must say
   both ends need 5.6 for remote volume.

2. Cross-user LPE closed: the SYSTEM service trusts app-source.txt to decide what to
   copy+run on self-update, and ProgramData lets any user who pre-created the service
   folder own it (CREATOR OWNER inheritance) and repoint that file. Elevated install
   now records the installing user's SID, takes ownership for Administrators and
   resets the ACL to exactly SYSTEM + Administrators + installing user (takeown +
   icacls /inheritance:r). Re-asserted on every SYSTEM self-update so existing
   installs pick it up; as SYSTEM with no recorded SID it defers rather than lock the
   user out of their no-admin workflow.

3. Audio-path GCM nonces are now counter-based per lane (random 4-byte prefix +
   64-bit counter, fresh sequence with every key rebuild) - unique by arithmetic,
   removing the random-nonce birthday bound on a long-lived key. Wire format
   unchanged; the receiver reads the nonce from the packet as before.

New gate steps: sealed-control auth/replay/stale/wrong-key/plaintext matrix + nonce
discipline; service-folder lockdown args + SID recording garbage-proofing.
Gate 63/63.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:34:49 +01:00
EdnunpandClaude Fable 5 6f91e0ed5e 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>
2026-07-26 23:02:11 +01:00
EdnunpandClaude Fable 5 7d886ea910 Peer extraction stage 2: ONE send-spec builder for app + service (CaptureSpecBuilder)
The WASAPI outputs-or-apps spec assembly - the exact logic the standing 'service must
mirror the app's send behaviour' rule exists for - lived as two hand-mirrored copies in
MainForm.ApplySendSources and ServiceSendHost.BuildSendSpecs. Now ONE builder:
- Devices mode: loopback specs, 'Use Windows default' sentinel resolved to the LIVE
  default render endpoint, duplicates collapsed, resolved-default reported via out
  param (drives the app's re-route-on-default-change detection).
- Applications mode: one process-loopback spec per running PID per chosen app name.
The app layers WASAPI inputs + ASIO pairs on top; the service adds neither (by design).
Both callers rewired; behaviour identical by construction and pinned three ways: the
service parity test, the follower test (now also pinning the out param, follower+
explicit-device dedup to one spec, and not-running apps contributing nothing), and the
main-window profile round-trip.

Gate 61/61.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:51:21 +01:00
EdnunpandClaude Fable 5 1b7fb47cd3 Peer extraction stage 1: arming logic shared in Core (PeerArming)
The 'which selected peers do we actually stream to' rule lived as near-identical
private copies in MainForm.RefreshAudioReceivers and ServiceSendHost (prune peers
unreachable past the grace window - issue #8 - re-arm on recovery, change-detect via
an ordered signature). Now ONE Core home: PeerArming.ComputeArmedEndpoints with the
app's actively-receiving carve-out injected as a predicate (asymmetric-path streams
are never cut), plus the shared order-independent Signature. Both sides delegate;
behaviour identical by construction. Reachability test extended: carve-out honoured,
false predicate doesn't rescue a dead peer, signature order-free. Gate 61/61.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:48:41 +01:00
EdnunpandClaude Fable 5 e3bf28a414 P2 leftovers: every inline dialog now audited + one shared sample clamp (review complete bar peer extraction)
Inline-built dialogs were invisible to the accessibility audit - which is exactly how
mnemonic-less buttons kept slipping through (the single-instance dialog Andre caught,
and now TWO more found by this very change: ManualPeerPrompt's OK/Cancel and
ProfileSaveAsPrompt's Cancel had no mnemonics - both fixed). Each inline dialog's
construction is split from its ShowDialog into a Build seam, and the audit now covers
15 dialog surfaces (was 8): manual peer prompt, quick profile switch, change password,
password manager, profile name prompt, and the service Additional-options window join
the eight Form dialogs. Behaviour unchanged.

The encoder-boundary clamp is now ONE shared rule (Core SampleClamp) instead of three
private copies in MixingEngine / AsioCaptureBackend / PushModeWasapiBackend - and the
ASIO copy's up-to-four interlocked increments per frame became one batched add per
buffer on the RT thread. Pinned by a new self-test (over-range clamps to exactly +/-1
and counts; +/-1 exactly passes untouched).

Gate 61/61.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:38:40 +01:00
EdnunpandClaude Fable 5 e2211f8a40 Announce the outcome of a Delete in both remembered lists through the screen reader
Deleting a remembered app (or peer) was SILENT to NVDA: the next row lands on the same
list index the deleted one had and the list already has focus, so no focus/selection
event fires - nothing to announce (Ed, 2026-07-26: deleting foobar said nothing).

FocusAndAnnounceAfterDelete now follows the focus move with a Tolk line through the
existing ScreenReader channel (the house pattern for feedback the screen reader can't
observe): '<deleted row> removed. <row now under focus>.' - or that the list is empty.
No dialog, no popup; applied to the remembered applications AND remembered peers lists
so the two stay identical.

Gate 60/60.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:27:55 +01:00
EdnunpandClaude Fable 5 64c92a67bb ASIO switch: per-driver tick memory + fully async driver release (Ed's EVO->ReaRoute->EVO)
Diagnosis from Ed's 2026-07-26 desktop log: (1) the 'hang' was the EVO taking 5+s inside
its own Stop - the 8s bound caught it and unfroze, working as designed but still FELT;
(2) the silence returning to EVO was 18s of 'start requested but no sources configured' -
switching drivers rebuilds the pair list and clears ticks (deliberately: pair N is a
different physical channel on a different card), so nothing was ticked when he returned.

Fixes - completing the architecture rather than patching it:
- Per-driver tick memory: before the swap clears ticks, the outgoing driver's ticked
  pairs (send + receive) are remembered BY DRIVER NAME; returning to that driver
  restores its own ticks after the lists rebuild and re-applies audio, so it resumes
  by itself. Safety preserved: driver A's ticks never bleed onto driver B; pairs a
  smaller driver lacks are skipped. Snapshot/restore pinned by a new self-test.
- Async release: the UI thread no longer WAITS for the old driver to close at all.
  The old backend's callback is unhooked immediately (volatile write - it stops
  feeding the lanes before the new driver starts), then Stop/Dispose runs on a
  worker where the apartment's 8s bound still backstops a wedged driver. A slow
  close now costs nothing perceptually. Known accepted edge (documented): re-picking
  the SAME driver inside the close window can find the card still held; picking it
  again recovers.
- The once-per-second no-sources line is now transition-only (18 identical lines in
  Ed's log buried the signal).

Gate 60/60 incl. the ASIO-enabled churn: 52 transitions over the real Audient driver,
handles flat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:11:19 +01:00
EdnunpandClaude Fable 5 517a066923 Shared homes + legacy sweep (review Phase 4)
Duplication that had to agree by luck now has ONE home each:
- PeerAddress (Core): the host[:port] split + resolve-preferring-IPv4 that lived as
  byte-identical private copies in MainForm and ServiceSendHost (plus an extra resolve
  copy). Both now delegate. (The unicast-hints path deliberately keeps its own loop -
  it collects ALL IPv4 records, a different behaviour, not a duplicate.)
- RemSoundCrypto.ForPlainPassword: key + fingerprint always derived together - the
  divergence that once got the service's audio silently rejected can't recur.
- AudioTransportRules.EffectiveOpusFrameSamples moved to Core; the headless service no
  longer reaches into the WinForms MainForm for a frame-size rule.
- ServiceAudioDefaults (Core): the service's fixed live-jamming transport numbers,
  referenced by BOTH the dialog that writes the profile and the host that re-forces
  them at runtime - they can no longer disagree.

Legacy sweep:
- HandleTypeProbe + its per-minute tick retired (the 2026-06 handle-leak investigation
  it was built for is closed; git history has it).
- RemSound.Harness project removed from the tree + solution (superseded by --selftest).
- Fixed the stale AppConfig claim that per-profile hotkeys are 'no longer read' (the
  one-time v4.4 import reads them once); Profile.AudioPort now carries an explicit
  DEAD FIELD / trap warning backed by the persistence tripwire test.

Gate 59/59.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:17:41 +01:00
EdnunpandClaude Fable 5 9f13ef3aae Robustness: bounded ASIO close, native service self-update, rollback pinned (review Phase 3)
- AsioApartment gains a bounded Invoke; the close path uses it (8s cap - a healthy
  close is ~5ms). A driver that wedges inside Stop/Dispose can no longer hang a live
  driver-switch or the resume path: the caller logs the timeout and abandons the
  driver (old park semantics, reclaimed at process exit). Apartment test extended to
  pin the timeout path.
- Service self-update is now NATIVE code: a detached copy of the NEW build runs
  --service-selfupdate (stop -> CopyProgramTo -> start, every step logged to the
  update log). The old PowerShell restart script silently died wherever Group Policy
  enforces execution policy (Bypass is ignored there), stranding the service on the
  old build. Verb wired through IsServiceInvocation + ServiceEntry; verb-gate test
  now pins all six verbs.
- UpdateApplier SwapInNewFiles/RollBack made internal + pinned by a real-folder test:
  swap lands exactly the release's files (user files untouched), backup holds the
  originals, rollback restores BYTE-EXACT including deleting newly-created files -
  the contract that stops a failed update bricking an install.
- RouterPortMapper: discovery callback now checks disposed under the gate, so an
  in-flight callback can't re-open the port map Dispose just removed.

Gate 59/59.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:10:37 +01:00
EdnunpandClaude Fable 5 e356ee46dd Tests: persistence tripwire + cue resolution + audit/gate hardening, group 3 (review Phase 2)
- Profile persistence TRIPWIRE: reflection over every public writable Profile field -
  each must round-trip ApplyProfile->CopyTo or appear on an explicit declared list
  (control-owned or legacy, each with its reason). Add a field without wiring it and
  this FAILS naming the field - closing the three-parallel-lists drift trap for good.
  The declared list is itself checked against real properties so it can't rot.
  (Immediately proved itself: enumerated the 14 legacy v4.4 hotkey fields.)
- Cue variant resolution: 'Sound N' labels, numeric ordering, the drop-bare-when-
  numbered dedupe (the duplicate-row NVDA trap), chosen-default honoured + fallback
  when the chosen file is gone.
- Accessibility audit now includes UpdateInstallNoticeDialog (it was the one
  Form-derived dialog missing from the audit list).
- Skipped self-test steps are now LOUD: the summary names every skipped step and
  run-tests.ps1 prints a yellow warning - a gate pass with steps skipped no longer
  reads as full coverage.

Gate 58/58.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:04:38 +01:00
EdnunpandClaude Fable 5 354d2b4fdb Tests: receiver security gates + wire integrity, group 2 (review Phase 2)
Four more zero-coverage areas, all headless:
- PcmFrameAssembler: byte-exact in-order assembly; out-of-order/oversize/zero-part
  rejected AND counted; half-finished frames counted as discards; recovers after
  every failure. (A mis-assembly = audible corruption while packets still flow.)
- Receiver allow-list gate (SECURITY): a Format packet from a non-allowed sender is
  rejected + counted before any session opens; an allowed ADDRESS passes at any
  source port. Nothing previously proved the gate end-to-end.
- Receiver decryptor (SECURITY): wrong-password audio yields EMPTY (silence), never
  garbage; no key = everything drops (encryption is mandatory); right key round-trips
  byte-exact.
- Heartbeat: payload wire format round-trips, and a ping injected into the service
  produces a pong back to the SOURCE endpoint with the originator tick unchanged -
  the number RTT (and so every peer's Healthy/Unreachable state) is computed from.

Receiver gains the same InternalsVisibleTo(RemSound) bridge Sender/Core already had.
Gate 56/56.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:57:15 +01:00
EdnunpandClaude Fable 5 9627f320ca Tests: audio-correctness + infra pinning, group 1 (review Phase 2)
Five subsystems that had ZERO coverage - all pure code where a real bug would ship
green because the flow tests only prove packets move, not that bytes are right:
- AudioRingBuffer: wrap correctness, overflow drops OLDEST, DropOldest/TrimFromProducer
  semantics, silence-fill + exact underrun/drop counters, oversized-write degrade.
- PcmPack 24-bit codec: +/-1-LSB round-trip incl. negatives (a sign-extension bug =
  distortion on the whole PCM path), sign preservation, over-range clamps not wraps.
- RemSoundUpdater tag filtering: server-v* relay tags rejected, numeric ordering
  (v5.10 > v5.9), suffix tolerance - what stops a wrong-version auto-update.
- RouterPortMapper.IsCgnatAddress: RFC 6598 boundaries exact (made internal for test).
- NetworkPriority.TryAttach: no-throw smoke on a bound socket.

Gate 52/52.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:51:57 +01:00
Ednunp 527c2976c9 Repo hygiene: remove the stale 'pi server' copy (v2.3; 'server' at v2.4 is canonical - a fix cut from the stale copy would revert the forged-BYE security fix), rescue its two unique design docs into server/, and gitignore the untracked personal pi-sender folder 2026-07-23 15:47:38 +01:00
Ednunp 81d19c7b8c Discovery: optional display-name override for headless senders (announce a friendly name instead of the hostname; null keeps the machine name, so the app is unchanged) 2026-07-23 15:47:38 +01:00
EdnunpandClaude Fable 5 b010525bce Review bug batch: Delete key on remembered apps (#26), phantom service cues, suppress-flag guards
- Remembered applications list now supports the Delete key (issue #26): deleting an
  entry forgets it machine-wide, mirroring the remembered-peers list exactly (same
  focus-next-item behaviour). Logic lives in RemoveRememberedApplication so the
  self-test drives the real path; the send-app semantics test now covers deletion.
  Manual documents Delete on BOTH remembered lists (neither was documented).
- Service dialog's Additional options no longer offers connect/disconnect cue
  checkboxes: the headless service never plays cues (nothing in it touches CuePlayer,
  and a logged-out session couldn't render them), so the switches did nothing.
  Manual sentence updated to match.
- Three re-entrancy suppress flags (send-mode reset x2, streaming password gate) now
  clear in try/finally - a throw can no longer leave list events suppressed for the
  rest of the session.

Gate 47/47.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:47:38 +01:00
EdnunpandClaude Fable 5 cf1eb92f11 Fix the two remaining UI-thread elevation freezes (profile save + installer)
Review finding, same class as the shipped install-hang fix: two paths still ran the
elevated service helper synchronously on the UI thread, freezing the window and live
audio for the duration (worst case minutes across two UAC prompts).

1) Service-profile save (MainForm.ConfigureServiceProfile): saving while the service
   runs did RunElevated(stop) + RunElevated(start) inline. Now: the restart runs on a
   background task, and tries a NO-UAC restart first - ServiceControl.TryRestartNoAdmin
   uses the start/stop rights the installer grants the installing account, so the
   normal case has no elevation prompt at all. Elevated verbs remain the fallback
   (service installed by a different account). Success is silent; only a failed
   restart reports back. The save popup now says the service is restarting.

2) App installer's optional service step (AppInstaller): the install + start-now calls
   ran RunElevated inline. The flow is sequential (can't fire-and-forget - the installer
   relaunches and exits afterwards), so RunElevatedResponsive runs the helper on a
   worker while a small modal "working..." shell pumps messages: UI and audio stay
   live, nothing can be double-triggered, NVDA announces the step, and the exit code
   still returns inline.

Test: "No-admin service restart fails safe" - TryRestartNoAdmin against a missing
service returns false promptly without throwing (that false routes callers onto the
elevated fallback). The success path needs the real SCM + grant, covered by hand-test.
Gate 47/47.

Part of the review-fix batch; no release until the whole plan lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:40:30 +01:00
EdnunpandClaude Opus 4.8 97c96b4fcb Release prep v5.5: version bump, About changelog, release notes
- csproj <Version> 5.4 -> 5.5.
- About box: prepend the v5.5 entry (UPnP close-hang fix + keyboard shortcuts on
  every dialog).
- RELEASE_NOTES.md rewritten for v5.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 09:39:46 +01:00
Ednunp a3b8f1659b UPnP: breadcrumb before the router teardown calls so a hang names itself in the log 2026-07-18 09:21:58 +01:00
EdnunpandClaude Opus 4.8 8fe52047be Fix UPnP close-hang, and add mnemonics to every dialog's buttons (Andre)
UPnP close hang:
- RouterPortMapper.Stop()/Dispose() does a SYNCHRONOUS DeletePortMap call to the router
  (plus StopDiscovery). When the router is slow or unresponsive that blocks — and it ran on
  the UI thread in FormClosing, so enabling UPnP could make RemSound impossible to close
  (Andre, this morning; his logs show UPnP teardown taking several seconds even on a good
  run). Moved the router teardown into the same bounded background-task pattern already used
  for the ASIO audio dispose: UPnP + audio now tear down off the UI thread, in parallel, under
  one 3s cap. Anything unfinished is reclaimed on process exit, so the window always closes.

Mnemonics on dialog buttons:
- Andre flagged the "RemSound is already running" dialog (single-instance) having no shortcut
  keys. Its three TaskDialog buttons had no & mnemonics; added Alt+S / Alt+F / Alt+C.
- Swept every dialog. Custom TaskDialog buttons and Form OK/Cancel/Close buttons that lacked
  mnemonics now have them: single-instance (Switch/Force/Cancel), save-onto-read-only
  (Save/Cancel), Add EQ band, Change/Manage profile password, Quick profile switch (Close),
  Rename peer (OK), About (Close). Cancel stays on Esc in the few dialogs where Alt+C is
  already taken (Rename peer's Clear, Service profile's peers list) — by design, not a miss.
- The rest already had mnemonics (Keyboard-shortcut import, Update-install notice, Profile
  selection, Recording settings). The dialog-accessibility gate (mnemonic uniqueness + names)
  stays green, so nothing collides.

Gate 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 09:17:49 +01:00
Ednunp a433645fd4 Refresh MANUAL.md from readme.html for v5.4 2026-07-18 00:20:09 +01:00
EdnunpandClaude Opus 4.8 44883af542 Release prep v5.4: version bump, About changelog, release notes
- csproj <Version> 5.3 -> 5.4.
- About box: prepend the v5.4 entry (Use Windows default output + exclusivity, the
  service-install freeze fix + start-after-install, service now outputs/apps only).
- RELEASE_NOTES.md rewritten for v5.4.

(The manual, readme.html, was already updated incrementally as each change landed.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:19:58 +01:00
EdnunpandClaude Opus 4.8 27353a3f41 Service: drop the WASAPI inputs (mic) list — outputs and apps only
The service streams a machine's OUTPUT audio (or specific apps); capturing a mic from an
unattended, logged-out box isn't a use case (Ed). Removed the "WASAPI audio inputs to
send" list from the service config dialog, and made the service ignore inputs everywhere:

- ServiceProfileDialog: removed the inputs CheckedListBox, its status label, its row and
  accessibility wiring. Save now clears SelectedWasapiSendInputs so a profile saved by an
  older build can't keep a mic streaming.
- ServiceSendHost.BuildSendSpecs: removed the input-spec loop — a legacy profile's inputs
  are deliberately ignored.

(The main app is unchanged: it still has its WASAPI inputs list and AudioDeviceCatalog
.LoadInputs stays for it.)

Test: the service-parity test now adds a mic to the profile and asserts BuildSendSpecs
emits only per-application specs and never a CaptureKind.Input. Gate 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:10:36 +01:00
EdnunpandClaude Opus 4.8 0102aa51f4 Service: offer to start it right after installing (uninstall already stops first)
- After a successful install, both entry points now ask "start it now?" (Yes/No):
  the app's Service menu (RunServiceVerbAsync reused for the follow-up start) and the
  app installer's optional service step. Without this the service only comes up at the
  next boot, so a first-time user saw nothing happen after installing it.
- Uninstall already stops the service before deleting (DoUninstall -> DoStop, which
  waits up to 15s for Stopped), so nothing to change there - confirmed, not added.
- Manual: note the start-now prompt in the service section.

No new automated test: these are UI Yes/No prompts layered on the already-tested
start/stop verbs. Gate 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:02:04 +01:00
EdnunpandClaude Opus 4.8 aa5707f8e1 Fix the service install hang (pipe deadlock) + stop it from ever freezing the app
Ed uninstalled then reinstalled the service from the app and the app "kind of crashed":
its audio froze while the connection stayed alive. The logs told the whole story - the
app logged "install requested" then never "install finished"; the elevated helper
(child of the app) was still running 40+ minutes later, and the app sat blocked on it,
UI thread frozen, so streaming died but the background heartbeat kept ticking.

Root cause - a classic pipe deadlock in the elevated installer. GrantUsersWriteToBin ran
`icacls /T` over the service bin (100+ files) and read STDERR to end, THEN stdout. icacls
floods stdout far past the ~4 KB pipe buffer, so it blocked writing stdout while we blocked
reading stderr - forever. That hung DoInstall, which hung the app waiting on it.

Fixes (root cause + defence in depth, so a stuck helper can never freeze the app again):
- RunProcessCaptured: one safe process runner that drains stdout AND stderr concurrently
  (async), bounded by a timeout, and kills the child (whole tree) if it overruns. RunSc,
  RunScCapture and GrantUsersWriteToBin all go through it now. This kills the deadlock.
- RunElevated now waits with a 120s cap and returns ElevatedTimedOut instead of blocking
  forever.
- ServiceAction runs the elevated helper OFF the UI thread and reports the result back, so
  even a slow/stuck helper can't stall the window or its audio. New "timed out" message.
- Program.cs Environment.Exit()s after a one-shot service verb, so a helper that finished
  its work can never linger (non-background thread) with the app waiting on it.

Test: "Elevated helper: no pipe deadlock on flooded output" - RunProcessCaptured against a
child that floods both pipes with ~260 KB (a big dir listing + a failing dir); it must
return promptly with the full output. The pre-fix order would have hung. Gate 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 23:56:26 +01:00
EdnunpandClaude Opus 4.8 5fc1b5bd75 "Use Windows default" is now exclusive: it clears + locks out the specific cards
Ed: ticking "Use Windows default audio device" should uncheck the other cards and stop
them being ticked again until the default is turned back off - in both the app and the
service. This replaces the old optional "shall I untick the others?" prompt with a hard
rule.

Behaviour (all follower lists: app received-outputs, outputs-to-send and inputs-to-send;
service outputs):
- Ticking the follower unticks every specific card in that list.
- While the follower is on, trying to tick a specific card is refused (the ItemCheck is
  vetoed straight back to unticked) - you're either following the Windows default or
  picking cards, never both.
- Unticking the follower frees the cards again.
- Enforced on load too, so a hand-edited/legacy profile carrying both comes up clean.

Shared, not duplicated: the veto + clear + follower-checked helpers live in
AudioDefaultFollower and are used by MainForm and the service dialog alike.

Removed the now-obsolete soft prompt and everything that hung off it:
- MainForm.MaybeUntickOthersForDefault + ResetDefaultAudioDevicePrompt.
- The "Reset the default audio device prompt" Options item (and its Alt+O,R).
- AppConfig.UntickOthersWhenUsingDefaultOutput/Input.
- Manual + About-box references to the prompt / reset item; the manual now documents
  the exclusive rule and lists all three follower lists (the outputs-to-send follower
  had been undocumented).

Test: "Default follower exclusivity" self-test - follower on clears + locks specific
cards, follower off frees them, unticking and the follower entry itself are never
vetoed. Gate 45/45.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 23:26:38 +01:00
EdnunpandClaude Opus 4.8 4a86a82ec8 Service: offer "Use Windows default output" (Christopher's request), reusing the app's follower
The service profile could only pick named output devices, so it couldn't "just send
whatever this machine plays and follow the Windows default". The main app already had
exactly that (its DefaultLoopbackSendFollower + ResolveDefaultDeviceId) - so rather
than invent a parallel mechanism (which would silently diverge), this pulls the shared
piece out and has the service reuse it.

- New AudioDefaultFollower: one home for the loopback-send sentinel
  ("__use-default-loopback-send__"), the follower list entry, and the default-endpoint
  resolver. MainForm now references it (its DefaultLoopbackSendFollower and
  ResolveDefaultDeviceId delegate to it) so there is a single definition.
- Service config dialog: the Audio send tab lists "Use Windows default audio device,
  follows Windows changes" as the first output choice. Ticking it persists the same
  sentinel the app uses.
- ServiceSendHost.BuildSendSpecs resolves that sentinel to the LIVE default render
  endpoint (with de-dup against explicitly-ticked devices), never passing the raw
  sentinel through. Because the service re-applies its profile on every device-change
  notification - and OnDefaultDeviceChanged is one of them - it FOLLOWS the default:
  change Windows' default output and the service switches to it within a beat.
- Manual: documents the new option in the service section.

Test: new "Default-output follower" self-test - follower is flagged + shares the app's
sentinel, and the service resolves it to the current Windows default render endpoint
(never leaks the raw sentinel into a capture spec). Gate 44/44.

The ASIO "Rea" devices Ed noticed are real registry drivers (Realtek ASIO + REAPER's
ReaRoute), not injected dummies - we only ever list HKLM\SOFTWARE\ASIO. No code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 23:08:55 +01:00
Ednunp 43e249e90e Refresh MANUAL.md from readme.html for v5.3 2026-07-17 17:33:03 +01:00