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]); + } }