From 186cf6f0bc15c5ca7d0ca4f558ef2adda5fe5134 Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Tue, 20 Oct 2026 21:31:06 -0500 Subject: [PATCH] fix(installer): skip the VoiceMeeter step unless AudioMode is SharedHost (#122) install-service.ps1 started VoiceMeeter, or warned that seats would have no audio when it was missing, on every install. Under PerSession, the service default, seats use their own RDP Remote Audio endpoint and no VoiceMeeter at all. The installer now reads the effective AudioMode from the deployed appsettings.local.json and appsettings.json (via the new Get-EffectiveAudioMode and Test-VoiceMeeterNeeded in install-lib.ps1) and only runs the step for SharedHost. Tests cover both modes under Windows PowerShell 5.1 and PowerShell 7, and a wiring test fails if the guard is removed. Co-Authored-By: Claude Sonnet 5.5 --- scripts/install-service.ps1 | 13 +- scripts/lib/install-lib.ps1 | 34 ++++ .../Scripts/InstallLibTests.cs | 150 ++++++++++++++++++ 3 files changed, 196 insertions(+), 1 deletion(-) diff --git a/scripts/install-service.ps1 b/scripts/install-service.ps1 index 65d9501..3146662 100644 --- a/scripts/install-service.ps1 +++ b/scripts/install-service.ps1 @@ -472,7 +472,18 @@ function Get-RunningVoiceMeeter { return $null } -if ($vmExe) { +# Only SharedHost uses VoiceMeeter. Under PerSession (the service default) every seat plays to +# its own RDP Remote Audio endpoint, so starting it, or telling the user to install it, is wrong. +# This runs BEFORE Install-Payload, so on an upgrade the config read is the one already deployed +# in $InstallDir (the settings the service will keep, since the payload step carries the user's +# appsettings over), and on a fresh install there is none yet, which correctly means PerSession. +$audioMode = Get-EffectiveAudioMode -ConfigPaths @( + (Join-Path $InstallDir "appsettings.local.json"), + (Join-Path $InstallDir "appsettings.json")) + +if (-not (Test-VoiceMeeterNeeded -AudioMode $audioMode)) { + Write-Host " OK: VoiceMeeter not needed (AudioMode $audioMode)" -ForegroundColor DarkGray +} elseif ($vmExe) { $vmRunning = Get-RunningVoiceMeeter -Names $vmNames if ($vmRunning) { Write-Host " OK: VoiceMeeter already running ($vmRunning)" -ForegroundColor DarkGray diff --git a/scripts/lib/install-lib.ps1 b/scripts/lib/install-lib.ps1 index 0cdb8e1..4823e5f 100644 --- a/scripts/lib/install-lib.ps1 +++ b/scripts/lib/install-lib.ps1 @@ -268,6 +268,40 @@ function Read-JsonLoose { return ($text | ConvertFrom-Json) } +function Get-EffectiveAudioMode { + # The AudioMode the service will run with, read from the config files it reads. + # -ConfigPaths is in precedence order, HIGHEST first (appsettings.local.json, then + # appsettings.json): the first file that sets AudioMode decides, like the service's own + # layering. A missing or unreadable file, no AudioMode key, or a value that is not + # SharedHost or PerSession (any case, as the service's binder reads it) all give PerSession, + # the service default (MultiSeatOptions). + # It takes paths, not an install folder, so a test can feed it temp files and nothing here + # knows where MultiSeat lives. + # + # A regex, not Read-JsonLoose/ConvertFrom-Json: it must not throw on a half-written or + # hand-edited file, and a comment after the value ("PerSession", // note) must not matter. + # Full-line // comments are dropped first so a commented-out "AudioMode" line cannot win. + # Same approach as Get-ConfiguredAudioMode in prerequisites\install-prerequisites.ps1. + param([string[]]$ConfigPaths = @()) + foreach ($f in $ConfigPaths) { + if (-not $f -or -not (Test-Path -LiteralPath $f -PathType Leaf)) { continue } + try { $text = [System.IO.File]::ReadAllText($f) } catch { continue } + $text = [regex]::Replace($text, '(?m)^[ \t]*//[^\r\n]*', '') + $m = [regex]::Match($text, '"AudioMode"\s*:\s*"([^"]*)"') + if (-not $m.Success) { continue } + if ($m.Groups[1].Value -eq 'SharedHost') { return 'SharedHost' } + return 'PerSession' + } + return 'PerSession' +} + +function Test-VoiceMeeterNeeded { + # Only SharedHost routes seat audio through VoiceMeeter's devices. PerSession gives every + # seat its own RDP Remote Audio endpoint, so VoiceMeeter is neither started nor missed. + param([string]$AudioMode) + return ($AudioMode -eq 'SharedHost') +} + function Get-Sha256 { # .NET directly: Get-FileHash lives in a module that is not always loadable when PSModulePath # is shared between Windows PowerShell and PowerShell 7. diff --git a/src/MultiSeat.Tests/Scripts/InstallLibTests.cs b/src/MultiSeat.Tests/Scripts/InstallLibTests.cs index d372c8e..19d82ab 100644 --- a/src/MultiSeat.Tests/Scripts/InstallLibTests.cs +++ b/src/MultiSeat.Tests/Scripts/InstallLibTests.cs @@ -938,6 +938,126 @@ public void Instrument_check_a_check_stubbed_to_always_clear_lets_the_stop_run_w Assert.True(File.Exists(marker)); } + // -- AudioMode decision: does this install need VoiceMeeter? (#122) ---------------- + + private static (string Mode, bool Needed) AudioModeOf(string engine, params string[] paths) + { + var j = J(Ps(engine, + "$mode = Get-EffectiveAudioMode -ConfigPaths @(" + string.Join(",", paths.Select(Q)) + ")\n" + + "$r = [pscustomobject]@{ Mode = $mode; Needed = [bool](Test-VoiceMeeterNeeded -AudioMode $mode) }\n")); + return (j.GetProperty("Mode").GetString()!, j.GetProperty("Needed").GetBoolean()); + } + + private static string Cfg(string audioMode) => "{\n \"MultiSeat\": {\n \"AudioMode\": \"" + audioMode + "\"\n }\n}\n"; + + [Theory, MemberData(nameof(Engines))] + public void No_config_files_means_PerSession_and_no_VoiceMeeter(string engine) + { + using var t = new Temp(); + Assert.Equal(("PerSession", false), AudioModeOf(engine, t.Sub("appsettings.local.json"), t.Sub("appsettings.json"))); + var empty = J(Ps(engine, "$mode = Get-EffectiveAudioMode\n$r = [pscustomobject]@{ Mode = $mode }\n")); + Assert.Equal("PerSession", empty.GetProperty("Mode").GetString()); + } + + [Theory, MemberData(nameof(Engines))] + public void Base_file_PerSession_is_PerSession(string engine) + { + using var t = new Temp(); + Put(t.Root, "appsettings.json", Cfg("PerSession")); + Assert.Equal(("PerSession", false), AudioModeOf(engine, t.Sub("appsettings.local.json"), t.Sub("appsettings.json"))); + } + + [Theory, MemberData(nameof(Engines))] + public void Base_file_SharedHost_is_SharedHost_and_needs_VoiceMeeter(string engine) + { + using var t = new Temp(); + Put(t.Root, "appsettings.json", Cfg("SharedHost")); + Assert.Equal(("SharedHost", true), AudioModeOf(engine, t.Sub("appsettings.local.json"), t.Sub("appsettings.json"))); + } + + [Theory, MemberData(nameof(Engines))] + public void Local_file_overrides_the_base_file_both_ways(string engine) + { + using var t = new Temp(); + Put(t.Root, "appsettings.json", Cfg("SharedHost")); + Put(t.Root, "appsettings.local.json", Cfg("PerSession")); + Assert.Equal(("PerSession", false), AudioModeOf(engine, t.Sub("appsettings.local.json"), t.Sub("appsettings.json"))); + Put(t.Root, "appsettings.local.json", Cfg("SharedHost")); + Assert.Equal(("SharedHost", true), AudioModeOf(engine, t.Sub("appsettings.local.json"), t.Sub("appsettings.json"))); + Put(t.Root, "appsettings.json", Cfg("PerSession")); + Assert.Equal(("SharedHost", true), AudioModeOf(engine, t.Sub("appsettings.local.json"), t.Sub("appsettings.json"))); + } + + [Theory, MemberData(nameof(Engines))] + public void A_local_file_without_the_key_leaves_the_base_file_in_charge(string engine) + { + using var t = new Temp(); + Put(t.Root, "appsettings.json", Cfg("SharedHost")); + Put(t.Root, "appsettings.local.json", "{ \"MultiSeat\": { \"ApiKey\": \"x\" } }"); + Assert.Equal(("SharedHost", true), AudioModeOf(engine, t.Sub("appsettings.local.json"), t.Sub("appsettings.json"))); + } + + [Theory, MemberData(nameof(Engines))] + public void Comments_and_a_byte_order_mark_do_not_break_the_read(string engine) + { + using var t = new Temp(); + // Full-line and trailing // comments (Windows PowerShell 5.1's ConvertFrom-Json rejects both), + // a commented-out AudioMode line that must NOT win, and a UTF-8 BOM. + var text = "// header comment\n{\n \"MultiSeat\": {\n // \"AudioMode\": \"PerSession\",\n" + + " \"AudioMode\": \"SharedHost\", // trailing note\n \"Url\": \"http://localhost\" // another\n }\n}\n"; + Put(t.Root, "appsettings.json", new byte[] { 0xEF, 0xBB, 0xBF }.Concat(Encoding.UTF8.GetBytes(text)).ToArray()); + Assert.Equal(("SharedHost", true), AudioModeOf(engine, t.Sub("appsettings.json"))); + } + + [Theory, MemberData(nameof(Engines))] + public void A_commented_out_AudioMode_line_alone_means_PerSession(string engine) + { + using var t = new Temp(); + Put(t.Root, "appsettings.json", "{\n // \"AudioMode\": \"SharedHost\"\n}\n"); + Assert.Equal(("PerSession", false), AudioModeOf(engine, t.Sub("appsettings.json"))); + } + + [Theory, MemberData(nameof(Engines))] + public void Garbage_and_unreadable_files_mean_PerSession(string engine) + { + using var t = new Temp(); + Put(t.Root, "appsettings.json", "\u0001\u0002 not json at all {{{ \"AudioMode"); + Assert.Equal(("PerSession", false), AudioModeOf(engine, t.Sub("appsettings.json"))); + + Put(t.Root, "locked.json", Cfg("SharedHost")); + using (new FileStream(t.Sub("locked.json"), FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + Assert.Equal(("PerSession", false), AudioModeOf(engine, t.Sub("locked.json"))); + } + // A directory where a file should be. + Directory.CreateDirectory(t.Sub("dir.json")); + Assert.Equal(("PerSession", false), AudioModeOf(engine, t.Sub("dir.json"))); + } + + [Theory, MemberData(nameof(Engines))] + public void An_unrecognised_value_means_PerSession(string engine) + { + using var t = new Temp(); + Put(t.Root, "appsettings.json", Cfg("Banana")); + Assert.Equal(("PerSession", false), AudioModeOf(engine, t.Sub("appsettings.json"))); + Put(t.Root, "appsettings.json", Cfg("")); + Assert.Equal(("PerSession", false), AudioModeOf(engine, t.Sub("appsettings.json"))); + } + + [Theory, MemberData(nameof(Engines))] + public void VoiceMeeter_is_needed_only_for_SharedHost(string engine) + { + var j = J(Ps(engine, + "$r = [pscustomobject]@{ Shared = [bool](Test-VoiceMeeterNeeded -AudioMode 'SharedHost');" + + " Per = [bool](Test-VoiceMeeterNeeded -AudioMode 'PerSession');" + + " Empty = [bool](Test-VoiceMeeterNeeded -AudioMode '');" + + " Other = [bool](Test-VoiceMeeterNeeded -AudioMode 'Banana') }\n")); + Assert.True(j.GetProperty("Shared").GetBoolean()); + Assert.False(j.GetProperty("Per").GetBoolean()); + Assert.False(j.GetProperty("Empty").GetBoolean()); + Assert.False(j.GetProperty("Other").GetBoolean()); + } + // -- Wiring of install-service.ps1 itself ---------------------------------------- private static string[] InstallerLines() => File.ReadAllLines(Path.Combine(RepoRoot, "scripts", "install-service.ps1")); @@ -990,4 +1110,34 @@ public void The_installer_runs_the_steam_preflight_before_it_can_stop_the_servic Assert.True(iBlocked > iStop && iExit > iBlocked && iExit < iInstall, "a block must exit non-zero before the install folder is replaced"); } + + [Fact] + public void The_VoiceMeeter_step_runs_only_when_the_AudioMode_decision_says_so() + { + var lines = InstallerLines(); + var iGuard = Array.FindIndex(lines, l => l.TrimStart().StartsWith("if (-not (Test-VoiceMeeterNeeded")); + Assert.True(iGuard > 0, "the VoiceMeeter block must be guarded by Test-VoiceMeeterNeeded"); + var iElse = Array.FindIndex(lines, iGuard, l => l.TrimStart().StartsWith("} elseif ($vmExe) {")); + var iEnd = Array.FindIndex(lines, iGuard, l => l.Contains("-- Standalone Apollo coexistence")); + Assert.True(iElse > iGuard && iEnd > iElse, "expected: guard, then the SharedHost branch, then the next section"); + + // The guard's own branch (PerSession) must neither start VoiceMeeter nor print the NOTE. + var skipBranch = string.Join("\n", lines.Skip(iGuard + 1).Take(iElse - iGuard - 1)); + Assert.DoesNotContain("Start-Process", skipBranch); + Assert.DoesNotContain("not found", skipBranch); + Assert.Contains("VoiceMeeter not needed", skipBranch); + + // Both the start and the NOTE live in the SharedHost branches, after the guard. + var iStart = Array.FindIndex(lines, l => l.Contains("Start-Process $vmExe")); + var iNote = Array.FindIndex(lines, l => l.Contains("VoiceMeeter not found under VB")); + Assert.True(iStart > iElse && iStart < iEnd, "Start-Process of VoiceMeeter must sit inside the guarded branch"); + Assert.True(iNote > iElse && iNote < iEnd, "the 'VoiceMeeter not found' NOTE must sit inside the guarded branch"); + + // The config is read from the deployed folder BEFORE Install-Payload replaces it. + var iRead = Array.FindIndex(lines, l => l.Contains("Get-EffectiveAudioMode -ConfigPaths")); + var iInstall = Array.FindIndex(lines, l => l.Contains("Install-Payload -Stage")); + Assert.True(iRead > 0 && iRead < iGuard && iGuard < iInstall, "AudioMode must be read before the guard and before Install-Payload"); + Assert.Contains("appsettings.local.json", lines[iRead + 1]); + Assert.Contains("appsettings.json", lines[iRead + 2]); + } }