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>
This commit is contained in:
Ednunp
2026-07-13 08:23:10 +01:00
co-authored by Claude Opus 4.8
parent f0b35b2b8c
commit 4f5265d8b0
7 changed files with 139 additions and 34 deletions
+13 -11
View File
@@ -2229,21 +2229,23 @@ public sealed class MainForm : Form
/// the service is running, restarts it so the edits take effect.</summary> /// the service is running, restarts it so the edits take effect.</summary>
private void ConfigureServiceProfile() private void ConfigureServiceProfile()
{ {
if (profileStore is null) return; // The service profile lives in the machine-wide ServiceStore (ProgramData), NOT the user's
var cfg = AppConfig.Load(); // profiles folder — so it's readable by the SYSTEM service and fully isolated from the picker,
Profile current; // recents and password manager. Migrate a profile left in the old (user-folder) location by the
try { current = profileStore.Load(ServiceControl.ServiceProfileTitle) ?? Profile.NewBlank(); } // earlier design so a user who configured it before doesn't lose their settings.
catch { current = Profile.NewBlank(); } var current = ServiceStore.LoadProfile();
if (current is null && profileStore is not null)
try { current = profileStore.Load(ServiceControl.ServiceProfileTitle); } catch { /* none */ }
current ??= Profile.NewBlank();
using var dlg = new ServiceProfileDialog(current, cfg.ServiceLoggingEnabled); using var dlg = new ServiceProfileDialog(current, ServiceStore.LoadLoggingEnabled());
if (dlg.ShowDialog(this) != DialogResult.OK) return; if (dlg.ShowDialog(this) != DialogResult.OK) return;
try try
{ {
profileStore.Save(dlg.Result); ServiceStore.SaveProfile(dlg.Result);
var c = AppConfig.Load(); ServiceStore.SaveLoggingEnabled(dlg.ServiceLoggingEnabled);
c.ServiceProfileName = ServiceControl.ServiceProfileTitle; // Remove any stray copy the old design left in the user's profiles folder.
c.ServiceLoggingEnabled = dlg.ServiceLoggingEnabled; try { profileStore?.Delete(ServiceControl.ServiceProfileTitle); } catch { /* best-effort */ }
c.Save();
if (ServiceControl.Query() == ServiceState.Running) if (ServiceControl.Query() == ServiceState.Running)
{ {
ServiceControl.RunElevated(ServiceControl.StopVerb); ServiceControl.RunElevated(ServiceControl.StopVerb);
+1 -1
View File
@@ -50,7 +50,7 @@ public sealed class RemSoundService : ServiceBase
private static bool SafeServiceLogging() private static bool SafeServiceLogging()
{ {
try { return AppConfig.Load().ServiceLoggingEnabled; } try { return ServiceStore.LoadLoggingEnabled(); }
catch { return false; } catch { return false; }
} }
+47
View File
@@ -64,6 +64,7 @@ internal static class SelfTest
RunStep(results, "Lifecycle churn (modes, sources, pan/EQ, send/receive)", LifecycleChurn); RunStep(results, "Lifecycle churn (modes, sources, pan/EQ, send/receive)", LifecycleChurn);
RunStep(results, "Service app-yield token", ServiceInteractivePresence); RunStep(results, "Service app-yield token", ServiceInteractivePresence);
RunStep(results, "Service sender parity (crypto + Opus frame)", ServiceSenderParity); RunStep(results, "Service sender parity (crypto + Opus frame)", ServiceSenderParity);
RunStep(results, "Service profile isolation (location + hidden from pickers)", ServiceProfileIsolation);
RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream); RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream);
RunStep(results, "Service registration args", ServiceRegistrationArgs); RunStep(results, "Service registration args", ServiceRegistrationArgs);
RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine); RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine);
@@ -678,6 +679,52 @@ internal static class SelfTest
return "sc create args quoted correctly for a spaced path"; return "sc create args quoted correctly for a spaced path";
} }
/// <summary>The service profile is fully isolated from the normal profile machinery: it lives in a
/// MACHINE-WIDE ProgramData location (readable by the SYSTEM service, outside the user's profiles
/// folder), and the reserved title never shows up in the profile listing that backs the startup
/// picker, File→Open, Recent profiles and the password manager. Also round-trips through the store.</summary>
private static string? ServiceProfileIsolation()
{
// 1. The store lives under ProgramData, NOT the user's profiles folder.
var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
Check(ServiceStore.Directory.StartsWith(programData, StringComparison.OrdinalIgnoreCase),
$"the service profile must live under ProgramData (got {ServiceStore.Directory})");
// 2. The reserved title is filtered out of ListProfileTitles (the picker / recents / password
// manager all read that), even if a stray file were present in the profiles folder.
var temp = Path.Combine(Path.GetTempPath(), "remsound-svciso-" + Guid.NewGuid().ToString("N"));
try
{
var store = new ProfileStore(temp);
store.Save(new Profile { Title = "My normal profile" });
store.Save(new Profile { Title = ProfileStore.ReservedServiceProfileTitle });
var titles = store.ListProfileTitles();
Check(titles.Contains("My normal profile"), "a normal profile must be listed");
Check(!titles.Any(t => string.Equals(t, ProfileStore.ReservedServiceProfileTitle, StringComparison.OrdinalIgnoreCase)),
"the service profile must NOT appear in the profile listing (picker / recents / password manager)");
// 3. Round-trip through the machine-wide store (redirected to a temp folder for the test).
var saved = ServiceStore.TestDirectoryOverride;
ServiceStore.TestDirectoryOverride = Path.Combine(temp, "service");
try
{
Check(ServiceStore.LoadProfile() is null, "no service profile before one is saved");
var p = new Profile { Title = ProfileStore.ReservedServiceProfileTitle, WasapiSendMode = "applications" };
p.SelectedConnectedPeers.Add("10.0.0.5");
ServiceStore.SaveProfile(p);
ServiceStore.SaveLoggingEnabled(true);
var back = ServiceStore.LoadProfile();
Check(back is not null && back.WasapiSendMode == "applications" && back.SelectedConnectedPeers.Contains("10.0.0.5"),
"the service profile must round-trip through the machine-wide store");
Check(ServiceStore.LoadLoggingEnabled(), "service logging flag must round-trip");
}
finally { ServiceStore.TestDirectoryOverride = saved; }
return "under ProgramData; hidden from the picker/recents/password-manager; round-trips";
}
finally { try { Directory.Delete(temp, recursive: true); } catch { /* best-effort */ } }
}
/// <summary>The service must configure the sender EXACTLY like the main app: derive both the audio key /// <summary>The service must configure the sender EXACTLY like the main app: derive both the audio key
/// AND the fingerprint from the password (a missing fingerprint gets the encrypted stream rejected at /// AND the fingerprint from the password (a missing fingerprint gets the encrypted stream rejected at
/// the peer), and apply the send-rate-adjusted Opus frame (the "Small" rate halves it). Guards the /// the peer), and apply the send-rate-adjusted Opus frame (the "Small" rate halves it). Guards the
+1 -1
View File
@@ -20,7 +20,7 @@ internal sealed class ServiceProfileDialog : Form
private readonly CheckedListBox peersList = new() { CheckOnClick = true, Width = 460, Height = 120, AccessibleName = "Peers to send to (Alt+C)" }; private readonly CheckedListBox peersList = new() { CheckOnClick = true, Width = 460, Height = 120, AccessibleName = "Peers to send to (Alt+C)" };
private readonly Label peersStatus = new() { AutoSize = true, Text = "No peers." }; private readonly Label peersStatus = new() { AutoSize = true, Text = "No peers." };
private readonly Button manualAddButton = new() { Text = "Add peer by IP (Alt+&A)", AutoSize = true, AccessibleName = "Add peer by IP" }; private readonly Button manualAddButton = new() { Text = "Add peer by IP (Alt+&A)", AutoSize = true, AccessibleName = "Add peer by IP" };
private readonly Button passwordButton = new() { Text = "Set pass&word...", AutoSize = true, AccessibleName = "Set the service profile password" }; private readonly Button passwordButton = new() { Text = "Set service profile pass&word...", AutoSize = true, AccessibleName = "Set service profile password" };
private readonly Label passwordStatus = new() { AutoSize = true }; private readonly Label passwordStatus = new() { AutoSize = true };
// --- Audio send tab --- // --- Audio send tab ---
+4 -9
View File
@@ -48,15 +48,10 @@ public sealed class ServiceSendHost : IDisposable
this.log = log; this.log = log;
} }
/// <summary>Convenience factory for the real service: loads the profile named by /// <summary>Convenience factory for the real service: loads the profile from the machine-wide
/// <see cref="AppConfig.ServiceProfileName"/> from the given profiles folder each time it's asked.</summary> /// <see cref="ServiceStore"/> (ProgramData) each time it's asked — the same file the config dialog
public static ServiceSendHost FromConfig(Action<string>? log = null) => new(() => /// writes, readable by the SYSTEM service account. Re-read on each resume so edits are picked up.</summary>
{ public static ServiceSendHost FromConfig(Action<string>? log = null) => new(ServiceStore.LoadProfile, log);
var cfg = AppConfig.Load();
if (string.IsNullOrWhiteSpace(cfg.ServiceProfileName) || string.IsNullOrWhiteSpace(cfg.ProfilesDirectory)) return null;
try { return new ProfileStore(cfg.ProfilesDirectory).Load(cfg.ServiceProfileName!); }
catch { return null; }
}, log);
public bool IsSending { get { lock (gate) return running; } } public bool IsSending { get { lock (gate) return running; } }
+3 -12
View File
@@ -244,18 +244,9 @@ public sealed class AppConfig
/// Startup behaviour dialog. Null = always show the picker (legacy behaviour).</summary> /// Startup behaviour dialog. Null = always show the picker (legacy behaviour).</summary>
public string? StartWithProfileTitle { get; set; } public string? StartWithProfileTitle { get; set; }
// === Lock-screen send-only service (the RemSound Windows service) === // The send-only service's profile + settings live in the machine-wide RemSound.Core.ServiceStore
/// <summary>The title of the profile the RemSound Windows service loads and streams from. Null = // (ProgramData), NOT here — AppConfig is per-user, but the service runs as SYSTEM and needs the same
/// no service profile configured yet. Machine-wide, set from the Service menu's config dialog. The // file the user wrote. (ServiceProfileName / ServiceLoggingEnabled were moved there 2026-07-12.)
/// service is send-only / WASAPI-only; this profile is edited exclusively through that dialog and is
/// kept out of the normal profile picker.</summary>
public string? ServiceProfileName { get; set; }
/// <summary>Whether the RemSound Windows service writes its own log file. Separate from the app's
/// machine-wide <see cref="LoggingEnabled"/> so you can diagnose the headless service without
/// turning on logging for the interactive app. Off by default. Set from the service config dialog's
/// "Additional options". Machine-wide.</summary>
public bool ServiceLoggingEnabled { get; set; }
/// <summary>How often RemSound polls the GitHub Releases API for a newer build. Default /// <summary>How often RemSound polls the GitHub Releases API for a newer build. Default
/// <see cref="UpdateCheckFrequency.Every24Hours"/>. Set to <see cref="UpdateCheckFrequency.Never"/> /// <see cref="UpdateCheckFrequency.Every24Hours"/>. Set to <see cref="UpdateCheckFrequency.Never"/>
+70
View File
@@ -0,0 +1,70 @@
using System.Text.Json;
namespace RemSound.Core;
/// <summary>
/// Machine-wide store for the send-only Windows service's profile and its settings, kept in ProgramData —
/// deliberately OUTSIDE the user's profiles folder. Two reasons:
///
/// <list type="number">
/// <item>The service runs as SYSTEM (session 0), whose per-user data folder is NOT the interactive user's.
/// A profile saved in the user's folder would be invisible to the service. ProgramData resolves to the
/// same absolute path for every account, so the user (config dialog) and the service (SYSTEM) read and
/// write the exact same file.</item>
/// <item>Isolation: the service profile must never appear in the normal profile machinery — the startup
/// picker, File→Open, Recent profiles, or the password manager — and the user shouldn't stumble on it in
/// their profiles folder. Living somewhere else entirely guarantees that; the only way in is the Service
/// menu's config dialog.</item>
/// </list>
/// </summary>
public static class ServiceStore
{
/// <summary>Test-only redirect so a self-test can round-trip without writing real ProgramData.</summary>
internal static string? TestDirectoryOverride;
/// <summary>ProgramData\RemSound\service — same path whether resolved by the user or by SYSTEM.</summary>
public static string Directory => TestDirectoryOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "RemSound", "service");
private static string ProfilePath => Path.Combine(Directory, "service-profile.json");
private static string SettingsPath => Path.Combine(Directory, "service-settings.json");
/// <summary>The configured service profile, or null if none has been set up yet. Never throws.</summary>
public static Profile? LoadProfile()
{
try
{
return File.Exists(ProfilePath) ? JsonSerializer.Deserialize<Profile>(File.ReadAllText(ProfilePath)) : null;
}
catch { return null; }
}
public static void SaveProfile(Profile profile)
{
System.IO.Directory.CreateDirectory(Directory);
File.WriteAllText(ProfilePath, JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }));
}
/// <summary>Whether the service writes its own log. Machine-wide (the service can't read the user's
/// per-account setting). Off by default.</summary>
public static bool LoadLoggingEnabled()
{
try
{
if (!File.Exists(SettingsPath)) return false;
return (JsonSerializer.Deserialize<ServiceSettings>(File.ReadAllText(SettingsPath))?.LoggingEnabled) ?? false;
}
catch { return false; }
}
public static void SaveLoggingEnabled(bool enabled)
{
System.IO.Directory.CreateDirectory(Directory);
File.WriteAllText(SettingsPath, JsonSerializer.Serialize(new ServiceSettings { LoggingEnabled = enabled }));
}
/// <summary>True once a service profile has been configured.</summary>
public static bool IsConfigured() => File.Exists(ProfilePath);
private sealed class ServiceSettings { public bool LoggingEnabled { get; set; } }
}