Cleanup + security narrowing + manual updates (post-review)

Security (narrow the no-admin grants to one account):
- The service's no-admin start/stop and bin-write grants went to Authenticated Users /
  BUILTIN\Users - together that was a one-step local privilege escalation for ANY
  account (overwrite the SYSTEM-run binary, then stop/start it). Now both grants go to
  the INSTALLING user's SID only (the elevated install runs as that interactive user).
  Same effortless workflow for that user; the any-account escalation surface is gone.
  AddUserStartStopAce takes the SID; self-test asserts it's scoped, not AU.

Dead-code removal:
- --probe-apploopback diagnostic verb + ProbeAppLoopback.cs + the ProcessLoopbackCapture
  .Diagnostic hook (all scaffolding for the now-fixed activation bug).
- --update-service verb + ServiceControl.DoUpdate (the "Update service" menu item is
  gone; auto-update via ServiceUpdate.RestartSelf replaced it). Verb gate now lists five.

Manual (readme.html):
- New "Sending specific applications" section (the How-to-send chooser + the two app
  lists) and the Alt+6/8/9 shortcuts - the whole per-app feature was undocumented.
- Documented the two "Clear remembered ... list" buttons on Preferences > General.

Gate: 42/42.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-17 16:37:13 +01:00
co-authored by Claude Opus 4.8
parent 984bcd042e
commit d002130402
7 changed files with 66 additions and 136 deletions
+23 -1
View File
@@ -318,7 +318,7 @@ ul, ol { padding-left: 1.4em; }
<tr><td><strong>Reset the default audio device prompt</strong></td><td>Alt+O, R</td><td>Brings back the &ldquo;use only the default audio device?&rdquo; question if you previously ticked &ldquo;Don't ask me this again&rdquo; on it. See <a href="#tab-io">Following the Windows default audio device</a>.</td></tr>
<tr><td><strong>Enable / Disable Realtek ASIO</strong></td><td>&mdash;</td><td>Only shown if a Realtek ASIO driver is installed. Lets you reverse the choice RemSound offered about disabling that driver (Realtek's generic ASIO driver tends to grab the wrong device and clash with your screen reader).</td></tr>
<tr><td><strong>Install RemSound on this PC&hellip;</strong> (or <strong>Uninstall&hellip;</strong>)</td><td>Alt+O, I (Alt+O, U once installed)</td><td>Turns the copy you're running into a properly installed Windows app &mdash; on the Start menu, with a desktop shortcut, and listed in Windows&rsquo; Installed apps &mdash; or removes it again. Once it's installed, this item changes to <strong>Uninstall RemSound from this PC</strong>. See <a href="#install">Installing RemSound on your PC</a>.</td></tr>
<tr><td><strong>Preferences&hellip;</strong></td><td>Ctrl+P, or Alt+O, P</td><td>Opens the Preferences dialog, organised into six tabs (move between them with Ctrl+Tab, or the arrow keys when the tab names have focus): <strong>General</strong> &mdash; the profiles folder, accept remote volume commands, and UPnP router opening; <strong>Appearance</strong> &mdash; <strong>Colour theme (Alt+T)</strong> (Match Windows / Light / Dark; changes only how the window looks, takes effect next launch, no effect on the screen reader), &ldquo;Show the volume, pan and EQ for peers tab&rdquo; (on by default; hides the <a href="#pan-eq">Volume, pan and EQ for peers tab</a> if you untick it), <strong>Tab order (Alt+O)</strong> &mdash; a list of the window's tabs with <strong>Move up (Alt+U)</strong> and <strong>Move down (Alt+N)</strong> buttons to reorder them, and two toggles to <strong>enable the Discovered (Alt+D)</strong> and <strong>Remembered (Alt+R)</strong> peer lists on the Connectivity tab (both on by default &mdash; untick one to hide that list); <strong>Audio cues</strong> &mdash; the cue list and its sounds (see <a href="#audio-cues">Audio cue sounds</a>); <strong>Startup behaviour</strong> &mdash; start minimised / with Windows / with a specific profile; <strong>Update settings</strong> &mdash; the update checks and install options; and <strong>Logging</strong> &mdash; enable logs, write logs now, and the log-folder housekeeping (see <a href="#logs">Logs and diagnostics</a>). Esc or the Close button dismisses it.</td></tr>
<tr><td><strong>Preferences&hellip;</strong></td><td>Ctrl+P, or Alt+O, P</td><td>Opens the Preferences dialog, organised into six tabs (move between them with Ctrl+Tab, or the arrow keys when the tab names have focus): <strong>General</strong> &mdash; the profiles folder, accept remote volume commands, UPnP router opening, and two buttons to clear the shared address books: <strong>Clear remembered peers list (Alt+P)</strong> and <strong>Clear remembered applications list (Alt+L)</strong> (each asks you to confirm first; these lists are shared across all your profiles, so clearing empties them everywhere); <strong>Appearance</strong> &mdash; <strong>Colour theme (Alt+T)</strong> (Match Windows / Light / Dark; changes only how the window looks, takes effect next launch, no effect on the screen reader), &ldquo;Show the volume, pan and EQ for peers tab&rdquo; (on by default; hides the <a href="#pan-eq">Volume, pan and EQ for peers tab</a> if you untick it), <strong>Tab order (Alt+O)</strong> &mdash; a list of the window's tabs with <strong>Move up (Alt+U)</strong> and <strong>Move down (Alt+N)</strong> buttons to reorder them, and two toggles to <strong>enable the Discovered (Alt+D)</strong> and <strong>Remembered (Alt+R)</strong> peer lists on the Connectivity tab (both on by default &mdash; untick one to hide that list); <strong>Audio cues</strong> &mdash; the cue list and its sounds (see <a href="#audio-cues">Audio cue sounds</a>); <strong>Startup behaviour</strong> &mdash; start minimised / with Windows / with a specific profile; <strong>Update settings</strong> &mdash; the update checks and install options; and <strong>Logging</strong> &mdash; enable logs, write logs now, and the log-folder housekeeping (see <a href="#logs">Logs and diagnostics</a>). Esc or the Close button dismisses it.</td></tr>
</table>
<h3 id="install">Installing RemSound on your PC</h3>
@@ -453,6 +453,25 @@ ul, ol { padding-left: 1.4em; }
<strong>If your microphone sends silence:</strong> Windows can block desktop apps from using the microphone, and when it does, RemSound's mic capture still switches on but only sends silence &mdash; so you look like you're sending, but the other person hears nothing. RemSound watches for this: when you tick a microphone in <strong>WASAPI audio inputs to send</strong> while Windows is blocking it &mdash; or load a profile that already has one ticked &mdash; a message pops up telling you, with the exact two settings to turn on &mdash; open Windows Settings &rarr; Privacy &amp; security &rarr; Microphone, then turn on both <em>Microphone access</em> and <em>Let desktop apps access your microphone</em>. The check also catches the sneakier kinds of block: one aimed at RemSound alone in that same Settings page's per-app list, and one set by an administrator or workplace policy &mdash; that last kind doesn't show up as a switch you can flip, so if the warning says a policy is involved, it needs whoever manages the computer to lift it. (ASIO inputs aren't affected, because ASIO talks straight to the hardware and bypasses that Windows privacy gate.) It doesn't change anything you receive &mdash; only sending your own mic.
</div>
<h3>Sending specific applications instead of whole devices</h3>
<p>By default the WASAPI output list sends <em>everything</em> playing on a sound device. If you'd rather send only <strong>one particular program</strong> &mdash; say foobar2000 or a browser &mdash; and nothing else, use the <strong>How to send WASAPI audio (Alt+6)</strong> chooser, just below <em>Send my audio</em>. It has two settings:</p>
<ul>
<li><strong>Send whole audio devices</strong> (the default) &mdash; the ordinary behaviour, using the &ldquo;WASAPI audio outputs to send&rdquo; list described above.</li>
<li><strong>Send specific applications</strong> &mdash; swaps that device list for two application lists. (This needs Windows 10 version 2004 or newer; on older Windows the chooser is hidden and only whole-device sending is available.)</li>
</ul>
<p>When you choose <em>Send specific applications</em>, two lists appear:</p>
<table>
<tr><th>List</th><th>What it holds</th></tr>
<tr><td><strong>Currently active applications (Alt+8)</strong></td><td>Every program making sound right now. Tick one and only that program's audio is captured and sent &mdash; its own private stream, separate from everything else on the machine. Tick several to send several. A program you've ticked that isn't running at the moment still shows here marked <em>(not running)</em>, so you can always find it and untick it; it starts being sent again the instant it reopens.</td></tr>
<tr><td><strong>Remembered applications (Alt+9)</strong></td><td>Your saved &ldquo;apps I send&rdquo; address book &mdash; shared across all your profiles, like the remembered peers list. Tick a program here and it moves up to the active list the moment it's running (and is captured from its very first sound). Untick a program in either list and it drops back here.</td></tr>
</table>
<p>Because sending is by program <em>name</em>, your choice survives that program being closed and reopened, or even the computer restarting. There is deliberately no &ldquo;send everything&rdquo; option in applications mode &mdash; if you want the whole machine's sound, that's what <em>Send whole audio devices</em> is for.</p>
<h2 id="tab-profile">8. Audio profile tab</h2>
<p>Everything that shapes the trade-off between sound quality and delay lives here. The first control on the tab is the <em>priority mode</em> checkbox &mdash; it sits on its own at the top because it has the biggest single effect on how the audio feels in the first few seconds. Below it are two groups: <strong>Audio send parameters</strong> first, then <strong>Audio receive parameters</strong>.</p>
@@ -865,6 +884,9 @@ Audient USB Audio ASIO Driver &mdash; Pair 3 (channels 5/6): Loop-back 1 (L) / L
<tr><td>Alt+3</td><td>Focus WASAPI outputs for received audio</td></tr>
<tr><td>Alt+4</td><td>Focus WASAPI audio outputs to send</td></tr>
<tr><td>Alt+5</td><td>Focus WASAPI audio inputs to send</td></tr>
<tr><td>Alt+6</td><td>Focus the &ldquo;How to send WASAPI audio&rdquo; chooser (whole devices vs specific applications)</td></tr>
<tr><td>Alt+8</td><td>(Applications mode) Focus the Currently active applications list</td></tr>
<tr><td>Alt+9</td><td>(Applications mode) Focus the Remembered applications list</td></tr>
<tr><td>Alt+V</td><td>Focus the volume slider</td></tr>
<tr><td>Alt+S</td><td>Toggle Send my audio</td></tr>
</table>
-66
View File
@@ -1,66 +0,0 @@
using System.Diagnostics;
using RemSound.Sender;
namespace RemSound.App;
/// <summary>Focused diagnostic probe for the process-loopback activation hang (per-app send). Run via
/// `RemSound.exe --probe-apploopback [pid]`. Traces the whole activation path (hr, callback delivery,
/// timing) for the given pid — or the FIRST process named in the REMSOUND_PROBE_APP env var, else self.
/// Prints to the console; not part of the gate. Temporary investigation aid.</summary>
internal static class ProbeAppLoopback
{
public static int Run(string[] args)
{
var outPath = Path.Combine(Path.GetTempPath(), "remsound-apploopback-probe.txt");
var sw0 = new StreamWriter(outPath, append: false) { AutoFlush = true };
void Line(string m) => sw0.WriteLine(m);
ProcessLoopbackCapture.Diagnostic = m => Line($" [{DateTime.Now:HH:mm:ss.fff}] {m}");
int pid;
var explicitPid = args.FirstOrDefault(a => int.TryParse(a, out _));
if (explicitPid is not null) pid = int.Parse(explicitPid);
else
{
var name = Environment.GetEnvironmentVariable("REMSOUND_PROBE_APP");
var proc = string.IsNullOrWhiteSpace(name)
? null
: Process.GetProcessesByName(name).FirstOrDefault();
pid = proc?.Id ?? Environment.ProcessId;
}
Line($"process-loopback probe: target pid={pid} "
+ $"({(pid == Environment.ProcessId ? "SELF (silent)" : SafeName(pid))}), IsSupported={ProcessLoopbackCapture.IsSupported}");
var sw = Stopwatch.StartNew();
long bytes = 0;
Exception? stopError = null;
var stopped = new ManualResetEventSlim(false);
var capture = new ProcessLoopbackCapture(pid);
capture.DataAvailable += (_, e) => Interlocked.Add(ref bytes, e.BytesRecorded);
capture.RecordingStopped += (_, e) => { stopError = e.Exception; stopped.Set(); };
Line("starting capture...");
capture.StartRecording();
// Give activation up to 5s to complete or fail, then observe ~2s of data flow.
var settled = stopped.Wait(5000);
var afterActivate = sw.ElapsedMilliseconds;
if (!settled) Thread.Sleep(2000); // capture is live — let some audio flow
var seen = Interlocked.Read(ref bytes);
Line($"--- after {afterActivate}ms: stopped={settled}, bytesCaptured={seen}, stopError={stopError?.GetType().Name}: {stopError?.Message}");
var disposeSw = Stopwatch.StartNew();
capture.Dispose();
disposeSw.Stop();
Line($"dispose took {disposeSw.ElapsedMilliseconds}ms (~2000ms = capture thread was still stuck in activation)");
Line(seen > 0 ? "RESULT: capture DELIVERED audio" : "RESULT: NO audio captured (activation failed)");
sw0.Dispose();
return 0;
}
private static string SafeName(int pid)
{
try { return Process.GetProcessById(pid).ProcessName; } catch { return "unknown"; }
}
}
+1 -9
View File
@@ -22,7 +22,7 @@ internal static class Program
/// never reached.</summary>
internal static bool IsServiceInvocation(string[] args) =>
HasArg(args, ServiceControl.RunVerb) || HasArg(args, ServiceControl.InstallVerb)
|| HasArg(args, ServiceControl.UninstallVerb) || HasArg(args, ServiceControl.UpdateVerb)
|| HasArg(args, ServiceControl.UninstallVerb)
|| HasArg(args, ServiceControl.StartVerb) || HasArg(args, ServiceControl.StopVerb);
// Writes an otherwise-fatal exception to a timestamped crash file in the logs folder, so a
@@ -87,14 +87,6 @@ internal static class Program
return;
}
// --probe-apploopback [pid]: temporary focused diagnostic for the per-app-send activation hang.
// Runs the process-loopback activation with full tracing and exits. Not part of the gate.
if (args.Length > 0 && Array.Exists(args, a => string.Equals(a, "--probe-apploopback", StringComparison.OrdinalIgnoreCase)))
{
Environment.ExitCode = ProbeAppLoopback.Run(args);
return;
}
// --config-dir <folder> (test / portable isolation): redirect ALL user state - config,
// profiles, logs, cue sounds - to an explicit folder for THIS process only. Applied first,
// before the layout migration and sound consolidation below read or write the default
+12 -9
View File
@@ -760,15 +760,18 @@ internal static class SelfTest
Check(createArgs.Contains("\\\"" + ServiceStore.BinExePath + "\\\" " + ServiceControl.RunVerb),
"the create command must register the ProgramData bin exe as the service binary");
// 2. AddUserStartStopAce inserts the AU start/stop ACE into the DACL, ahead of the SACL, and is idempotent.
// 2. AddUserStartStopAce inserts the user's start/stop ACE into the DACL, ahead of the SACL, idempotently.
const string sample = "D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCLCSWLOCRRC;;;IU)S:(AU;FA;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)";
var amended = ServiceControl.AddUserStartStopAce(sample);
Check(amended is not null && amended.Contains(ServiceControl.UserStartStopAce), "the AU start/stop ACE must be added");
Check(amended!.IndexOf(ServiceControl.UserStartStopAce, StringComparison.Ordinal) < amended.IndexOf("S:", StringComparison.Ordinal),
const string sid = "S-1-5-21-111-222-333-1001"; // a specific user SID (the installing user, scoped grant)
var ace = ServiceControl.UserStartStopAceFor(sid);
var amended = ServiceControl.AddUserStartStopAce(sample, sid);
Check(amended is not null && amended.Contains(ace), "the user's start/stop ACE must be added");
Check(amended!.IndexOf(ace, StringComparison.Ordinal) < amended.IndexOf("S:", StringComparison.Ordinal),
"the ACE must sit inside the DACL, before the SACL");
Check(amended.StartsWith("D:", StringComparison.Ordinal), "the result must still be a valid DACL-first SDDL");
Check(ServiceControl.AddUserStartStopAce(amended) == amended, "adding the ACE twice must be a no-op (idempotent)");
Check(ServiceControl.AddUserStartStopAce("garbage") is null, "a non-DACL SDDL must be rejected");
Check(!amended.Contains(";;;AU)"), "the grant must be scoped to the specific user SID, not Authenticated Users");
Check(ServiceControl.AddUserStartStopAce(amended, sid) == amended, "adding the ACE twice must be a no-op (idempotent)");
Check(ServiceControl.AddUserStartStopAce("garbage", sid) is null, "a non-DACL SDDL must be rejected");
// 2b. The app-source path (which the SYSTEM service watches for auto-updates) round-trips, and drives
// the update check: unknown/empty source => no update, so the service never acts on uncertainty.
@@ -811,7 +814,7 @@ internal static class SelfTest
Check(File.Exists(Path.Combine(dst, "default sounds", "connect.wav")), "bundled default sounds must be copied");
Check(!Directory.Exists(Path.Combine(dst, "user settings and logs")), "user settings/logs must NOT be copied");
Check(!Directory.Exists(Path.Combine(dst, "logs")), "stray logs folder must NOT be copied");
return "runs from own ProgramData bin; AU start/stop ACE added idempotently; program copy excludes user state";
return "runs from own ProgramData bin; user-scoped start/stop ACE added idempotently; program copy excludes user state";
}
finally { try { Directory.Delete(root, recursive: true); } catch { /* temp */ } }
}
@@ -1417,7 +1420,7 @@ internal static class SelfTest
foreach (var verb in new[]
{
ServiceControl.RunVerb, ServiceControl.InstallVerb, ServiceControl.UninstallVerb,
ServiceControl.UpdateVerb, ServiceControl.StartVerb, ServiceControl.StopVerb,
ServiceControl.StartVerb, ServiceControl.StopVerb,
})
{
Check(Program.IsServiceInvocation(new[] { verb }), $"'{verb}' must be recognised as a service invocation");
@@ -1435,7 +1438,7 @@ internal static class SelfTest
if (!loadedBefore)
Check(!IsAssemblyLoaded(svcAsm), "deciding a normal launch must not load the Windows-service assembly");
return "normal launches stay load-safe; all six service verbs recognised (case-insensitive)";
return "normal launches stay load-safe; all five service verbs recognised (case-insensitive)";
}
/// <summary>The Service menu's "View service log" opens the newest diagnostic log — the log that says
+29 -41
View File
@@ -1,6 +1,7 @@
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Security.Principal;
using System.ServiceProcess;
using RemSound.Core;
@@ -33,7 +34,6 @@ public static class ServiceControl
/// the Program.cs dispatcher agree.</summary>
public const string InstallVerb = "--install-service";
public const string UninstallVerb = "--uninstall-service";
public const string UpdateVerb = "--update-service";
public const string StartVerb = "--start-service";
public const string StopVerb = "--stop-service";
public const string RunVerb = "--run-service";
@@ -129,20 +129,31 @@ public static class ServiceControl
return 0;
}
/// <summary>Grant Authenticated Users Modify rights on the service's bin folder (via icacls), so a
/// <summary>The SID to grant the no-admin service rights to: the account that installed it (the
/// elevated install runs as the same interactive user with an elevated token, so its SID is that user).
/// Scoping the grants to ONE account instead of all Users/Authenticated-Users keeps the effortless
/// stop/update workflow for that user while removing the "any account on this PC could replace a
/// SYSTEM-run binary" escalation surface. Falls back to BUILTIN\Users only if the SID can't be read.</summary>
private static string InstallingUserSid()
{
try { return WindowsIdentity.GetCurrent().User?.Value ?? "S-1-5-32-545"; }
catch { return "S-1-5-32-545"; }
}
/// <summary>Grant the installing user Modify rights on the service's bin folder (via icacls), so a
/// stopped service's binaries can be refreshed without administrator rights. Best-effort.</summary>
private static void GrantUsersWriteToBin()
{
try
{
// *S-1-5-32-545 = BUILTIN\Users (locale-independent) — the group the interactive user is in.
// (OI)(CI) = inherit to files + subfolders; (M) = Modify. /T applies to the existing contents too
// (the bin was just populated), /C keeps going past any single-file error. Capture stderr so a
// real failure is logged rather than swallowed.
// Grant the installing user (see InstallingUserSid) Modify. (OI)(CI) = inherit to files +
// subfolders; (M) = Modify. /T applies to the existing contents too (the bin was just
// populated), /C keeps going past any single-file error. Capture stderr so a real failure is
// logged rather than swallowed.
var psi = new ProcessStartInfo
{
FileName = "icacls.exe",
Arguments = $"\"{ServiceStore.BinDirectory}\" /grant \"*S-1-5-32-545:(OI)(CI)(M)\" /T /C",
Arguments = $"\"{ServiceStore.BinDirectory}\" /grant \"*{InstallingUserSid()}:(OI)(CI)(M)\" /T /C",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
@@ -195,7 +206,7 @@ public static class ServiceControl
try
{
var sddl = RunScCapture($"sdshow {ServiceName}").Trim();
var newSddl = AddUserStartStopAce(sddl);
var newSddl = AddUserStartStopAce(sddl, InstallingUserSid());
if (newSddl is null || string.Equals(newSddl, sddl, StringComparison.Ordinal)) return;
var rc = RunSc($"sdset {ServiceName} {newSddl}");
if (rc != 0) ServiceStore.AppendServiceEvent($"install: sdset (user start/stop) returned {rc}");
@@ -203,21 +214,22 @@ public static class ServiceControl
catch (Exception ex) { ServiceStore.AppendServiceEvent($"install: grant user start/stop failed: {ex.GetType().Name}: {ex.Message}"); }
}
/// <summary>The ACE granting Authenticated Users start (RP) + stop (WP) + query status (LC) + read
/// control (RC). Public-ish for the self-test.</summary>
internal const string UserStartStopAce = "(A;;RPWPLCRC;;;AU)";
/// <summary>The ACE granting <paramref name="sid"/> start (RP) + stop (WP) + query status (LC) + read
/// control (RC) on the service.</summary>
internal static string UserStartStopAceFor(string sid) => $"(A;;RPWPLCRC;;;{sid})";
/// <summary>Pure, testable: insert <see cref="UserStartStopAce"/> into a service SDDL's DACL (right
/// after "D:" and any DACL flags, ahead of the first ACE and the SACL). Returns null for an SDDL that
/// doesn't start with a DACL, and the input unchanged if the ACE is already present.</summary>
internal static string? AddUserStartStopAce(string? sddl)
/// <summary>Pure, testable: insert the start/stop ACE for <paramref name="sid"/> into a service SDDL's
/// DACL (right after "D:" and any DACL flags, ahead of the first ACE and the SACL). Returns null for an
/// SDDL that doesn't start with a DACL, and the input unchanged if the ACE is already present.</summary>
internal static string? AddUserStartStopAce(string? sddl, string sid)
{
if (string.IsNullOrEmpty(sddl) || !sddl.StartsWith("D:", StringComparison.Ordinal)) return null;
if (sddl.Contains(UserStartStopAce, StringComparison.OrdinalIgnoreCase)) return sddl; // already granted
var ace = UserStartStopAceFor(sid);
if (sddl.Contains(ace, StringComparison.OrdinalIgnoreCase)) return sddl; // already granted
var firstAce = sddl.IndexOf('(');
var sacl = sddl.IndexOf("S:", StringComparison.Ordinal);
var insertAt = firstAce >= 0 && (sacl < 0 || firstAce < sacl) ? firstAce : (sacl >= 0 ? sacl : sddl.Length);
return sddl.Insert(insertAt, UserStartStopAce);
return sddl.Insert(insertAt, ace);
}
/// <summary>The sc.exe "failure" args that make the service auto-restart on a crash. Pure, so a
@@ -239,30 +251,6 @@ public static class ServiceControl
return rc;
}
/// <summary>Refreshes the service's OWN copy of the program with the CURRENTLY-running build, without
/// an uninstall/reinstall: stop → overwrite the binaries in <see cref="ServiceStore.BinDirectory"/>
/// from this exe's folder → start. Must be run elevated (writing into the admin-only service bin
/// folder, and stop/start). Returns 0 on success. Used by the Service menu's "Update service" so a new
/// build reaches the service in one UAC prompt.</summary>
public static int DoUpdate()
{
if (!IsInstalled()) return DoInstall(); // not installed yet — a plain install does the copy too
var exe = Environment.ProcessPath;
var sourceDir = string.IsNullOrEmpty(exe) ? null : Path.GetDirectoryName(exe);
if (string.IsNullOrEmpty(sourceDir)) return 2;
try { DoStop(); } catch { /* best-effort — copy may still fail if files stay locked, handled below */ }
try { CopyProgramTo(sourceDir, ServiceStore.BinDirectory); }
catch (Exception ex)
{
ServiceStore.AppendServiceEvent($"update: copy program failed: {ex.GetType().Name}: {ex.Message}");
try { DoStart(); } catch { /* leave it stopped rather than half-updated */ }
return 5;
}
ServiceStore.SaveAppSourcePath(sourceDir); // keep the auto-update watch pointed at the current app
return DoStart();
}
/// <summary>Starts the service. Must be run elevated. Returns 0 on success.</summary>
public static int DoStart()
{
-2
View File
@@ -36,7 +36,6 @@ internal static class ServiceEntry
var verb = Has(args, ServiceControl.InstallVerb) ? "install"
: Has(args, ServiceControl.UninstallVerb) ? "uninstall"
: Has(args, ServiceControl.UpdateVerb) ? "update"
: Has(args, ServiceControl.StartVerb) ? "start"
: Has(args, ServiceControl.StopVerb) ? "stop"
: null;
@@ -53,7 +52,6 @@ internal static class ServiceEntry
{
"install" => ServiceControl.DoInstall(),
"uninstall" => ServiceControl.DoUninstall(),
"update" => ServiceControl.DoUpdate(),
"start" => ServiceControl.DoStart(),
"stop" => ServiceControl.DoStop(),
_ => 0,
@@ -67,9 +67,6 @@ public sealed class ProcessLoopbackCapture : IWaveIn
/// e.g. the send-mode UI that crashed at launch on Win7 (issue #22). Null = use the real OS check.</summary>
internal static bool? ForceSupportedForTest;
/// <summary>Diagnostic sink for activation-path tracing (hr codes, callback delivery, timing). Static
/// so a probe can wire it without touching every construction site. Null = no tracing.</summary>
internal static Action<string>? Diagnostic;
/// <summary>True on Windows builds new enough for the process-loopback API (10.0.19041+).</summary>
public static bool IsSupported =>
@@ -151,7 +148,6 @@ public sealed class ProcessLoopbackCapture : IWaveIn
try
{
var hr = ActivateAudioInterfaceAsync(VirtualDevicePath, ref iidAudioClient, ref propVariant, handler, out opPtr);
Diagnostic?.Invoke($"activate: ActivateAudioInterfaceAsync hr=0x{hr:X8} for pid {targetPid}");
if (hr != 0) Marshal.ThrowExceptionForHR(hr);
if (!handler.Completed.WaitOne(3000))
@@ -301,7 +297,6 @@ public sealed class ProcessLoopbackCapture : IWaveIn
public void ActivateCompleted(IntPtr activateOperation)
{
Diagnostic?.Invoke($"ActivateCompleted ENTERED on thread apartment={Thread.CurrentThread.GetApartmentState()}, op={(activateOperation == IntPtr.Zero ? "null" : "set")}");
var ifacePtr = IntPtr.Zero;
try
{
@@ -312,16 +307,14 @@ public sealed class ProcessLoopbackCapture : IWaveIn
var fnPtr = Marshal.ReadIntPtr(vtbl, 3 * IntPtr.Size);
var getResult = Marshal.GetDelegateForFunctionPointer<GetActivateResultDelegate>(fnPtr);
var callHr = getResult(activateOperation, out var activateHr, out ifacePtr);
if (callHr != 0) { ActivateResult = callHr; Diagnostic?.Invoke($"ActivateCompleted: GetActivateResult call failed hr=0x{callHr:X8}"); return; }
if (callHr != 0) { ActivateResult = callHr; return; }
ActivateResult = activateHr;
if (activateHr == 0 && ifacePtr != IntPtr.Zero)
Interface = Marshal.GetTypedObjectForIUnknown(ifacePtr, typeof(IAudioClient));
Diagnostic?.Invoke($"ActivateCompleted: activateHr=0x{activateHr:X8}, iface={(ifacePtr == IntPtr.Zero ? "null" : "got")}");
}
catch (Exception ex)
{
ActivateResult = ex.HResult != 0 ? ex.HResult : unchecked((int)0x80004005);
Diagnostic?.Invoke($"ActivateCompleted: THREW {ex.GetType().Name}: {ex.Message} (hr=0x{ActivateResult:X8})");
}
finally
{