Commit Graph
109 Commits
Author SHA1 Message Date
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 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 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 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
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
EdnunpandClaude Opus 4.8 984bcd042e Review sweep: fix real bugs found across the service + per-app + settings changes
Parallel code review of this session's changes surfaced several real bugs; fixed the
substantive ones (judgment/cleanup calls held for Ed):

- HIGH Clearing "remembered peers" was resurrected on the next launch: the per-profile
  -> global migration re-ran every startup and re-unioned the profile file's stale copy.
  Added a one-time AppConfig.RememberedPeersMigrated marker so migration runs once and a
  cleared list stays cleared. Self-test pins the clear-then-reload scenario.
- MED PushModeWasapiBackend.Start rethrew on a device-open failure; nothing up the stack
  wraps it, so device churn (a push-eligible single WASAPI source unplugged mid-open)
  could crash the app. Now logs and stays stopped like MixingEngine/ASIO; the device
  watcher / self-heal re-open when a device returns.
- MED Service self-heal: (a) the re-open "no send sources" path left PerformanceMode ON
  and presence up while streaming nothing - now releases cleanly; (b) the 3-attempt
  ladder never refunded, so 3 hiccups over a days-long stint meant permanent silence -
  now refunds when real audio is heard, and resets on a device hot-plug.
- MED ApplyProfile resolved peers (DNS) and enumerated devices INSIDE the gate lock -
  a boot-time DNS hang as SYSTEM stalled Suspend()/yield/self-heal. Moved outside the lock.
- LOW AudioSessionStartWatcher leaked the AudioSessionManager on every Rehook (the WASAPI
  handle-leak fingerprint) - now disposed. New lifecycle self-test.
- LOW stale docstrings (send-all master toggle; ServiceUpdate in-place scheme; Profile
  .SendAllApplications "neither reads nor writes").

