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:
co-authored by
Claude Opus 4.8
parent
f0b35b2b8c
commit
4f5265d8b0
@@ -2229,21 +2229,23 @@ public sealed class MainForm : Form
|
||||
/// the service is running, restarts it so the edits take effect.</summary>
|
||||
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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -48,15 +48,10 @@ public sealed class ServiceSendHost : IDisposable
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
/// <summary>Convenience factory for the real service: loads the profile named by
|
||||
/// <see cref="AppConfig.ServiceProfileName"/> from the given profiles folder each time it's asked.</summary>
|
||||
public static ServiceSendHost FromConfig(Action<string>? 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);
|
||||
/// <summary>Convenience factory for the real service: loads the profile from the machine-wide
|
||||
/// <see cref="ServiceStore"/> (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.</summary>
|
||||
public static ServiceSendHost FromConfig(Action<string>? log = null) => new(ServiceStore.LoadProfile, log);
|
||||
|
||||
public bool IsSending { get { lock (gate) return running; } }
|
||||
|
||||
|
||||
@@ -244,18 +244,9 @@ public sealed class AppConfig
|
||||
/// Startup behaviour dialog. Null = always show the picker (legacy behaviour).</summary>
|
||||
public string? StartWithProfileTitle { get; set; }
|
||||
|
||||
// === Lock-screen send-only service (the RemSound Windows service) ===
|
||||
/// <summary>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.</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; }
|
||||
// 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.)
|
||||
|
||||
/// <summary>How often RemSound polls the GitHub Releases API for a newer build. Default
|
||||
/// <see cref="UpdateCheckFrequency.Every24Hours"/>. Set to <see cref="UpdateCheckFrequency.Never"/>
|
||||
|
||||
@@ -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; } }
|
||||
}
|
||||
Reference in New Issue
Block a user