From 4f5265d8b084cea4c260ab5cbe18a0278281b1d4 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:23:10 +0100 Subject: [PATCH] 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 --- src/RemSound.App/MainForm.cs | 24 ++++---- src/RemSound.App/RemSoundService.cs | 2 +- src/RemSound.App/SelfTest.cs | 47 ++++++++++++++++ src/RemSound.App/ServiceProfileDialog.cs | 2 +- src/RemSound.App/ServiceSendHost.cs | 13 ++--- src/RemSound.Core/AppConfig.cs | 15 +---- src/RemSound.Core/ServiceStore.cs | 70 ++++++++++++++++++++++++ 7 files changed, 139 insertions(+), 34 deletions(-) create mode 100644 src/RemSound.Core/ServiceStore.cs diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index c505075..82a9039 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -2229,21 +2229,23 @@ public sealed class MainForm : Form /// the service is running, restarts it so the edits take effect. private void ConfigureServiceProfile() { - if (profileStore is null) return; - var cfg = AppConfig.Load(); - Profile current; - try { current = profileStore.Load(ServiceControl.ServiceProfileTitle) ?? Profile.NewBlank(); } - catch { current = Profile.NewBlank(); } + // The service profile lives in the machine-wide ServiceStore (ProgramData), NOT the user's + // profiles folder — so it's readable by the SYSTEM service and fully isolated from the picker, + // recents and password manager. Migrate a profile left in the old (user-folder) location by the + // earlier design so a user who configured it before doesn't lose their settings. + 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; try { - profileStore.Save(dlg.Result); - var c = AppConfig.Load(); - c.ServiceProfileName = ServiceControl.ServiceProfileTitle; - c.ServiceLoggingEnabled = dlg.ServiceLoggingEnabled; - c.Save(); + ServiceStore.SaveProfile(dlg.Result); + ServiceStore.SaveLoggingEnabled(dlg.ServiceLoggingEnabled); + // Remove any stray copy the old design left in the user's profiles folder. + try { profileStore?.Delete(ServiceControl.ServiceProfileTitle); } catch { /* best-effort */ } if (ServiceControl.Query() == ServiceState.Running) { ServiceControl.RunElevated(ServiceControl.StopVerb); diff --git a/src/RemSound.App/RemSoundService.cs b/src/RemSound.App/RemSoundService.cs index f9845d2..2411ccf 100644 --- a/src/RemSound.App/RemSoundService.cs +++ b/src/RemSound.App/RemSoundService.cs @@ -50,7 +50,7 @@ public sealed class RemSoundService : ServiceBase private static bool SafeServiceLogging() { - try { return AppConfig.Load().ServiceLoggingEnabled; } + try { return ServiceStore.LoadLoggingEnabled(); } catch { return false; } } diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 5814b3d..3eea62d 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -64,6 +64,7 @@ internal static class SelfTest RunStep(results, "Lifecycle churn (modes, sources, pan/EQ, send/receive)", LifecycleChurn); RunStep(results, "Service app-yield token", ServiceInteractivePresence); 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 registration args", ServiceRegistrationArgs); 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"; } + /// 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. + 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 */ } } + } + /// 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 /// the peer), and apply the send-rate-adjusted Opus frame (the "Small" rate halves it). Guards the diff --git a/src/RemSound.App/ServiceProfileDialog.cs b/src/RemSound.App/ServiceProfileDialog.cs index 0a1c0c2..c396a4c 100644 --- a/src/RemSound.App/ServiceProfileDialog.cs +++ b/src/RemSound.App/ServiceProfileDialog.cs @@ -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 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 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 }; // --- Audio send tab --- diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs index 3ccf9dc..8c45cdd 100644 --- a/src/RemSound.App/ServiceSendHost.cs +++ b/src/RemSound.App/ServiceSendHost.cs @@ -48,15 +48,10 @@ public sealed class ServiceSendHost : IDisposable this.log = log; } - /// Convenience factory for the real service: loads the profile named by - /// from the given profiles folder each time it's asked. - public static ServiceSendHost FromConfig(Action? log = null) => new(() => - { - 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); + /// Convenience factory for the real service: loads the profile from the machine-wide + /// (ProgramData) each time it's asked — the same file the config dialog + /// writes, readable by the SYSTEM service account. Re-read on each resume so edits are picked up. + public static ServiceSendHost FromConfig(Action? log = null) => new(ServiceStore.LoadProfile, log); public bool IsSending { get { lock (gate) return running; } } diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs index 33f24b4..77f91f7 100644 --- a/src/RemSound.Core/AppConfig.cs +++ b/src/RemSound.Core/AppConfig.cs @@ -244,18 +244,9 @@ public sealed class AppConfig /// Startup behaviour dialog. Null = always show the picker (legacy behaviour). public string? StartWithProfileTitle { get; set; } - // === Lock-screen send-only service (the RemSound Windows service) === - /// The title of the profile the RemSound Windows service loads and streams from. Null = - /// no service profile configured yet. Machine-wide, set from the Service menu's config dialog. The - /// service is send-only / WASAPI-only; this profile is edited exclusively through that dialog and is - /// kept out of the normal profile picker. - public string? ServiceProfileName { get; set; } - - /// Whether the RemSound Windows service writes its own log file. Separate from the app's - /// machine-wide 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. - public bool ServiceLoggingEnabled { get; set; } + // The send-only service's profile + settings live in the machine-wide RemSound.Core.ServiceStore + // (ProgramData), NOT here — AppConfig is per-user, but the service runs as SYSTEM and needs the same + // file the user wrote. (ServiceProfileName / ServiceLoggingEnabled were moved there 2026-07-12.) /// How often RemSound polls the GitHub Releases API for a newer build. Default /// . Set to diff --git a/src/RemSound.Core/ServiceStore.cs b/src/RemSound.Core/ServiceStore.cs new file mode 100644 index 0000000..140a4f0 --- /dev/null +++ b/src/RemSound.Core/ServiceStore.cs @@ -0,0 +1,70 @@ +using System.Text.Json; + +namespace RemSound.Core; + +/// +/// 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: +/// +/// +/// 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. +/// 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. +/// +/// +public static class ServiceStore +{ + /// Test-only redirect so a self-test can round-trip without writing real ProgramData. + internal static string? TestDirectoryOverride; + + /// ProgramData\RemSound\service — same path whether resolved by the user or by SYSTEM. + 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"); + + /// The configured service profile, or null if none has been set up yet. Never throws. + public static Profile? LoadProfile() + { + try + { + return File.Exists(ProfilePath) ? JsonSerializer.Deserialize(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 })); + } + + /// Whether the service writes its own log. Machine-wide (the service can't read the user's + /// per-account setting). Off by default. + public static bool LoadLoggingEnabled() + { + try + { + if (!File.Exists(SettingsPath)) return false; + return (JsonSerializer.Deserialize(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 })); + } + + /// True once a service profile has been configured. + public static bool IsConfigured() => File.Exists(ProfilePath); + + private sealed class ServiceSettings { public bool LoggingEnabled { get; set; } } +}