Gate: 42/42 (added peers-migration + session-watcher-lifecycle tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:49:01 +01:00
EdnunpandClaude Opus 4.8 5dfc4faec6 Service: remove the confusing Update-service menu item; make bin folder user-writable
Ed: a menu item a user has to know when to click is the wrong answer -- real users get
the service via the automatic self-update (version bump), full stop. Remove the menu item.

For developer/tester same-version refreshes, honour "stop it and update it": install now
grants Authenticated Users Modify on the service bin folder (icacls), so once the service
is stopped its binaries can be replaced with no admin -- the exact workflow Ed asked for.
Same trust posture as the auto-update copy (user-writable content run as SYSTEM); noted
for a future code-signed hardening.

The --update-service verb / DoUpdate stay as internal plumbing (no user-facing entry).

Gate: 40/40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:09:48 +01:00
EdnunpandClaude Opus 4.8 fa554cbe35 Service: one-click Update-service-to-this-version (no uninstall/reinstall)
Because the service now runs from its own admin-only copy, getting a new build into it
meant uninstall+reinstall (two UAC prompts). Add a DoUpdate path + Service-menu item
that does it in one: stop -> overwrite the ProgramData bin copy from the running app ->
start. Wired as the --update-service verb (elevated), recognised by the service-verb
gate. Enabled only when the service is installed; falls back to a plain install if not.

Note: the service bin folder stays admin-only writable on purpose (a user-writable
SYSTEM binary would be a privilege-escalation hole), so the copy is done elevated via
the one UAC prompt rather than in-process.

Gate: 40/40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:55:14 +01:00
EdnunpandClaude Opus 4.8 a450cae66a Lock-to-clock always on (remove option); service audio hardcoded to Opus live config
Ed, 2026-07-17. The bigger half of the service crackle fix: raw PCM sounded hideous;
Opus at the live-jamming frame with Small packets and lock-to-clock sounds right.

Lock to audio clock (TightLatencyMode) is now ALWAYS ON and no longer a user option:
- Removed the checkbox from the main window and the service dialog. Nobody ever runs
  with it off -- off just adds delay.
- Main app forces the sender tight at startup; Profile.TightLatencyMode defaults true
  and is kept only for file compatibility. Load/SaveTightLatencyMode accessors removed.

Service audio profile is now fixed and unconfigurable:
- Removed the "Audio profile" tab from the service config dialog entirely (two tabs
  left: Connectivity, Audio send).
- The service FORCES Opus + 2.5 ms frame (120 samples) + Small packets + lock-to-clock
  at runtime in ServiceSendHost.ApplyProfile, ignoring whatever the profile carries, so
  a stale/hand-edited profile can never put it back on a bad codec. SaveToProfile writes
  the same fixed values.

Robustness: ApplyProfile now swallows a capture Start() failure (with lock-to-clock
always on the WASAPI lane is push-mode, which opens the device synchronously and throws
if it is invalid/gone) -- presence stays up and the device-watcher/self-heal re-open when
a device is available, instead of the exception crashing the service loop.

Self-test "Service sender parity" rewritten to prove the service overrides a PCM profile
to Opus/120. Gate: 39/39.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 12:38:29 +01:00
EdnunpandClaude Opus 4.8 1003bbfdf2 Per-app send fixes + apps-mode UI rework (no send-all) + truly-global remembered lists
Per-app capture (the "foobar alone = no sound" report):
- ProcessLoopbackCapture: take ActivateAudioInterfaceAsync's out operation as a raw
  IntPtr (released after completion) instead of a typed interface. The eager RCW cast
  of the not-yet-realised operation object threw InvalidCastException / E_NOINTERFACE
  and killed EVERY specific-app capture at start.
- CompositeCaptureBackend: single IsPushEligible authority shared by Start,
  UpdateSources and the coalesced rebuild. The update paths were missing the
  ProcessLoopback exclusion, so switching whole-device -> one app kept the push
  backend and fed it "proc:<pid>" (GetDevice ArgumentException).
- PushModeWasapiBackend: loud backstop rejecting process-loopback specs.
- Self-test: lifecycle test now FAILS on an activation error (it previously passed
  green with the feature completely dead) + pure routing-rule checks.

Apps-mode UI (Ed 2026-07-16): the "Send all applications" checkbox is GONE from the
main window - applications mode always means picking specific apps; whole-system
audio is devices mode's job. Active list = running apps + any ticked app that is not
running ("(not running)" so it can be unticked); Remembered list = global address
book minus whatever is ticked. In-place list reconcile (no Clear+rebuild) kills the
NVDA double-read of the toggled row. Profile.SendAllApplications stays for the
SERVICE (deliberate divergence - a headless lock-screen sender wants system audio).

Remembered lists now genuinely machine-wide (AppConfig-backed): the settings store is
an intra-process cache, so remembered applications were forgotten on every exit and
remembered peers were per-profile in practice. Both books moved to AppConfig; legacy
per-profile peers are unioned in on profile load; profile save snapshots the global
book back for old-build compat. Cross-instance persistence pinned by self-test.

Service (issue #23): 15s capture pulse (callbacks/bytes/pre-encode peak/frames sent)
while sending - distinguishes "endpoint mix is genuinely silent at the lock screen"
from a pipeline fault, which callbacks alone cannot.

Gate: 38/38.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:50:38 +01:00
EdnunpandClaude Opus 4.8 18b079ba3a Send-app UI: 'Send all applications' is now the first row of the list
Per Ed: instead of a separate 'Send all applications' checkbox that hides the
list, that toggle is now the FIRST ROW of the active-applications list. Ticked =
send the whole system and the individual apps collapse away; untick it and the
running apps appear beneath it (the remembered list shows too). Backed by the
now-hidden sendAllApplicationsCheckbox as state; a reserved sentinel row name
routes the first-row toggle to it. One scrollable control instead of a checkbox
plus a list. Coverage audit + profile round-trip still clean; gate 37/37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:00:17 +01:00
EdnunpandClaude Opus 4.8 d9c78a5956 Two app lists in the main window: Currently active + Remembered
In WASAPI 'applications' send mode you now see two checkbox lists, mirroring the
peers lists:
- Currently active applications (Alt+8) — apps running right now.
- Remembered applications (Alt+9) — the global remembered-apps address book,
  including apps that aren't running (shown '(not running)').

Both drive ONE shared send set (selectedSendApps): tick an app in either list
and it's marked to send; the other list re-renders to match. Tick a remembered
app that's closed and, the instant it goes live, it appears ticked in the active
list AND capture begins from its start (the session-start watcher). Unticking in
either removes it. selectedSendApps is the source of truth (= per-profile
SelectedSendApplications on save); both lists render their checkboxes from it.

Layout: inserted the remembered list at row 11, shifted the send-input rows down.
Reconcile now rebuilds both lists; capture change-detection and profile
round-trip unchanged. Coverage audit now 42 controls, mnemonics + tab order
clean. Gate 37/37. Live two-list sync + capture wants on-machine testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:29:11 +01:00
EdnunpandClaude Opus 4.8 e95fc36926 Remembered lists are global; add Clear buttons in Preferences
Remembered peers were already machine-wide for the main app; remembered apps are
now global too (a shared address book across all profiles), so it doesn't confuse
the user with a different list per profile.

- RemSoundSettingsStore.Load/SaveRememberedApplications (global, NOT written into
  profile files) mirrors remembered peers. Removed the per-profile
  Profile.RememberedSendApplications. Ticking an app in any profile adds it to the
  global list (RememberCheckedApps); the per-profile SelectedSendApplications
  (the active/ticked subset) is unchanged.
- Preferences -> General now has two buttons at the end, under a 'Remembered
  lists (shared across all profiles)' header: 'Clear remembered peers list...'
  and 'Clear remembered applications list...', each with a Yes/No confirmation.
  Clearing peers refreshes the main window; clearing apps empties the global list.
- Self-test 'Remembered applications list is global + clearable' (round-trip,
  case-insensitive dedupe, clear); the dialog accessibility audit covers the two
  new buttons (names + mnemonics, no clash). Gate 37/37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:54:55 +01:00
EdnunpandClaude Opus 4.8 1e5030b08f Catch a per-app send from its very start (instant capture-on-open)
Fixes the bug where a ticked app that launched AFTER the profile loaded was
never captured: the 3s reconcile only redrew the list and only ran while you
were looking at it, so it never restarted the audio capture.

Now:
- AudioSessionStartWatcher (RemSound.Sender): hooks the Windows audio-session-
  created notification on the default render device, firing the instant an app
  opens an audio session (i.e. about to make its first sound). Re-points on a
  default-device change. Best-effort; falls back to the poll.
- RefreshSendAppCapture: re-applies the send sources whenever the ticked apps'
  running process ids change, driven by both the session watcher (instant) and
  the reconcile poll (backstop) — and the poll now runs regardless of which tab
  is showing, so a remembered app is caught even when you're not on the list.
- Profile.RememberedSendApplications added (persisted; the two-list UI that
  uses it lands next).
- Self-test 'Send-app capture change-detection': the pure signature is stable
  while nothing changes and flips when a ticked app opens/closes. Gate 36/36.

Live behaviour (real apps + the session notification) needs on-machine testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:54:27 +01:00
EdnunpandClaude Opus 4.8 5129aa2a39 Always-on service events log + show failure reasons on screen
So that if the service breaks on a machine where nobody enabled logging first
(e.g. a Win7 tester), we still learn WHY:

- New always-on ServiceStore.AppendServiceEvent -> service-events.log in
  ProgramData, independent of any logging toggle (like the update log).
- Service menu status-query failure now shows the actual reason IN the status
  line (a screen reader reads it) AND records it to the events log, instead of a
  bare 'status unavailable' whose reason only went to the off-by-default app log.
- ServiceAction (install/uninstall/start/stop) records request + outcome to the
  events log.
- ServiceEntry (the elevated child) logs each verb's start, and CATCHES a
  System.ServiceProcess load failure to record its reason before rethrowing (so
  the crash file still gets the full stack) — this is the likely Win7 failure.
- 'View service log' falls back to the events log when no runtime diagnostic log
  exists, so there's always something to view after any service interaction.

Recap of coverage: hard crashes -> crash-*.txt (always, no setup). Graceful
service failures -> now the events log + on-screen reason (no setup). Gate 35/35.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:08:49 +01:00
EdnunpandClaude Opus 4.8 4d1815e071 Show the Service menu on all Windows (let Win7 try the service)
Removes the Windows-10+ version gate on the Service menu so a Win7 user can
actually attempt Install/Configure and we can see whether the service works
there. Still launch-safe: building the menu references no service type (inlined
const verbs + method-group handlers), so System.ServiceProcess is not loaded at
window construction on any OS — verified by the 'main window builds without
loading the service assembly' self-test and an empirical 0-modules launch check.
The assembly loads only when the menu is opened (status query) or an action runs,
both try/catch-wrapped, so on Win7 it degrades to 'status unavailable' rather
than crashing. Re-gate with IsWindowsVersionAtLeast(10,0) if Win7 can't run it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:59:11 +01:00
EdnunpandClaude Opus 4.8 c482857314 Fix the actual Win7 launch crash (issue #22): empty send-mode list
The real cause — from the tester's crash stack, not my earlier (wrong)
System.ServiceProcess theory: ApplySendModeVisibility() is called early in the
MainForm constructor (via ApplyAsioMode), BEFORE the Input/Output tab populates
the send-mode ListBox. On Windows 7 process-loopback is unsupported, so it took
the `!supported` branch and set SelectedIndex on an EMPTY list, throwing
ArgumentOutOfRangeException ("value ('0') must be less than '0'") and crashing
at launch. On Windows 10/11 that branch is skipped (process-loopback IS
supported), which hid it from the gate and from every Win10/11 test.

.NET 10 genuinely runs on the tester's Win7 SP1 box (CoreLib 10.0.826 in the
crash) — so the earlier assembly-load theory was wrong; those Win7 changes were
addressing a non-problem. This is the fault.

Fix: guard the SelectedIndex set with `sendModeList.Items.Count > 0`. When the
list is empty there's nothing to reset (it's created selecting Devices, and this
runs again once the tab is built).

Test: "Main window builds where process-loopback is unsupported (issue #22)" —
forces ProcessLoopbackCapture.IsSupported = false (new ForceSupportedForTest
seam) and constructs the headless MainForm, so a Win10/11 box exercises the Win7
path. VERIFIED it reproduces: with the guard reverted the test fails with the
exact tester exception; with the guard it passes. Gate 34/34.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:18:30 +01:00
EdnunpandClaude Opus 4.8 0567ea22c3 Restore Win7 launch safety: version-gate the Service menu, don't probe
The capability PROBE (added earlier for feature-detect) constructed a
ServiceController during window construction, which loaded System.ServiceProcess
on EVERY launch. On Win11 that's harmless, but on Win7 it meant the app was
attempting that risky load at startup, surviving only on a catch — too fragile
for a guarantee. Verified empirically: a normal launch was loading
System.ServiceProcess.ServiceController.dll again.

Fix: decide Service-menu visibility with a Windows-VERSION check
(OperatingSystem.IsWindowsVersionAtLeast(10,0)) instead of a runtime probe. A
version check touches no service type, so the service assembly is NOT referenced
at launch on any OS. On Win7/8 the menu is hidden and that assembly is never
loaded; on Win10+ it loads only later, if the user actually opens the Service
menu (already wrapped in try/catch). Feature-detecting on Win7 and keeping Win7
launch-safe are mutually exclusive (you can't test the load without doing it),
so we choose guaranteed launch safety.

- Deleted ServiceCapability (the probe) — now unused.
- Replaced its self-test with "Main window builds without loading the service
  assembly (Win7-safe)": constructing the main window must not load
  System.ServiceProcess. Verified empirically too: normal launch now loads 0
  service modules (was 1).

Gate 33/33.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:03:17 +01:00
EdnunpandClaude Opus 4.8 91f5fc74c0 Add "View service log" (diagnostic activity log), distinct from update log
Tester couldn't see anything under "View service update log" -- that item only
shows the SELF-UPDATE log (written when the service updates itself to a new
version), so with no update it's empty. What he actually wanted is the service's
ACTIVITY log, which records why the service is or isn't sending
("streaming N sources to M peers", "profile has no WASAPI send sources",
"no reachable peers"), but there was no way to open it.

- New Service menu item "View service log" opens the newest diagnostic log in
  ProgramData\RemSound\service\logs. If none exists it explains that service
  logging must be enabled first (Configure service profile -> Logging), so the
  path to getting a log is discoverable.
- ServiceStore.LogsDirectory + NewestLogFile() to locate it (same absolute path
  for the SYSTEM service and the interactive user).
- Clarified the update-log "nothing yet" message to point at the new item.

This also unblocks diagnosing the pre-login send issue: enable service logging,
reproduce, then View service log to see the exact reason.

New self-test "Service log discovery": folder resolves under the service dir,
newest .log is chosen, empty case handled. Gate 32/32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:18:18 +01:00
EdnunpandClaude Opus 4.8 e3e57affb4 Fix Service menu shortcut: Alt+J, not Alt+S (Send owns Alt+S)
Alt+S didn't open the Service menu because the always-visible "Send my audio"
checkbox already owns Alt+S, and a visible control beats a top-level menu for
the same Alt key. Every letter in "Service" (S/e/r/v/i/c) collides with a
control (Send / Receive / Volume / Connected peers / EQ / ...), so the menu
now uses Alt+J -- unused anywhere in the main window, so it opens reliably from
every tab. Same approach the Record menu already uses ("Record (Alt+&K)").
Kept Send on Alt+S (frequent control) rather than moving it.

New self-test "Menu shortcuts don't clash with controls": builds the main
window and asserts no top-level menu mnemonic collides with any control
mnemonic. The existing coverage audit couldn't catch this -- menu items are
ToolStripItems, not Controls, so its control walk never saw them. Confirms the
other four menus (File/Record/Options/Help) were already clean. Gate 31/31.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:17:39 +01:00
EdnunpandClaude Opus 4.8 2dad00c42f Offer the service on Win7+ by feature-detecting, not version-gating
Replaces the hardcoded "Windows 10 or newer" gate on the Service menu with a
runtime capability probe (ServiceCapability). It tries to load the .NET
service machinery (System.ServiceProcess) and offers the Service menu wherever
that succeeds -- including Windows 7 if the service layer loads there, which is
what Ed asked for ("install on Win7 or above"). If the assembly can't load on
an older/unsupported Windows, the probe catches it and the menu is simply
hidden, so such a machine can never be crashed by it.

Safety by construction: the reference to the service types lives only in the
NoInlining Probe method, so the assembly load is triggered by the CALL from
IsAvailable (inside its try/catch) and is catchable -- not during JIT of the
caller, which would be fatal. The startup path stays service-type-free via
ServiceEntry, so this probe runs only at window construction, never at launch.

Probe uses the parameterless ServiceController ctor: it forces the assembly to
load but touches no service and no SCM. (First cut read .ServiceName on a
made-up name, which actually queries the SCM and threw "service not found" --
the self-test caught that it was hiding the menu on Windows 11 too.)

New self-test "Service capability probe": available + cached + never throws on
the Win10/11 gate runner. Gate 30/30.

Note: this makes the service INSTALLABLE on Win7 wherever the layer loads; it
does not prove the service RUNS there (Session-0 capture on an unsupported
runtime) -- only a real test on the Win7 box can confirm that.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:55:16 +01:00
EdnunpandClaude Opus 4.8 9392cc1fcc Fix Win7 launch crash: keep service types off the startup path
Reported: RemSound no longer launches at all on Windows 7 since the
send-only service was added. Cause: RemSoundService derives from
ServiceBase (System.ServiceProcess), and Program.Main called
RemSoundService.RunAsService() directly in its body. The runtime resolves
every type a method names when it JIT-compiles that method -- so the moment
Main was compiled, at the very start of every launch and before any argument
was read, it force-loaded System.ServiceProcess. That assembly won't load on
Windows 7 under the .NET 10 runtime, so Main failed to compile and the app
died with no window. net10 has always been the target, so this was a pure
regression from the service work, not a framework change.

Fix:
- Move the whole service-verb dispatch into a separate ServiceEntry class.
  Program.Main now only calls it (a) after a cheap check that uses only
  inlined const verb strings, and (b) only when a service verb is actually
  present. A normal launch never JIT-compiles anything that names a service
  type, so System.ServiceProcess is never loaded. Verified empirically:
  a normal launch loads 107 modules, none of them System.ServiceProcess.
- Gate the Service menu to Windows 10+ (OperatingSystem.IsWindowsVersionAtLeast),
  mirroring how the "capture individual apps" feature is gated. On Win7/8 the
  menu isn't shown and no service code is reachable. Made the status-query
  handler defensive too, so a query failure can never crash the menu.

New self-test "Service verb gate": normal launches (no args, --silent,
--profile, --connect, --minimized, --config-dir) are never treated as a
service invocation; all five service verbs are recognised case-insensitively;
and deciding a normal launch loads no service assembly. Gate 29/29.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:41:56 +01:00
EdnunpandClaude Opus 4.8 26903950f7 Add "auto save non-read only profiles" preference (silent)
New global setting in Preferences -> General, right after "Browse for
profiles folder": a list "Auto-save non-read-only profiles" with Never /
Every 2 / 5 / 10 / 15 / 20 / 30 minutes. When enabled, RemSound periodically
saves the current profile, but ONLY if it is a real saved profile, is NOT
read-only, and has unsaved changes -- and it saves SILENTLY (no save cue,
no confirmation), so it never interrupts the user.

- AppConfig.AutoSaveNonReadOnlyMinutes (machine-wide, 0 = Never = default).
- SaveProfileTo gains a playCue flag; auto-save passes playCue: false.
- MainForm autoSaveTimer + ApplyAutoSaveTimer + ShouldAutoSave guard,
  re-applied live when the setting changes in Preferences.
- New self-test "Auto-save non-read-only profiles": options list, AppConfig
  persistence, the guard (read-only / blank / unchanged all skipped), and the
  timer turning on/off. Gate 28/28.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:32:17 +01:00
EdnunpandClaude Opus 4.8 4b2ecca960 Service: always-on update log + "View service update log" menu item
Local checkpoint - NOT for public release. Ed wants logging for the service updating.

- New ALWAYS-ON update log at ProgramData\RemSound\service\update.log (not gated on the
  service-logging toggle - updates are rare but important). It records the full sequence:
    * "update detected: newer RemSound.exe (5.3) found, running 5.2 - restarting"
    * the restarter's own "stopping" / "service started" (or "START FAILED - <reason>")
    * "update complete: now running version 5.3"
  The restart PowerShell writes its stop/start outcome itself, so the part that runs AFTER
  the old service process is gone - and any failed start - is still captured. A pending
  marker set at detection and consumed on the next start closes the loop (its absence flags
  a stuck update).
- Service menu gains "View service update log" to open it (friendly message if none yet).

Update log + pending-marker round-trip covered by the isolation self-test. Gate 27/27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:59:40 +01:00
EdnunpandClaude Opus 4.8 d5767050f2 Service: show the running version in the Service menu (so a self-update is visible)
Local checkpoint - NOT for public release. Answers "how will we know if the service
updated itself?"

- The service records its version + start time on every OnStart into ServiceStore
  (ProgramData). A self-update restart therefore bumps the version and refreshes the
  start time.
- The Service menu status line now shows it: "Service: installed, running — version 5.3,
  running since 2 min ago". So you can see the running version at a glance, and a recent
  start with a bumped version is the self-update landing.
- Also logged on start ("service: OnStart, version X") for the service log.

Status round-trip covered by the isolation self-test. Gate 27/27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:51:59 +01:00
EdnunpandClaude Opus 4.8 941e0df99f Service: log install/uninstall/start/stop/configure to the RemSound log
Local checkpoint - NOT for public release. ServiceAction and ConfigureServiceProfile
now write to the main app's log (gated on RemSound logging), so turning on logs in
RemSound captures the install request + result code and profile saves. (Windows also
records the install itself in the System Event Log, Event 7045, always.) Gate 27/27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:30:55 +01:00
EdnunpandClaude Opus 4.8 4f5265d8b0 Service: isolate the profile in ProgramData, out of all normal profile paths
Local checkpoint - NOT for public release. Ed: the service profile must never be
reachable except through the Service menu.

Also fixes a real bug: the service runs as SYSTEM, whose per-user data folder is NOT the
interactive user's - so a profile saved in the user's profiles folder (or AppConfig, both
per-user) was invisible to the service. It would have idled, never streaming.

- New RemSound.Core.ServiceStore: the service profile + its settings (logging) live in a
  MACHINE-WIDE ProgramData\RemSound\service location - same absolute path for the user
  (config dialog) and SYSTEM (service). Moved ServiceProfileName/ServiceLoggingEnabled off
  AppConfig (per-user) onto this store.
- ServiceSendHost.FromConfig + RemSoundService now read ServiceStore; ConfigureServiceProfile
  saves there (and migrates + deletes any profile left in the old user-folder location).
- Because it's no longer in the user's profiles folder, it can't appear in the startup
  picker, File->Open, Recent profiles, or the password manager (all of which read the user
  ProfileStore); the reserved-title filter in ListProfileTitles stays as belt-and-braces.
- Password button renamed "Set service profile password".
- New self-test "Service profile isolation": store is under ProgramData, the reserved title
  is filtered from the listing, and it round-trips through the machine-wide store.

Gate 27/27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 08:23:10 +01:00
EdnunpandClaude Opus 4.8 78a9aa6572 Service: fix divergences from the main app's send path
Local checkpoint - NOT for public release. Audited ServiceSendHost against MainForm's
send path (Ed: make the service reuse the same code, be just as stable). Three real
divergences found and fixed:

1. ENCRYPTION FINGERPRINT (critical): the host set sender.AudioKey but NOT
   sender.AudioFingerprint. The main app (RecomputeAudioCrypto) sets both, and the peer
   verifies the fingerprint before accepting a stream - so the service's encrypted audio
   would have been REJECTED at the far end. Now derives and sets both from the password.
2. OPUS FRAME: the main app applies EffectiveOpusFrameSamples (the "Small" send rate
   halves the Opus frame); the host passed the raw frame, so it would encode differently
   than the main app for the same profile. Now reuses MainForm.EffectiveOpusFrameSamples
   (made internal - same code, not a copy).
3. PEER PORT: send target fell back to the profile's LOCAL AudioPort; the correct default
   is RemPacket.DefaultPeerDialPort (what the main app's manual-peer path uses). Same value
   today but the right constant.

Reviewed and OK: sender defaults to WasapiOnly (no SetAudioMode needed); BuildSendSpecs
matches ApplySendSources for explicit-device profiles; default-device changes are covered
by the device-change watcher; direct-send (no relay/StartReceiving) is the intended v1 scope.

New self-test "Service sender parity" asserts key + fingerprint + effective Opus frame
match the main app. Gate 26/26.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:13:48 +01:00
EdnunpandClaude Opus 4.8 91b3f0c1c1 Service: menu before Options + start-after-audio dependency
Local checkpoint - NOT for public release.

- Service menu now sits before Options in the menu bar (File / Record / Service /
  Options / Help), per Ed.
- The service is registered with depend= Audiosrv/AudioEndpointBuilder, so Windows
  starts it the instant the audio services are ready at boot (before login) - the
  earliest point WASAPI capture can find any audio. It CANNOT start before the audio
  services (there'd be no endpoints to capture, and no sound exists before audio is up),
  so this is the earliest useful start. DoInstall now uses the single BuildCreateArgs
  source of truth.

Gate 25/25.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:34:34 +01:00
EdnunpandClaude Opus 4.8 a52f08c188 Tests: functional profile round-trip through the real main-window controls
Local checkpoint - NOT for public release.

Toward Ed's "every control and every function tested" goal: a new self-test applies a
profile to the ACTUAL main-window controls (via an internal ApplyThenCaptureForTest seam
on the headless form) and reads it straight back, asserting every persisted value
survived - volume, mute, send/receive toggles, peer-shaping master, send mode, send-all-
applications, and the selected applications. This exercises each control's load AND save
logic, not just that it exists (which the accessibility audit already covers).

The apply path pops the "set a password to stream" dialog when enabling send with no
password (would hang a headless test); the seam sets the existing suppressStreamingPasswordGate
around the apply, same gate the app uses internally.

Gate 25/25.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:16:08 +01:00
EdnunpandClaude Opus 4.8 c8fed26115 Tests: headless main-window coverage (all tabs + controls) + fix Alt+L clash
Local checkpoint - NOT for public release. Closes the UI-coverage gap Ed pushed on.

- MainForm gets a `headless` ctor flag (defaults false → real startup path byte-for-byte
  unchanged). When true it builds the WHOLE window — every tab, control and menu — but
  skips the OS touches: global-hotkey registration, the status/device-refresh timers, the
  device-change notifier, and the audio-backend mode switch in ApplyAsioMode. The
  disruptive startup work (Connect + sockets, UPnP, update check) already lives in the
  Shown handler, which never fires when a test constructs the form without showing it — so
  a headless construction is naturally side-effect-free.
- New self-test "Main window coverage": constructs the headless main window and audits the
  lot — accessible names present, Alt mnemonics unique per group, tab order forms no cycle.
  4 tabs, 41 interactive controls.
- It immediately EARNED ITS KEEP: caught a real Alt+L collision — the WASAPI latency and
  ASIO latency labels both claimed Alt+L in the same panel, so in WASAPI+ASIO mode the ASIO
  field was unreachable by its shortcut. Moved ASIO latency to Alt+I; WASAPI keeps Alt+L.

Gate 23/23.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:59:09 +01:00
EdnunpandClaude Opus 4.8 f4551517e5 Service: config dialog + Service menu
Local checkpoint - NOT for public release.

- ServiceProfileDialog: modal 3-tab editor (Audio send / Audio profile / Connectivity)
  reusing the house controls, with a Save and Close / Cancel / Additional options
  button row. Send-only: send-mode chooser + WASAPI outputs/apps + inputs (no "Send
  my audio" toggle, no receive, no ASIO); codec/rate/lock-to-clock; peers add/remove +
  a Set password button. Additional options sub-dialog = play connect/disconnect sound
  + enable service logging. Edits a Profile clone; returns it on OK.
- Service menu in MainForm: status line + Configure / Install / Uninstall / Start / Stop,
  items enabled per live state on drop-down. Install/uninstall confirm then elevate.
  Configure saves the reserved service profile, points AppConfig at it, stores the
  machine-wide logging choice, and restarts a running service to pick up edits.
- ProfileStore.ReservedServiceProfileTitle ("RemSound service"): the service profile
  lives with normal profiles (so the service Loads it) but ListProfileTitles hides it
  from every picker. ServiceControl reuses that single constant.
- Self-test: the service dialog is now in the accessibility audit (constructs cleanly,
  unique mnemonics, all controls named). Gate 20/20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:27:37 +01:00
EdnunpandClaude Opus 4.8 e648bee531 Lock-screen service: spike + headless send host + app-yield coordination
Local checkpoint - NOT for public release. First increment of the send-only
Windows service feature (design in memory/project_remsound_service).

THE SPIKE PASSED: the send engine runs fully headless (no window, no message
pump) and streams, proven by a real self-test — the one genuine unknown that
gated the whole feature. Also proves the app-yield model end to end.

What's in this increment (all headless, all tested, 19/19 gate):
- AppConfig: ServiceProfileName + ServiceLoggingEnabled (machine-wide).
- InteractivePresence (Core): the cross-session app-yield token. App holds a
  Global\ mutex for its lifetime; the service checks it and yields while an
  interactive app is present, resuming when it closes OR crashes (OS frees the
  mutex). Name-parameterised internal seams for isolated testing.
- ServiceSendHost (App): loads a send-only profile and streams it to its peers,
  WASAPI-only, no ASIO/receive. ApplyProfile/Suspend/Resume + a RunLoop that
  drives them from the presence token with a settle delay. v1 sends to direct
  peer addresses (LAN/port-forwarded); NAT/relay discovery stays the app's job.
- Program.cs: the interactive app now acquires the presence token at startup so
  a future service yields to it.
- Tests: "Service app-yield token" (held=present, released=absent) and "Service
  send host (headless stream + yield)" — streams a captured device to a local
  receiver over loopback, verifies start/suspend/resume, then drives the full
  RunLoop against the token (held=suspended, released=resumes-and-flows).

Still to come (later increments): the --run-service entry + Windows-service
registration, the Service menu, the 3-tab config dialog, updater integration,
docs. None user-facing yet, so nothing deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 14:59:31 +01:00
EdnunpandClaude Opus 4.8 ffeba4ad2b Fix hard crash when toggling ASIO while sending a specific app
Local checkpoint - NOT for public release.

Repro (Ed): in applications send mode with an app being captured, turning the
ASIO driver off hard-crashed the process. No log and no crash-report file were
written - and MixingEngine.DisposeEntry swallows managed exceptions - which points
to a native access violation, not a managed throw.

Cause: the ASIO toggle rebuilds the capture backend (ApplyAsioMode -> ApplyAudioRuntime
-> ApplySendSources), which disposes the live ProcessLoopbackCapture. The old design
released the WASAPI COM objects from the disposing thread while the capture thread
could still be inside a native GetBuffer call - a classic use-after-free / AV.

Fix: the capture thread now owns the ENTIRE COM lifecycle. It activates, runs, and
releases every COM object itself, in its finally, only after the loop has exited.
StopRecording/Dispose merely signal and join (2s) - they never touch the COM objects.
If the thread ever wedges in a native call we leak it rather than free from outside
(a rare bounded leak beats a hard crash). The thread is also explicitly MTA, and
activation moved onto it, so the async-activation callback can't stall the UI thread.
bufferReady is volatile and only disposed once the thread has genuinely exited.

Also: ApplyAsioMode force-sets the WASAPI send-list visibility for the new mode, which
resurrected the loopback-outputs list in applications mode; re-assert ApplySendModeVisibility
at the end so the correct list stays shown after an ASIO toggle.

New self-test "Per-application capture lifecycle" runs real start/stop/dispose cycles
of the native capture against our own process on hardware - a bad teardown would AV
and fail the gate. Gate: 16/16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 11:23:18 +01:00
EdnunpandClaude Opus 4.8 f8a42f2806 Per-app send: UI on the Input/Output tab, saved per profile
Local checkpoint - NOT for public release. Builds on the engine core commit.

Ed's revisions to the plan: the send-mode chooser lives on the Input/Output tab
(not Preferences), right after "Send my audio", and the setting saves per profile.

Input/Output tab:
- New "How to send WASAPI audio" listbox (Alt+6) after the send checkbox, with
  two rows: send whole sound devices (classic) or send specific applications.
  Switching it swaps the tab live between the loopback-outputs list and the app
  section. Hidden entirely on Windows too old for process loopback (mode pinned
  to devices), so nothing changes for Win7.
- Applications section: "Send all applications" master checkbox (Alt+7, ticked by
  default = whole system audio, same as today) and an "Applications to send"
  checked list (Alt+8) shown only when the master is unticked.
- All house controls (AccessibleCheckBox, MnemonicLabel, WireCheckedListAccessibility),
  accessible names + Alt-shortcut suffixes, tab order slotted in.

Behaviour:
- App list reconciles on a 3s timer while visible: apps appear/disappear as they
  open and close, ticks preserved by process NAME, and a ticked app that closes
  stays in the list marked "(not running)" and resumes when it reappears.
- ApplySendSources: devices mode unchanged; applications mode sends either the
  default-render loopback (send all) or one process-loopback spec per running
  process of each ticked app. WASAPI mics and ASIO run alongside in both modes.
- Process-loopback sources are excluded from the single-source push-mode fast
  path (it opens an MMDevice; a "proc:<pid>" id has none) - they go via MixingEngine.
- "Is anything being sent" status/tray gates account for applications mode.

Persistence moved from AppConfig to Profile: WasapiSendMode / SendAllApplications /
SelectedSendApplications. Profile round-trip self-test extended. Gate: 15/15.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:53:58 +01:00
EdnunpandClaude Opus 4.8 8a7c4ddf2b Multi-output fan-out, offline-marker fix, #19, and per-app send engine core
Local checkpoint - NOT for public release.

Fan-out (every received stream to every selected output, no added latency):
- SessionPlayout mirror replicas fed the same decoded bytes; delicate ReadFloats untouched.
- PlayoutEngine reconciles replicas per active output lane; per-route tuner
  aggregation keeps lanes from disturbing each other. Recording dispatch gated
  to the recording route only.
- PeerDspChain.Clone() gives each output lane independent biquad state.
- ReceiverSelfChecks.FanOutToBothOutputs proves both lanes get audio (self-test).

Offline-marker pile-up fix:
- ResolvePeerDisplayName strips the " (offline)" marker before reuse, so a ghost
  peer no longer compounds the suffix hundreds of times in the status line.

Issue #19 (Use-Windows-default follower in the loopback send list):
- DefaultLoopbackSendFollower resolves to the current default render device's
  loopback spec, re-applied when the default changes.

Per-application send engine core (issue #20) - WASAPI-only, Win10 19041+ gated:
- CaptureKind.ProcessLoopback + ProcessLoopbackId ("proc:<pid>").
- AudioAppEnumerator: snapshots apps with audio sessions, tracked by process
  name, releasing every session object each pass so nothing piles up.
- ProcessLoopbackCapture: IWaveIn over the process-loopback activation API
  (hand-rolled COM interop; NAudio has no binding). Fixed 48k/float/stereo.
- CaptureSource IWaveIn overload; MixingEngine opens "proc:<pid>" sources with
  no MMDevice and no render keepalive. ASIO path untouched.
- Self-test enumerated real apps on hardware; support gate verified.

UI (Preferences device/app mode + app checklist), ApplySendSources app specs,
and the reconcile timer are still to come. Gate: 15/15.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 05:58:33 +01:00
EdnunpandClaude Opus 4.8 2fb9274a95 v5.2: stability and polish — deep-audit bug fixes + install-flow fixes
Verified findings from a multi-dimension code audit, plus the two install-flow bugs:
- Fix Opus encoder use-after-free on a codec/rate change while streaming (guard swap vs encode).
- Fix "both" single-file recording dropping audio + drifting (drain both directions in lockstep).
- Fix broken clip counter, UPnP teardown on exit, auto-update-restart foreground grant, and a
  malformed-Opus-format packet orphaning a playout session forever.
- Post-install relaunch now respects start-minimised; uninstall is path-aware so it won't clear a
  different copy's run-at-startup.
- Perf/hygiene: cache AppConfig off UI hot paths, fold per-peer EQ+gain into one pass, deterministic
  disposal (tray menu, timers, COM shortcut, Process handles, process meter), ring-buffer overflow
  guard, receiver session-lock fix, remote-control allow-list moved onto the UI thread.
- Remove dead code (two IsAsioBackend, SessionPlayout.Reset, IsSameEndpoint, RemSoundUpdater
  IDisposable); several stale-doc fixes.

Deferred (not in this release): drift-estimator tweak, peer-discovery pruning, uninstall retry-loop,
encryption nonce. Wire format unchanged (interops v3.3-v5.1). Version -> 5.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:54:53 +01:00
EdnunpandClaude Opus 4.8 7038fef67c v5.1: install RemSound as a proper Windows app, plus smaller sounds
New Options -> Install / Uninstall RemSound on this PC: a per-user self-installer
(%LOCALAPPDATA%\Programs\RemSound, no admin) with optional desktop + Start-menu
shortcuts, login auto-start (reuses StartupAutoStart), Windows Installed-apps
registration, and copy-across of profiles+config, recordings and logs. Install
state is decided by a marker file, not a folder-path guess; the post-install
relaunch hands over foreground via AllowSetForegroundWindow so the installed copy
comes to the front; uninstall uses a batch remover (no PowerShell) and confirms
with two independent tick-boxes. All new dialogs use the house accessible controls
(AccessibleCheckBox, Theme.Heading).

Also: iOS (TestFlight) companion link alongside Android in README + manual;
slimmed-down default cue WAVs; About/RELEASE_NOTES/manual updated; version -> 5.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:45:21 +01:00
EdnunpandClaude Opus 4.8 aeca24ae17 v5 pre-release: multi-track drift fix, more self-tests, logging, CLI, version bump
Multi-track recording drift fix (Ed's question — can the separate tracks drift over an hour?):
 * Root: FlushPeerTracks skipped a peer that produced no samples in a render block, so a peer that
   went quiet long enough for its session to be pruned (>4 s idle) would have its track fall behind
   and desync. Now every peer track is padded to a full render block each cycle (silence when the
   peer produced nothing), so all peer tracks stay sample-locked to the single render clock — they
   can't drift apart however long the recording runs, and all end the same length. Same padding for
   the single-file bypass path. OnRecordBlockComplete now carries the block's float count.
   (The peer tracks are already resampled to the render clock per peer, so this makes peer-to-peer
   sync exact; your own "me" track is capture-clocked — same soundcard for capture+playback = same
   clock = no drift, different interfaces can drift slightly.)

 * Self-test: two new steps — "Per-peer shaping DSP" (PeerDspChain unity/master-off/volume/parametric
   + ParametricToPeaking) and "v5 settings and shaping round-trip" (AppConfig defaults, NamedPeers,
   MainTabOrder, parametric PeerShaping, recording default = Both).
 * Logging (gated by the logging checkbox): master shaping switch, EQ-mode change, parametric band
   add/delete, peer rename/clear/delete, and the applied Appearance settings after Preferences close.
 * CLI: --list-profiles and --list-named-peers (read-only), in --help.
 * Version bumped to 5.0; About-box changelog, RELEASE_NOTES.md and README updated for v5.

Build clean; --selftest passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:19:01 +01:00
EdnunpandClaude Opus 4.8 fcc3cdc793 Ctrl+1..9 to jump to a tab by its current position (main window + Preferences)
Ctrl and a number selects the Nth tab as it currently appears — positions are live, so they follow
the user's tab reordering and the pan/EQ tab's show/hide. Handled in ProcessCmdKey on both the main
window and the Preferences dialog; focuses the tab strip afterwards so NVDA reads the new tab (like
Ctrl+Tab). Matches Andre's readout app. Manual updated.

Build clean; --selftest passes; deployed to both test folders. Held for next release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:31:22 +01:00
EdnunpandClaude Opus 4.8 78684f29f1 New Preferences "Appearance" tab: theme + show-pan/EQ moved in, tab reordering, peer-list toggles
Adds an Appearance tab (after General) and moves the colour theme and "show volume/pan/EQ tab" into
it, per Ed. New on that tab:
 * Main tab order — a list of the four main-window tabs (all shown, even when the pan/EQ tab is
   hidden) with Move up / Move down buttons to reorder them. Saved to AppConfig.MainTabOrder and
   applied by the new ApplyMainTabLayout (rebuilds the tab strip in order, dropping pan/EQ when off,
   preserving selection). Replaces RefreshPanEqTabVisibility.
 * Enable discovered / remembered peers lists on the Connectivity tab (AppConfig.ShowDiscoveredPeers
   / ShowRememberedPeers, both default on). RefreshConnectivityListVisibility hides the row's label
   and its list wrapper when off. Row labels are now captured for this.

All apply when Preferences closes. Manual updated. Build clean; --selftest passes; deployed to both
test folders. Held for next release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:56:06 +01:00