├── .gitignore ├── Events.ps1 ├── HDRController.dll ├── Helpers.ps1 ├── Install.bat ├── Installer.ps1 ├── Packager.ps1 ├── Readme.md ├── StreamMonitor.ps1 ├── UndoScript.ps1 ├── Uninstall.bat ├── log.txt ├── output.txt └── settings.json /.gitignore: -------------------------------------------------------------------------------- 1 | Releases 2 | assets/* 3 | logs 4 | -------------------------------------------------------------------------------- /Events.ps1: -------------------------------------------------------------------------------- 1 | # Determine the path of the currently running script and set the working directory to that path 2 | param( 3 | [Parameter(Position = 0, Mandatory = $true)] 4 | [Alias("n")] 5 | [string]$scriptName 6 | ) 7 | $path = (Split-Path $MyInvocation.MyCommand.Path -Parent) 8 | Set-Location $path 9 | . .\Helpers.ps1 -n $scriptName 10 | 11 | # Load settings from a JSON file located in the same directory as the script 12 | $settings = Get-Settings 13 | 14 | # Initialize a script scoped dictionary to store variables. 15 | # This dictionary is used to pass parameters to functions that might not have direct access to script scope, like background jobs. 16 | if (-not $script:arguments) { 17 | $script:arguments = @{} 18 | } 19 | 20 | 21 | $dllPath = "$($PWD.Path)\HDRController.dll".Replace("\", "\\") 22 | 23 | # Define the function signature 24 | Add-Type -TypeDefinition @" 25 | using System.Runtime.InteropServices; 26 | 27 | public static class HDRController { 28 | [DllImport("$dllPath", EntryPoint = "GetGlobalHDRState")] 29 | public static extern bool GetGlobalHDRState(); 30 | 31 | 32 | [DllImport("$dllPath", EntryPoint = "EnableGlobalHDRState")] 33 | public static extern void EnableGlobalHDRState(); 34 | 35 | [DllImport("$dllPath", EntryPoint = "DisableGlobalHDRState")] 36 | public static extern void DisableGlobalHDRState(); 37 | } 38 | "@ 39 | 40 | 41 | # Function to execute at the start of a stream 42 | function OnStreamStart() { 43 | $hostHDR = [HDRController]::GetGlobalHDRState() 44 | 45 | $script:arguments.Add("hostHDR", $hostHDR) 46 | $clientHdrState = [System.Boolean]::Parse($env:SUNSHINE_CLIENT_HDR) 47 | Write-Host "Current (Host) HDR State: $hostHDR" 48 | Write-Host "Current (Client) HDR State: $clientHdrState" 49 | 50 | if ($hostHDR -ne $clientHdrState) { 51 | if ($clientHdrState) { 52 | Write-Host "Enabling HDR" 53 | [HDRController]::EnableGlobalHDRState() 54 | } 55 | else { 56 | Write-Host "Turning off HDR" 57 | [HDRController]::DisableGlobalHDRState() 58 | } 59 | } 60 | 61 | if($settings.IDDSampleFix){ 62 | if([HDRController]::GetGlobalHDRState() -and $clientHdrState){ 63 | Write-Host "IDDSample Fix is enabled, now automating turning HDR off and on again." 64 | [HDRController]::DisableGlobalHDRState() 65 | [HDRController]::EnableGlobalHDRState() 66 | Write-Host "HDR has been toggled successfully!" 67 | } 68 | } 69 | elseif($hostHDR -eq $clientHdrState) { 70 | Write-Host "Client already matches the host for HDR, no changes will be applied." 71 | } 72 | } 73 | 74 | # Function to execute at the end of a stream. This function is called in a background job, 75 | # and hence doesn't have direct access to the script scope. $kwargs is passed explicitly to emulate script:arguments. 76 | function OnStreamEnd($kwargs) { 77 | 78 | if ($kwargs["hostHDR"]) { 79 | Write-Host "Enabling HDR" 80 | [HDRController]::EnableGlobalHDRState() 81 | } 82 | else { 83 | Write-Host "Turning off HDR" 84 | [HDRController]::DisableGlobalHDRState() 85 | } 86 | 87 | return $true 88 | } -------------------------------------------------------------------------------- /HDRController.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nonary/AutoHDRSwitch/d9c0d40222f269ecb3c4a13a08d3f35bdb4601da/HDRController.dll -------------------------------------------------------------------------------- /Helpers.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Position = 0, Mandatory = $true)] 3 | [Alias("n")] 4 | [string]$scriptName, 5 | [Alias("t")] 6 | [Parameter(Position = 1, Mandatory = $false)] 7 | [int]$terminate 8 | ) 9 | $path = (Split-Path $MyInvocation.MyCommand.Path -Parent) 10 | Set-Location $path 11 | $script:attempt = 0 12 | function OnStreamEndAsJob() { 13 | return Start-Job -Name "$scriptName-OnStreamEnd" -ScriptBlock { 14 | param($path, $scriptName, $arguments) 15 | 16 | function Write-Debug($message){ 17 | if ($arguments['debug']) { 18 | Write-Host "DEBUG: $message" 19 | } 20 | } 21 | 22 | Write-Host $arguments 23 | 24 | Write-Debug "Setting location to $path" 25 | Set-Location $path 26 | Write-Debug "Loading Helpers.ps1 with script name $scriptName" 27 | . .\Helpers.ps1 -n $scriptName 28 | Write-Debug "Loading Events.ps1 with script name $scriptName" 29 | . .\Events.ps1 -n $scriptName 30 | 31 | Write-Host "Stream has ended, now invoking code" 32 | Write-Debug "Creating pipe with name $scriptName-OnStreamEnd" 33 | $job = Create-Pipe -pipeName "$scriptName-OnStreamEnd" 34 | 35 | while ($true) { 36 | $maxTries = 25 37 | $tries = 0 38 | 39 | Write-Debug "Checking job state: $($job.State)" 40 | if ($job.State -eq "Completed") { 41 | Write-Host "Another instance of $scriptName has been started again. This current session is now redundant and will terminate without further action." 42 | Write-Debug "Job state is 'Completed'. Exiting loop." 43 | break; 44 | } 45 | 46 | Write-Debug "Invoking OnStreamEnd with arguments: $arguments" 47 | if ((OnStreamEnd $arguments)) { 48 | Write-Debug "OnStreamEnd returned true. Exiting loop." 49 | break; 50 | } 51 | 52 | 53 | if ((IsCurrentlyStreaming)) { 54 | Write-Host "Streaming is active. To prevent potential conflicts, this script will now terminate prematurely." 55 | } 56 | 57 | 58 | while (($tries -lt $maxTries) -and ($job.State -ne "Completed")) { 59 | Start-Sleep -Milliseconds 200 60 | $tries++ 61 | } 62 | } 63 | 64 | Write-Debug "Sending 'Terminate' message to pipe $scriptName-OnStreamEnd" 65 | Send-PipeMessage "$scriptName-OnStreamEnd" Terminate 66 | } -ArgumentList $path, $scriptName, $script:arguments 67 | } 68 | 69 | 70 | function IsCurrentlyStreaming() { 71 | $sunshineProcess = Get-Process sunshine -ErrorAction SilentlyContinue 72 | 73 | if ($null -eq $sunshineProcess) { 74 | return $false 75 | } 76 | return $null -ne (Get-NetUDPEndpoint -OwningProcess $sunshineProcess.Id -ErrorAction Ignore) 77 | } 78 | 79 | function Stop-Script() { 80 | Send-PipeMessage -pipeName $scriptName Terminate 81 | } 82 | function Send-PipeMessage($pipeName, $message) { 83 | Write-Debug "Attempting to send message to pipe: $pipeName" 84 | 85 | $pipeExists = Get-ChildItem -Path "\\.\pipe\" | Where-Object { $_.Name -eq $pipeName } 86 | Write-Debug "Pipe exists check: $($pipeExists.Length -gt 0)" 87 | 88 | if ($pipeExists.Length -gt 0) { 89 | $pipe = New-Object System.IO.Pipes.NamedPipeClientStream(".", $pipeName, [System.IO.Pipes.PipeDirection]::Out) 90 | Write-Debug "Connecting to pipe: $pipeName" 91 | 92 | $pipe.Connect(3000) 93 | $streamWriter = New-Object System.IO.StreamWriter($pipe) 94 | Write-Debug "Sending message: $message" 95 | 96 | $streamWriter.WriteLine($message) 97 | try { 98 | $streamWriter.Flush() 99 | $streamWriter.Dispose() 100 | $pipe.Dispose() 101 | Write-Debug "Message sent and resources disposed successfully." 102 | } 103 | catch { 104 | Write-Debug "Error during disposal: $_" 105 | # We don't care if the disposal fails, this is common with async pipes. 106 | # Also, this powershell script will terminate anyway. 107 | } 108 | } 109 | else { 110 | Write-Debug "Pipe not found: $pipeName" 111 | } 112 | } 113 | 114 | 115 | function Create-Pipe($pipeName) { 116 | return Start-Job -Name "$pipeName-PipeJob" -ScriptBlock { 117 | param($pipeName, $scriptName) 118 | Register-EngineEvent -SourceIdentifier $scriptName -Forward 119 | 120 | $pipe = New-Object System.IO.Pipes.NamedPipeServerStream($pipeName, [System.IO.Pipes.PipeDirection]::In, 10, [System.IO.Pipes.PipeTransmissionMode]::Byte, [System.IO.Pipes.PipeOptions]::Asynchronous) 121 | 122 | $streamReader = New-Object System.IO.StreamReader($pipe) 123 | Write-Host "Waiting for named pipe to recieve kill command" 124 | $pipe.WaitForConnection() 125 | 126 | $message = $streamReader.ReadLine() 127 | if ($message) { 128 | Write-Host "Terminating pipe..." 129 | $pipe.Dispose() 130 | $streamReader.Dispose() 131 | New-Event -SourceIdentifier $scriptName -MessageData $message 132 | } 133 | } -ArgumentList $pipeName, $scriptName 134 | } 135 | 136 | function Remove-OldLogs { 137 | 138 | # Get all log files in the directory 139 | $logFiles = Get-ChildItem -Path './logs' -Filter "log_*.txt" -ErrorAction SilentlyContinue 140 | 141 | # Sort the files by creation time, oldest first 142 | $sortedFiles = $logFiles | Sort-Object -Property CreationTime -ErrorAction SilentlyContinue 143 | 144 | if ($sortedFiles) { 145 | # Calculate how many files to delete 146 | $filesToDelete = $sortedFiles.Count - 10 147 | 148 | # Check if there are more than 10 files 149 | if ($filesToDelete -gt 0) { 150 | # Delete the oldest files, keeping the latest 10 151 | $sortedFiles[0..($filesToDelete - 1)] | Remove-Item -Force 152 | } 153 | } 154 | } 155 | 156 | function Start-Logging { 157 | # Get the current timestamp 158 | $timeStamp = [int][double]::Parse((Get-Date -UFormat "%s")) 159 | $logDirectory = "./logs" 160 | 161 | # Define the path and filename for the log file 162 | $logFileName = "log_$timeStamp.txt" 163 | $logFilePath = Join-Path $logDirectory $logFileName 164 | 165 | # Check if the log directory exists, and create it if it does not 166 | if (-not (Test-Path $logDirectory)) { 167 | New-Item -Path $logDirectory -ItemType Directory 168 | } 169 | 170 | # Start logging to the log file 171 | Start-Transcript -Path $logFilePath 172 | } 173 | 174 | 175 | 176 | function Stop-Logging { 177 | Stop-Transcript 178 | } 179 | 180 | 181 | function Get-Settings { 182 | # Read the file content 183 | $jsonContent = Get-Content -Path ".\settings.json" -Raw 184 | 185 | # Remove single line comments 186 | $jsonContent = $jsonContent -replace '//.*', '' 187 | 188 | # Remove multi-line comments 189 | $jsonContent = $jsonContent -replace '/\*[\s\S]*?\*/', '' 190 | 191 | # Remove trailing commas from arrays and objects 192 | $jsonContent = $jsonContent -replace ',\s*([\]}])', '$1' 193 | 194 | try { 195 | # Convert JSON content to PowerShell object 196 | $jsonObject = $jsonContent | ConvertFrom-Json 197 | return $jsonObject 198 | } 199 | catch { 200 | Write-Error "Failed to parse JSON: $_" 201 | } 202 | } 203 | 204 | 205 | function Update-JsonProperty { 206 | [CmdletBinding()] 207 | param( 208 | [Parameter(Mandatory = $true)] 209 | [string]$FilePath, 210 | 211 | [Parameter(Mandatory = $true)] 212 | [string]$Property, 213 | 214 | [Parameter(Mandatory = $true)] 215 | [object]$NewValue 216 | ) 217 | 218 | # Read the file as a single string. 219 | $content = Get-Content -Path $FilePath -Raw 220 | 221 | # Remove comments (both // and /* */ style) and trailing commas 222 | $strippedContent = $content -replace '//.*?(\r?\n|$)', '$1' # Remove single line comments 223 | $strippedContent = $strippedContent -replace '/\*[\s\S]*?\*/', '' # Remove multi-line comments 224 | $strippedContent = $strippedContent -replace ',(\s*[\]}])', '$1' # Remove trailing commas 225 | 226 | # Format the new value properly 227 | if ($NewValue -is [string]) { 228 | # Convert the string to a JSON-compliant string. 229 | $formattedValue = (ConvertTo-Json $NewValue -Compress) 230 | } else { 231 | $formattedValue = $NewValue.ToString().ToLower() 232 | } 233 | 234 | # Build a regex pattern for matching the property. 235 | $escapedProperty = [regex]::Escape($Property) 236 | $pattern = '"' + $escapedProperty + '"\s*:\s*[^,}\r\n]+' 237 | 238 | # Check if the property exists in the stripped content 239 | if ($strippedContent -match $pattern) { 240 | # Property exists, just update its value in the original content 241 | $replacement = '"' + $Property + '": ' + $formattedValue 242 | $updatedContent = [regex]::Replace($content, $pattern, $replacement) 243 | } else { 244 | # Property doesn't exist, need to add it 245 | # Find the last property in the JSON object 246 | $lastPropPattern = ',?\s*"([^"]+)"\s*:\s*[^,}\r\n]+' 247 | 248 | if ($content -match '}\s*$') { 249 | # Find the last occurrence of a property 250 | $lastMatch = [regex]::Matches($content, $lastPropPattern)[-1] 251 | $lastPropIndex = $lastMatch.Index + $lastMatch.Length 252 | 253 | # Check if the last property ends with a comma 254 | $endsWithComma = $content.Substring($lastPropIndex).TrimStart() -match '^\s*,' 255 | 256 | # Prepare the new property string 257 | $newPropString = "" 258 | if (!$endsWithComma) { 259 | $newPropString += "," 260 | } 261 | $newPropString += "`n `"$Property`": $formattedValue" 262 | 263 | # Insert the new property before the closing brace 264 | $closingBraceIndex = $content.LastIndexOf('}') 265 | $updatedContent = $content.Substring(0, $closingBraceIndex).TrimEnd() + 266 | $newPropString + 267 | "`n}" + 268 | $content.Substring($closingBraceIndex + 1) 269 | } else { 270 | Write-Error "Unable to find a proper location to insert the new property. JSON file may be malformed." 271 | return 272 | } 273 | } 274 | 275 | # Write the updated content back. 276 | Set-Content -Path $FilePath -Value $updatedContent 277 | } 278 | 279 | 280 | 281 | function Wait-ForStreamEndJobToComplete() { 282 | $job = OnStreamEndAsJob 283 | while ($job.State -ne "Completed") { 284 | $job | Receive-Job 285 | Start-Sleep -Seconds 1 286 | } 287 | $job | Wait-Job | Receive-Job 288 | } 289 | 290 | 291 | if ($terminate -eq 1) { 292 | Write-Host "Stopping Script" 293 | Stop-Script | Out-Null 294 | } 295 | -------------------------------------------------------------------------------- /Install.bat: -------------------------------------------------------------------------------- 1 | powershell.exe -executionpolicy bypass -file ./Installer.ps1 -n AutoHDR -i 1 2 | -------------------------------------------------------------------------------- /Installer.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Position = 0, Mandatory = $true)] 3 | [Alias("n")] 4 | [string]$scriptName, 5 | 6 | [Parameter(Position = 1, Mandatory = $true)] 7 | [Alias("i")] 8 | [string]$install 9 | ) 10 | Set-Location (Split-Path $MyInvocation.MyCommand.Path -Parent) 11 | $filePath = $($MyInvocation.MyCommand.Path) 12 | $scriptRoot = Split-Path $filePath -Parent 13 | $scriptPath = "$scriptRoot\StreamMonitor.ps1" 14 | . .\Helpers.ps1 -n $scriptName 15 | $settings = Get-Settings 16 | 17 | # This script modifies the global_prep_cmd setting in the Sunshine/Apollo configuration files 18 | 19 | function Test-UACEnabled { 20 | $key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' 21 | $uacEnabled = Get-ItemProperty -Path $key -Name 'EnableLUA' 22 | return [bool]$uacEnabled.EnableLUA 23 | } 24 | 25 | $isAdmin = [bool]([System.Security.Principal.WindowsIdentity]::GetCurrent().Groups -match 'S-1-5-32-544') 26 | 27 | # If the user is not an administrator and UAC is enabled, re-launch the script with elevated privileges 28 | if (-not $isAdmin -and (Test-UACEnabled)) { 29 | Start-Process powershell.exe -Verb RunAs -ArgumentList "-ExecutionPolicy Bypass -NoExit -File `"$filePath`" -n `"$scriptName`" -i `"$install`"" 30 | exit 31 | } 32 | 33 | function Find-ConfigurationFiles { 34 | $sunshineDefaultPath = "C:\Program Files\Sunshine\config\sunshine.conf" 35 | $apolloDefaultPath = "C:\Program Files\Apollo\config\sunshine.conf" 36 | 37 | $sunshineFound = Test-Path $sunshineDefaultPath 38 | $apolloFound = Test-Path $apolloDefaultPath 39 | $configPaths = @{} 40 | 41 | # If either one is found, use their default paths 42 | if ($sunshineFound) { 43 | $configPaths["Sunshine"] = $sunshineDefaultPath 44 | Write-Host "Sunshine config found at: $sunshineDefaultPath" 45 | } 46 | 47 | if ($apolloFound) { 48 | $configPaths["Apollo"] = $apolloDefaultPath 49 | Write-Host "Apollo config found at: $apolloDefaultPath" 50 | } 51 | 52 | # Only prompt if neither is found 53 | if (-not $sunshineFound -and -not $apolloFound) { 54 | # Show error message dialog 55 | [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") | Out-Null 56 | [System.Windows.Forms.MessageBox]::Show("Neither Sunshine nor Apollo configuration could be found. Please locate a configuration file.", "Error", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null 57 | 58 | # Open file dialog 59 | $fileDialog = New-Object System.Windows.Forms.OpenFileDialog 60 | $fileDialog.Title = "Open sunshine.conf" 61 | $fileDialog.Filter = "Configuration files (*.conf)|*.conf" 62 | 63 | if ($fileDialog.ShowDialog() -eq "OK") { 64 | $selectedPath = $fileDialog.FileName 65 | # Check if the selected path is valid 66 | if (Test-Path $selectedPath) { 67 | Write-Host "File selected: $selectedPath" 68 | if ($selectedPath -like "*Apollo*") { 69 | $configPaths["Apollo"] = $selectedPath 70 | } else { 71 | $configPaths["Sunshine"] = $selectedPath 72 | } 73 | } 74 | else { 75 | Write-Error "Invalid file path selected." 76 | exit 1 77 | } 78 | } 79 | else { 80 | Write-Error "Configuration file dialog was canceled or no valid file was selected." 81 | exit 1 82 | } 83 | } 84 | 85 | return $configPaths 86 | } 87 | 88 | # Find configuration files 89 | $configPaths = Find-ConfigurationFiles 90 | 91 | # Save paths to settings 92 | if ($configPaths.ContainsKey("Sunshine")) { 93 | Update-JsonProperty -FilePath "./settings.json" -Property "sunshineConfigPath" -NewValue $configPaths["Sunshine"] 94 | } 95 | if ($configPaths.ContainsKey("Apollo")) { 96 | Update-JsonProperty -FilePath "./settings.json" -Property "apolloConfigPath" -NewValue $configPaths["Apollo"] 97 | } 98 | 99 | # Get the current value of global_prep_cmd from the configuration file 100 | function Get-GlobalPrepCommand { 101 | param ( 102 | [string]$ConfigPath 103 | ) 104 | 105 | # Read the contents of the configuration file into an array of strings 106 | $config = Get-Content -Path $ConfigPath 107 | 108 | # Find the line that contains the global_prep_cmd setting 109 | $globalPrepCmdLine = $config | Where-Object { $_ -match '^global_prep_cmd\s*=' } 110 | 111 | # Extract the current value of global_prep_cmd 112 | if ($globalPrepCmdLine -match '=\s*(.+)$') { 113 | return $matches[1] 114 | } 115 | else { 116 | Write-Information "Unable to extract current value of global_prep_cmd, this probably means user has not setup prep commands yet." 117 | return [object[]]@() 118 | } 119 | } 120 | 121 | # Remove any existing commands that contain the scripts name from the global_prep_cmd value 122 | function Remove-Command { 123 | param ( 124 | [string]$ConfigPath 125 | ) 126 | 127 | # Get the current value of global_prep_cmd as a JSON string 128 | $globalPrepCmdJson = Get-GlobalPrepCommand -ConfigPath $ConfigPath 129 | 130 | # Convert the JSON string to an array of objects 131 | $globalPrepCmdArray = $globalPrepCmdJson | ConvertFrom-Json 132 | $filteredCommands = @() 133 | 134 | # Remove any existing matching Commands 135 | for ($i = 0; $i -lt $globalPrepCmdArray.Count; $i++) { 136 | if (-not ($globalPrepCmdArray[$i].do -like "*$scriptRoot*")) { 137 | $filteredCommands += $globalPrepCmdArray[$i] 138 | } 139 | } 140 | 141 | return [object[]]$filteredCommands 142 | } 143 | 144 | # Set a new value for global_prep_cmd in the configuration file 145 | function Set-GlobalPrepCommand { 146 | param ( 147 | [string]$ConfigPath, 148 | # The new value for global_prep_cmd as an array of objects 149 | [object[]]$Value 150 | ) 151 | 152 | if ($null -eq $Value) { 153 | $Value = [object[]]@() 154 | } 155 | 156 | # Read the contents of the configuration file into an array of strings 157 | $config = Get-Content -Path $ConfigPath 158 | 159 | # Get the current value of global_prep_cmd as a JSON string 160 | $currentValueJson = Get-GlobalPrepCommand -ConfigPath $ConfigPath 161 | 162 | # Convert the new value to a JSON string - ensure proper JSON types 163 | $newValueJson = ConvertTo-Json -InputObject $Value -Compress -Depth 10 164 | # Fix boolean values to be JSON compliant 165 | $newValueJson = $newValueJson -replace '"elevated"\s*:\s*"true"', '"elevated": true' 166 | $newValueJson = $newValueJson -replace '"elevated"\s*:\s*"false"', '"elevated": false' 167 | 168 | # Replace the current value with the new value in the config array 169 | try { 170 | $config = $config -replace [regex]::Escape($currentValueJson), $newValueJson 171 | } 172 | catch { 173 | # If it failed, it probably does not exist yet. 174 | # In the event the config only has one line, we will cast this to an object array so it appends a new line automatically. 175 | 176 | if ($Value.Length -eq 0) { 177 | [object[]]$config += "global_prep_cmd = []" 178 | } 179 | else { 180 | [object[]]$config += "global_prep_cmd = $($newValueJson)" 181 | } 182 | } 183 | # Write the modified config array back to the file 184 | $config | Set-Content -Path $ConfigPath -Force 185 | } 186 | 187 | function OrderCommands($commands, $scriptNames) { 188 | $orderedCommands = New-Object System.Collections.ArrayList 189 | 190 | if($commands -isnot [System.Collections.IEnumerable]) { 191 | # PowerShell likes to magically change types on you, so we have to check for this 192 | $commands = @(, $commands) 193 | } 194 | 195 | $orderedCommands.AddRange($commands) 196 | 197 | for ($i = 1; $i -lt $scriptNames.Count; $i++) { 198 | if ($i - 1 -lt 0) { 199 | continue 200 | } 201 | 202 | $before = $scriptNames[$i - 1] 203 | $after = $scriptNames[$i] 204 | 205 | $afterCommand = $orderedCommands | Where-Object { $_.do -like "*$after*" -or $_.undo -like "*$after*" } | Select-Object -First 1 206 | 207 | $beforeIndex = $null 208 | for ($j = 0; $j -lt $orderedCommands.Count; $j++) { 209 | if ($orderedCommands[$j].do -like "*$before*" -or $orderedCommands[$j].undo -like "*$before*") { 210 | $beforeIndex = $j 211 | break 212 | } 213 | } 214 | $afterIndex = $null 215 | for ($j = 0; $j -lt $orderedCommands.Count; $j++) { 216 | if ($orderedCommands[$j].do -like "*$after*" -or $orderedCommands[$j].undo -like "*$after*") { 217 | $afterIndex = $j 218 | break 219 | } 220 | } 221 | 222 | if ($null -ne $afterIndex -and ($afterIndex -lt $beforeIndex)) { 223 | $orderedCommands.RemoveAt($afterIndex) 224 | $orderedCommands.Insert($beforeIndex, $afterCommand) 225 | } 226 | } 227 | 228 | $orderedCommands 229 | } 230 | 231 | function Add-Command { 232 | param ( 233 | [string]$ConfigPath 234 | ) 235 | 236 | # Remove any existing commands that contain the scripts name from the global_prep_cmd value 237 | $globalPrepCmdArray = Remove-Command -ConfigPath $ConfigPath 238 | 239 | $command = [PSCustomObject]@{ 240 | do = "powershell.exe -executionpolicy bypass -file `"$($scriptPath)`" -n $scriptName" 241 | elevated = $false 242 | undo = "powershell.exe -executionpolicy bypass -file `"$($scriptRoot)\UndoScript.ps1`" -n $scriptName" 243 | } 244 | 245 | # Add the new object to the global_prep_cmd array 246 | [object[]]$globalPrepCmdArray += $command 247 | 248 | return [object[]]$globalPrepCmdArray 249 | } 250 | 251 | # Process each found configuration file 252 | foreach ($key in $configPaths.Keys) { 253 | $configPath = $configPaths[$key] 254 | 255 | $commands = @() 256 | if ($install -eq 1) { 257 | $commands = Add-Command -ConfigPath $configPath 258 | } 259 | else { 260 | $commands = Remove-Command -ConfigPath $configPath 261 | } 262 | 263 | if ($settings.installationOrderPreferences.enabled) { 264 | $commands = OrderCommands $commands $settings.installationOrderPreferences.scriptNames 265 | } 266 | 267 | Set-GlobalPrepCommand -ConfigPath $configPath -Value $commands 268 | 269 | if ($key -eq "Sunshine") { 270 | $service = Get-Service -ErrorAction Ignore | Where-Object { $_.Name -eq 'sunshinesvc' -or $_.Name -eq 'SunshineService' } 271 | $service | Restart-Service -WarningAction SilentlyContinue 272 | Write-Host "Sunshine configuration updated successfully!" 273 | } elseif ($key -eq "Apollo") { 274 | $service = Get-Service -ErrorAction Ignore | Where-Object { $_.Name -eq 'Apollo Service' } 275 | # Uncomment the line below if you want to automatically restart the service 276 | $service | Restart-Service -WarningAction SilentlyContinue 277 | Write-Host "Apollo configuration updated successfully!" 278 | } 279 | } 280 | 281 | Write-Host "If you didn't see any errors, that means the script installed without issues! You can close this window." 282 | -------------------------------------------------------------------------------- /Packager.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [string]$scriptName 3 | ) 4 | 5 | $whiteListedEntries = @("Packager.ps1", "Releases", "*.txt", ".gitignore", "logs", ".vscode") 6 | $releaseBasePath = "Releases" 7 | $releasePath = Join-Path -Path $releaseBasePath -ChildPath $scriptName 8 | $assetsPath = Join-Path -Path $releaseBasePath -ChildPath "assets" 9 | 10 | # Remove existing release directory if it exists 11 | Remove-Item -Force $releasePath -Recurse -ErrorAction SilentlyContinue 12 | 13 | # Ensure the Releases directory exists 14 | if (-not (Test-Path -Path $releaseBasePath)) { 15 | New-Item -ItemType Directory -Path $releaseBasePath | Out-Null 16 | } 17 | 18 | # Ensure the assets directory exists 19 | if (-not (Test-Path -Path $assetsPath)) { 20 | New-Item -ItemType Directory -Path $assetsPath | Out-Null 21 | } 22 | 23 | # Get all top-level items from the current directory, excluding the Releases directory 24 | $items = Get-ChildItem -Path . | Where-Object { 25 | $_.FullName -notmatch "^\.\\Releases(\\|$)" 26 | } 27 | 28 | # Create a hashtable for quick whitelist lookup 29 | $whitelistHash = @{} 30 | foreach ($whitelist in $whiteListedEntries) { 31 | $whitelistHash[$whitelist] = $true 32 | } 33 | 34 | # Create a hashtable to store asset files and directories for quick lookup 35 | $assetItems = @{} 36 | Get-ChildItem -Path $assetsPath -Recurse | ForEach-Object { 37 | $assetItems[$_.Name] = $_.FullName 38 | } 39 | 40 | # Filter and replace items efficiently 41 | $filteredItems = @() 42 | foreach ($item in $items) { 43 | $itemName = $item.Name 44 | 45 | # Check for whitelist 46 | $isWhitelisted = $false 47 | foreach ($key in $whitelistHash.Keys) { 48 | if ($itemName -like $key) { 49 | $isWhitelisted = $true 50 | break 51 | } 52 | } 53 | 54 | if (-not $isWhitelisted) { 55 | if ($assetItems.ContainsKey($itemName)) { 56 | $filteredItems += Get-Item -Path $assetItems[$itemName] 57 | } else { 58 | $filteredItems += $item 59 | } 60 | } 61 | } 62 | 63 | # Create the release directory named after the script 64 | if (-not (Test-Path -Path $releasePath)) { 65 | New-Item -ItemType Directory -Path $releasePath | Out-Null 66 | } 67 | 68 | # Copy the filtered items to the release directory 69 | foreach ($item in $filteredItems) { 70 | $destinationPath = Join-Path -Path $releasePath -ChildPath $item.Name 71 | if ($item.PSIsContainer) { 72 | Copy-Item -Path $item.FullName -Destination $destinationPath -Recurse -Force 73 | } else { 74 | Copy-Item -Path $item.FullName -Destination $destinationPath -Force 75 | } 76 | } 77 | 78 | Write-Output "Files and directories have been copied to $releasePath" 79 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | ## Requirements: 2 | - Host must be Windows 3 | - HDR Capable Display 4 | - Sunshine 0.21.0 or higher 5 | 6 | ## Caveats: 7 | - If using Windows 11, you'll need to set the default terminal to Windows Console Host as there is currently a bug in Windows Terminal that prevents hidden consoles from working properly. 8 | * That can be changed at Settings > Privacy & security > Security > For developers > Terminal [Let Windows decide] >> (change to) >> Terminal [Windows Console Host] 9 | - Due to Windows API restrictions, this script does not work on cold reboots (hard crashes or shutdowns of your computer). 10 | * Fortunately recent changes to Sunshine makes this issue much easier to workaround. 11 | * Simply sign into the computer using the "Desktop" app on Moonlight, then end the stream, then start it again to resolve issue in this scenario. 12 | - The script will stop working if you move the folder, simply reinstall it to resolve that issue. 13 | 14 | ## What it Does: 15 | Checks to see if the last connected Moonlight client asked for HDR, if so, it will enable HDR. Otherwise, it will disable it. 16 | Once the stream ends, it will configure the last HDR setting prior to starting the stream. 17 | 18 | (Optionally) If enabled, will toggle HDR on and off automatically to fix issues with the IDDSampleDriver on overblown colors when streaming in HDR. 19 | 20 | ## Credits: 21 | The HDR toggling code is from the following repositories: 22 | - https://github.com/Codectory/AutoActions - The original developer of the HDR toggle code that made calling the DLL possible. 23 | - https://github.com/anaisbetts/AutoActions - She added two additional exported functions to make calling the DLL easier. 24 | 25 | ## Installation: 26 | 1. Store this folder in a location you intend to keep. If you delete this folder or move it, the automation will stop working. 27 | 2. If you intend on using IDDSampleDriver, which has known issues with HDR you should enable the "IDDSampleFix" setting located in the settings.json file. 28 | 3. To install, double click the Install.bat file. You may get a smart screen warning, this is normal. 29 | 4. To uninstall, do the same thing with Uninstall.bat. 30 | 31 | ### Recent Changes 32 | - Migrated to [Sunshine Script Installer Template](https://github.com/Nonary/SunshineScriptInstaller) 33 | - Added new option for fixing HDR issues on IDDSampleDriver displays. 34 | -------------------------------------------------------------------------------- /StreamMonitor.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Position = 0, Mandatory = $true)] 3 | [ValidateNotNullOrEmpty()] 4 | [Alias("n")] 5 | [string]$scriptName, 6 | 7 | [Parameter(Position = 1)] 8 | [Alias("sib")] 9 | [bool]$startInBackground 10 | ) 11 | $path = (Split-Path $MyInvocation.MyCommand.Path -Parent) 12 | Set-Location $path 13 | . .\Helpers.ps1 -n $scriptName 14 | . .\Events.ps1 -n $scriptName 15 | $settings = Get-Settings 16 | $DebugPreference = if ($settings.debug) { "Continue" } else { "SilentlyContinue" } 17 | 18 | # Since pre-commands in sunshine are synchronous, we'll launch this script again in another powershell process 19 | if ($startInBackground -eq $false) { 20 | $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition 21 | $arguments = "-ExecutionPolicy Bypass -Command `"& '$scriptPath\StreamMonitor.ps1' -scriptName $scriptName -sib 1`"" 22 | Start-Process powershell.exe -ArgumentList $arguments -WindowStyle Hidden 23 | Start-Sleep -Seconds $settings.startDelay 24 | exit 25 | } 26 | 27 | 28 | Remove-OldLogs 29 | Start-Logging 30 | 31 | # OPTIONAL MUTEX HANDLING 32 | # Create a mutex to prevent multiple instances of this script from running simultaneously. 33 | $lock = $false 34 | $mutex = New-Object System.Threading.Mutex($false, $scriptName, [ref]$lock) 35 | 36 | # Exit the script if another instance is already running. 37 | if (-not $mutex.WaitOne(0)) { 38 | Write-Host "Exiting: Another instance of the script is currently running." 39 | exit 40 | } 41 | 42 | if (-not $mutex) { 43 | ### If you don't use a mutex, you can optionally fan the hammer 44 | for ($i = 0; $i -lt 6; $i++) { 45 | Send-PipeMessage $scriptName NewSession 46 | Send-PipeMessage "$scriptName-OnStreamEnd" Terminate 47 | } 48 | } 49 | 50 | 51 | # END OF OPTIONAL MUTEX HANDLING 52 | 53 | 54 | try { 55 | 56 | # Asynchronously start the script, so we can use a named pipe to terminate it. 57 | Start-Job -Name "$($scriptName)Job" -ScriptBlock { 58 | param($path, $scriptName, $gracePeriod) 59 | . $path\Helpers.ps1 -n $scriptName 60 | $lastStreamed = Get-Date 61 | 62 | 63 | Register-EngineEvent -SourceIdentifier $scriptName -Forward 64 | New-Event -SourceIdentifier $scriptName -MessageData "Start" 65 | while ($true) { 66 | try { 67 | if ((IsCurrentlyStreaming)) { 68 | $lastStreamed = Get-Date 69 | } 70 | else { 71 | if (((Get-Date) - $lastStreamed).TotalSeconds -gt $gracePeriod) { 72 | New-Event -SourceIdentifier $scriptName -MessageData "GracePeriodExpired" 73 | break; 74 | } 75 | 76 | } 77 | } 78 | finally { 79 | Start-Sleep -Seconds 1 80 | } 81 | } 82 | 83 | } -ArgumentList $path, $scriptName, $settings.gracePeriod | Out-Null 84 | 85 | 86 | # This might look like black magic, but basically we don't have to monitor this pipe because it fires off an event. 87 | Create-Pipe $scriptName | Out-Null 88 | 89 | Write-Host "Waiting for the next event to be called... (for starting/ending stream)" 90 | while ($true) { 91 | Start-Sleep -Seconds 1 92 | $eventFired = Get-Event -SourceIdentifier $scriptName -ErrorAction SilentlyContinue 93 | if ($null -ne $eventFired) { 94 | $eventName = $eventFired.MessageData 95 | Write-Host "Processing event: $eventName" 96 | if ($eventName -eq "Start") { 97 | OnStreamStart 98 | } 99 | elseif ($eventName -eq "NewSession") { 100 | Write-Host "A new session of this script has been started. To avoid conflicts, this session will now terminate. This is a normal process and not an error." 101 | break; 102 | } 103 | elseif ($eventName -eq "GracePeriodExpired") { 104 | Write-Host "Stream has been suspended beyond the defined grace period. We will now treat this as if you ended the stream. If this was unintentional or if you wish to extend the grace period, please adjust the grace period timeout in the settings.json file." 105 | Wait-ForStreamEndJobToComplete 106 | break; 107 | } 108 | else { 109 | Wait-ForStreamEndJobToComplete 110 | break; 111 | } 112 | Remove-Event -EventIdentifier $eventFired.EventIdentifier 113 | } 114 | } 115 | } 116 | finally { 117 | if ($mutex) { 118 | $mutex.ReleaseMutex() 119 | } 120 | Stop-Logging 121 | } 122 | -------------------------------------------------------------------------------- /UndoScript.ps1: -------------------------------------------------------------------------------- 1 | # UndoFilteredFromSunshineConfig.ps1 2 | param( 3 | # Used by Helpers.ps1 (and for filtering undo commands) 4 | [Parameter(Mandatory = $true)] 5 | [Alias("n")] 6 | [string]$ScriptName, 7 | 8 | # When this switch is not present, the script will relaunch itself detached via WMI. 9 | [Switch]$Detached 10 | ) 11 | 12 | # If not already running detached, re-launch self via WMI and exit. 13 | if (-not $Detached) { 14 | # Get the full path of this script. 15 | $scriptPath = $MyInvocation.MyCommand.Definition 16 | # Build the command line; note that we add the -Detached switch. 17 | $command = "powershell.exe -ExecutionPolicy Bypass -File `"$scriptPath`" -ScriptName `"$ScriptName`" -Detached" 18 | Write-Host "Launching detached instance via WMI: $command" 19 | # Launch using WMI Create process. 20 | ([wmiclass]"\\.\root\cimv2:Win32_Process").Create($command) | Out-Null 21 | exit 22 | } 23 | 24 | # Now we are running in detached mode. 25 | # Set the working directory to this script's folder. 26 | $path = (Split-Path $MyInvocation.MyCommand.Path -Parent) 27 | Set-Location $path 28 | 29 | # Load helper functions (assumes Helpers.ps1 exists in the same folder) 30 | . .\Helpers.ps1 -n $ScriptName 31 | 32 | # Load settings (this function should be defined in Helpers.ps1) 33 | $settings = Get-Settings 34 | 35 | # Define a unique, system-wide mutex name. 36 | $mutexName = "Global\SunshineUndoMutex" 37 | $createdNew = $false 38 | $mutex = New-Object System.Threading.Mutex($true, $mutexName, [ref] $createdNew) 39 | 40 | if (-not $createdNew) { 41 | Write-Host "Undo process already in progress or executed. Exiting..." 42 | exit 43 | } 44 | 45 | try { 46 | Write-Host "Acquired mutex. Running undo process..." 47 | 48 | # Retrieve the list of script names from settings. 49 | $desiredNames = $settings.installationOrderPreferences.scriptNames 50 | if (-not $desiredNames) { 51 | Write-Error "No script names defined in settings.installationOrderPreferences.scriptNames." 52 | exit 1 53 | } 54 | 55 | # Create a hashtable to store unique commands by their undo command 56 | $commandHashMap = @{} 57 | 58 | # Function to read global_prep_cmd from a config file 59 | function Get-GlobalPrepCommands { 60 | param ( 61 | [string]$ConfigPath 62 | ) 63 | 64 | if (-not $ConfigPath -or -not (Test-Path $ConfigPath)) { 65 | Write-Host "Config path not found or not specified: $ConfigPath" 66 | return @() 67 | } 68 | 69 | try { 70 | $configContent = Get-Content -Path $ConfigPath -Raw 71 | 72 | if ($configContent -match 'global_prep_cmd\s*=\s*(\[[^\]]+\])') { 73 | $jsonText = $matches[1] 74 | try { 75 | $commands = $jsonText | ConvertFrom-Json 76 | if (-not ($commands -is [System.Collections.IEnumerable])) { 77 | $commands = @($commands) 78 | } 79 | return $commands 80 | } 81 | catch { 82 | Write-Error "Failed to parse global_prep_cmd JSON from $ConfigPath`: $_" 83 | return @() 84 | } 85 | } 86 | else { 87 | Write-Host "No valid 'global_prep_cmd' entry found in $ConfigPath." 88 | return @() 89 | } 90 | } 91 | catch { 92 | Write-Error "Unable to read config file at '$ConfigPath'. Error: $_" 93 | return @() 94 | } 95 | } 96 | 97 | # Get commands from Sunshine config if available 98 | if ($settings.sunshineConfigPath) { 99 | Write-Host "Reading commands from Sunshine config: $($settings.sunshineConfigPath)" 100 | $sunshineCommands = Get-GlobalPrepCommands -ConfigPath $settings.sunshineConfigPath 101 | foreach ($cmd in $sunshineCommands) { 102 | if ($cmd.undo) { 103 | # Use the undo command as the key to avoid duplicates 104 | $commandHashMap[$cmd.undo] = $cmd 105 | } 106 | } 107 | } 108 | 109 | # Get commands from Apollo config if available 110 | if ($settings.apolloConfigPath) { 111 | Write-Host "Reading commands from Apollo config: $($settings.apolloConfigPath)" 112 | $apolloCommands = Get-GlobalPrepCommands -ConfigPath $settings.apolloConfigPath 113 | foreach ($cmd in $apolloCommands) { 114 | if ($cmd.undo) { 115 | # This will overwrite any duplicate keys from Sunshine config 116 | $commandHashMap[$cmd.undo] = $cmd 117 | } 118 | } 119 | } 120 | 121 | # Convert the hashtable values back to an array 122 | $allPrepCommands = @($commandHashMap.Values) 123 | 124 | Write-Host "Total unique commands found: $($allPrepCommands.Count)" 125 | 126 | # Filter the commands to only include those matching our desired script names 127 | $filteredCommands = @() 128 | foreach ($name in $desiredNames) { 129 | $regexName = [regex]::Escape($name) 130 | $matchesForName = $allPrepCommands | Where-Object { $_.undo -match $regexName } 131 | if ($matchesForName) { 132 | $filteredCommands += $matchesForName 133 | } 134 | } 135 | 136 | if (-not $filteredCommands) { 137 | Write-Host "No matching undo commands found for the desired script names. Exiting." 138 | exit 0 139 | } 140 | 141 | # Order the commands in reverse of the installation order 142 | $desiredNamesReversed = $desiredNames.Clone() 143 | [Array]::Reverse($desiredNamesReversed) 144 | $finalCommands = @() 145 | foreach ($name in $desiredNamesReversed) { 146 | $cmdForName = $filteredCommands | Where-Object { $_.undo -match [regex]::Escape($name) } 147 | if ($cmdForName) { 148 | # Add all matching commands (if more than one per script) 149 | $finalCommands += $cmdForName 150 | } 151 | } 152 | 153 | # Execute the filtered undo commands synchronously 154 | Write-Host "Starting undo for filtered installed scripts (in reverse order):" 155 | foreach ($cmd in $finalCommands) { 156 | if ($cmd.undo -and $cmd.undo.Trim() -ne "") { 157 | # Save the original undo command text 158 | $undoCommand = $cmd.undo 159 | 160 | if ($undoCommand -match "PlayniteWatcher" -or $undoCommand -match "RTSSLimiter") { 161 | Write-Host "Skipping undo command related to PlayniteWatcher or RTSSLimiter." 162 | continue 163 | } 164 | 165 | # Look for the -file parameter and extract its value 166 | if ($undoCommand -match '-file\s+"([^"]+)"') { 167 | $origFilePath = $matches[1] 168 | $origFileName = Split-Path $origFilePath -Leaf 169 | 170 | # If the file isn't already Helpers.ps1, replace it 171 | if ($origFileName -ne "Helpers.ps1") { 172 | $folder = Split-Path $origFilePath -Parent 173 | $newFilePath = Join-Path $folder "Helpers.ps1" 174 | 175 | # Replace the original file path with the new Helpers.ps1 path 176 | $undoCommand = $undoCommand -replace [regex]::Escape($origFilePath), $newFilePath 177 | 178 | if ($undoCommand -notmatch "-t\s+1") { 179 | $undoCommand = $undoCommand + " -t 1" 180 | } 181 | Write-Host "Modified undo command to: $undoCommand" 182 | } 183 | } 184 | 185 | Write-Host "Running undo command:" 186 | Write-Host " $undoCommand" 187 | try { 188 | # Execute the modified undo command synchronously 189 | Invoke-Expression $undoCommand 190 | Write-Host "Undo command completed." 191 | } 192 | catch { 193 | Write-Warning "Failed to run undo command for one of the scripts: $_" 194 | } 195 | } 196 | else { 197 | Write-Host "No undo command for this entry. Skipping." 198 | } 199 | Start-Sleep -Seconds 1 # Optional pause between commands 200 | } 201 | 202 | Write-Host "All undo operations have been processed." 203 | } 204 | finally { 205 | # Always release and dispose of the mutex 206 | $mutex.ReleaseMutex() 207 | $mutex.Dispose() 208 | } 209 | -------------------------------------------------------------------------------- /Uninstall.bat: -------------------------------------------------------------------------------- 1 | powershell.exe -executionpolicy bypass -file ./Installer.ps1 -n AutoHDR -i 0 2 | -------------------------------------------------------------------------------- /log.txt: -------------------------------------------------------------------------------- 1 | ********************** 2 | Windows PowerShell transcript start 3 | Start time: 20240509001933 4 | Username: AMBIDEX\Chase 5 | RunAs User: AMBIDEX\Chase 6 | Configuration Name: 7 | Machine: AMBIDEX (Microsoft Windows NT 10.0.22631.0) 8 | Host Application: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe 9 | Process ID: 68864 10 | PSVersion: 5.1.22621.2506 11 | PSEdition: Desktop 12 | PSCompatibleVersions: 1.0, 2.0, 3.0, 4.0, 5.0, 5.1.22621.2506 13 | BuildVersion: 10.0.22621.2506 14 | CLRVersion: 4.0.30319.42000 15 | WSManStackVersion: 3.0 16 | PSRemotingProtocolVersion: 2.3 17 | SerializationVersion: 1.1.0.1 18 | ********************** 19 | Transcript started, output file is .\log.txt 20 | 21 | Id Name PSJobTypeName State HasMoreData Location Command 22 | -- ---- ------------- ----- ----------- -------- ------- 23 | 1 SunshineScri... BackgroundJob Running True localhost ... 24 | 3 SunshineScri... BackgroundJob Running True localhost ... 25 | Waiting for the next event to be called... (for starting/ending stream) 26 | Processing event: Start 27 | Stream started! 28 | Processing event: SunshineScriptInstaller-Terminated 29 | INFO: Stream has ended, now invoking code 30 | INFO: Ending Stream! 31 | INFO: This is an example of retrieving parameters in the future 32 | ********************** 33 | Windows PowerShell transcript end 34 | End time: 20240509002019 35 | ********************** 36 | -------------------------------------------------------------------------------- /output.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | File: Installer.ps1 4 | 5 | ## Fun fact, most of this code is generated entirely using GPT-3.5 ## CHATGPT PROMPT 1 ## Explain to me how to parse a conf file in PowerShell. ## AI EXPLAINS HOW... this is important as it invokes reflective thinking. ## Having AI explain things to us first before asking ## your question, significantly improves the quality of the response. ### PROMPT 2 ### Okay, using this conf, can you write a powershell script that saves a new value to the global_prep_cmd? ### AI Generates valid code for saving to conf file ## Prompt 3 ### I think I have found a mistake, can you double check your work? ## Again, this is important for reflective thinking, having the AI ## check its work is important, as it may improve quality. ## Response: Did not find any errors. ## Prompt 4: I tried this and unfortunately my config file requires admin to save. ## AI Responses solutions ## Like before, I already knew the solution but having the AI ## respond with tips, greatly improves the quality of the next prompts ## Prompt 5 (Final with GPT3.5): Can you make this script self elevate itself. ## Repeat the same prompt principles, and basically 70% of this script is entirely written by Artificial Intelligence. Yay! ## Refactor Prompt (GPT-4): Please refactor the following code, remove duplication and define better function names, once finished you will also add documentation and comments to each function. param($install) $filePath = $($MyInvocation.MyCommand.Path) $scriptRoot = Split-Path $filePath -Parent $scriptPath = "$scriptRoot\ResolutionMatcher.ps1" # This script modifies the global_prep_cmd setting in the Sunshine configuration file # to add a command that runs ResolutionMatcher.ps1 # Check if the current user has administrator privileges $isAdmin = [bool]([System.Security.Principal.WindowsIdentity]::GetCurrent().groups -match 'S-1-5-32-544') # If the current user is not an administrator, re-launch the script with elevated privileges if (-not $isAdmin) { Start-Process powershell.exe -Verb RunAs -ArgumentList "-ExecutionPolicy Bypass -NoExit -File `"$filePath`" $install" exit } function Test-AndRequest-SunshineConfig { param( [string]$InitialPath ) # Check if the initial path exists if (Test-Path $InitialPath) { Write-Host "File found at: $InitialPath" return $InitialPath } else { # Show error message dialog [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") | Out-Null [System.Windows.Forms.MessageBox]::Show("Sunshine configuration could not be found. Please locate it.", "Error", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null # Open file dialog $fileDialog = New-Object System.Windows.Forms.OpenFileDialog $fileDialog.Title = "Open sunshine.conf" $fileDialog.Filter = "Configuration files (*.conf)|*.conf" $fileDialog.InitialDirectory = [System.IO.Path]::GetDirectoryName($InitialPath) if ($fileDialog.ShowDialog() -eq "OK") { $selectedPath = $fileDialog.FileName # Check if the selected path is valid if (Test-Path $selectedPath) { Write-Host "File selected: $selectedPath" return $selectedPath } else { Write-Error "Invalid file path selected." } } else { Write-Error "Sunshine Configuiration file dialog was canceled or no valid file was selected." exit 1 } } } # Define the path to the Sunshine configuration file $confPath = Test-AndRequest-SunshineConfig -InitialPath "C:\Program Files\Sunshine\config\sunshine.conf" $scriptRoot = Split-Path $scriptPath -Parent # Get the current value of global_prep_cmd from the configuration file function Get-GlobalPrepCommand { # Read the contents of the configuration file into an array of strings $config = Get-Content -Path $confPath # Find the line that contains the global_prep_cmd setting $globalPrepCmdLine = $config | Where-Object { $_ -match '^global_prep_cmd\s*=' } # Extract the current value of global_prep_cmd if ($globalPrepCmdLine -match '=\s*(.+)$') { return $matches[1] } else { Write-Information "Unable to extract current value of global_prep_cmd, this probably means user has not setup prep commands yet." return [object[]]@() } } # Remove any existing commands that contain ResolutionMatcher from the global_prep_cmd value function Remove-ResolutionMatcherCommand { # Get the current value of global_prep_cmd as a JSON string $globalPrepCmdJson = Get-GlobalPrepCommand -ConfigPath $confPath # Convert the JSON string to an array of objects $globalPrepCmdArray = $globalPrepCmdJson | ConvertFrom-Json $filteredCommands = @() # Remove any ResolutionMatcher Commands for ($i = 0; $i -lt $globalPrepCmdArray.Count; $i++) { if (-not ($globalPrepCmdArray[$i].do -like "*ResolutionMatcher*")) { $filteredCommands += $globalPrepCmdArray[$i] } } return [object[]]$filteredCommands } # Set a new value for global_prep_cmd in the configuration file function Set-GlobalPrepCommand { param ( # The new value for global_prep_cmd as an array of objects [object[]]$Value ) if ($null -eq $Value) { $Value = [object[]]@() } # Read the contents of the configuration file into an array of strings $config = Get-Content -Path $confPath # Get the current value of global_prep_cmd as a JSON string $currentValueJson = Get-GlobalPrepCommand -ConfigPath $confPath # Convert the new value to a JSON string $newValueJson = ConvertTo-Json -InputObject $Value -Compress # Replace the current value with the new value in the config array try { $config = $config -replace [regex]::Escape($currentValueJson), $newValueJson } catch { # If it failed, it probably does not exist yet. # In the event the config only has one line, we will cast this to an object array so it appends a new line automatically. if ($Value.Length -eq 0) { [object[]]$config += "global_prep_cmd = []" } else { [object[]]$config += "global_prep_cmd = $($newValueJson)" } } # Write the modified config array back to the file $config | Set-Content -Path $confPath -Force } # Add a new command to run ResolutionMatcher.ps1 to the global_prep_cmd value function Add-ResolutionMatcherCommand { # Remove any existing commands that contain ResolutionMatcher from the global_prep_cmd value $globalPrepCmdArray = Remove-ResolutionMatcherCommand -ConfigPath $confPath # Create a new object with the command to run ResolutionMatcher.ps1 $ResolutionMatcherCommand = [PSCustomObject]@{ do = "powershell.exe -executionpolicy bypass -WindowStyle Hidden -file `"$($scriptPath)`"" elevated = "false" undo = "powershell.exe -executionpolicy bypass -WindowStyle Hidden -file `"$($scriptRoot)\ResolutionMatcher-Functions.ps1`" $true" } # Add the new object to the global_prep_cmd array [object[]]$globalPrepCmdArray += $ResolutionMatcherCommand return [object[]]$globalPrepCmdArray } $commands = @() if ($install -eq "True") { $commands = Add-ResolutionMatcherCommand } else { $commands = Remove-ResolutionMatcherCommand } Set-GlobalPrepCommand $commands $sunshineService = Get-Service -ErrorAction Ignore | Where-Object { $_.Name -eq 'sunshinesvc' -or $_.Name -eq 'SunshineService' } # In order for the commands to apply we have to restart the service $sunshineService | Restart-Service -WarningAction SilentlyContinue Write-Host "If you didn't see any errors, that means the script installed without issues! You can close this window." 6 | 7 | File: ResolutionMatcher-Functions.ps1 8 | 9 | param($terminate) Set-Location (Split-Path $MyInvocation.MyCommand.Path -Parent) Add-Type -Path .\internals\DisplaySettings.cs # If reverting the resolution fails, you can set a manual override here. $host_resolution_override = @{ Width = 0 Height = 0 Refresh = 0 } ## Code and type generated with ChatGPT v4, 1st prompt worked flawlessly. Function Set-ScreenResolution($width, $height, $frequency) { Write-Host "Setting screen resolution to $width x $height x $frequency" $tolerance = 2 # Set the tolerance value for the frequency comparison $devMode = New-Object DisplaySettings+DEVMODE $devMode.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($devMode) $modeNum = 0 while ([DisplaySettings]::EnumDisplaySettings([NullString]::Value, $modeNum, [ref]$devMode)) { $frequencyDiff = [Math]::Abs($devMode.dmDisplayFrequency - $frequency) if ($devMode.dmPelsWidth -eq $width -and $devMode.dmPelsHeight -eq $height -and $frequencyDiff -le $tolerance) { $result = [DisplaySettings]::ChangeDisplaySettings([ref]$devMode, 0) if ($result -eq 0) { Write-Host "Resolution changed successfully." } else { Write-Host "Failed to change resolution. Error code: $result" } break } $modeNum++ } } function Get-HostResolution { $devMode = New-Object DisplaySettings+DEVMODE $devMode.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($devMode) $modeNum = -1 while ([DisplaySettings]::EnumDisplaySettings([NullString]::Value, $modeNum, [ref]$devMode)) { return @{ Width = $devMode.dmPelsWidth Height = $devMode.dmPelsHeight Refresh = $devMode.dmDisplayFrequency } } } function Assert-ResolutionChange($width, $height, $refresh) { # Attempt to set the resolution up to 6 times, in event of failures for ($i = 0; $i -lt 12; $i++) { $hostResolution = Get-HostResolution $refreshDiff = [Math]::Abs($hostResolution.Refresh - $refresh) if (($width -ne $hostResolution.Width) -or ($height -ne $hostResolution.Height) -or ($refreshDiff -ge 2)) { # If the resolutions don't match, set the screen resolution to the current client's resolution Write-Host "Current Resolution: $($hostResolution.Width) x $($hostResolution.Height) x $($hostResolution.Refresh)" Write-Host "Expected Requested Resolution: $width x $height x $refresh" Set-ScreenResolution $width $height $refresh } # Wait for a while before checking the resolution again Start-Sleep -Milliseconds 500 } } function Join-Overrides($width, $height, $refresh) { Write-Host "Before Override: $width x $height x $refresh" $overrides = Get-Content ".\overrides.txt" -ErrorAction SilentlyContinue foreach ($line in $overrides) { if ($null -ne $line -and "" -ne $line) { $overrides = $line | Select-String "(?\d{1,})x(?\d*)x?(?\d*)?" -AllMatches $heights = $overrides[0].Matches.Groups | Where-Object { $_.Name -eq 'height' } $widths = $overrides[0].Matches.Groups | Where-Object { $_.Name -eq 'width' } $refreshes = $overrides[0].Matches.Groups | Where-Object { $_.Name -eq 'refresh' } if ($widths[0].Value -eq $width -and $heights[0].Value -eq $height -and $refreshes[0].Value -eq $refresh) { $width = $widths[1].Value $height = $heights[1].Value $refresh = $refreshes[1].Value break } } } Write-Host "After Override: $width x $height x $refresh" return @{ height = $height width = $width refresh = $refresh } } function IsCurrentlyStreaming() { return $null -ne (Get-NetUDPEndpoint -OwningProcess (Get-Process sunshine).Id -ErrorAction Ignore) } function Stop-ResolutionMatcherScript() { $pipeExists = Get-ChildItem -Path "\\.\pipe\" | Where-Object { $_.Name -eq "ResolutionMatcher" } if ($pipeExists.Length -gt 0) { $pipeName = "ResolutionMatcher" $pipe = New-Object System.IO.Pipes.NamedPipeClientStream(".", $pipeName, [System.IO.Pipes.PipeDirection]::Out) $pipe.Connect(5) $streamWriter = New-Object System.IO.StreamWriter($pipe) $streamWriter.WriteLine("Terminate") try { $streamWriter.Flush() $streamWriter.Dispose() $pipe.Dispose() } catch { # We don't care if the disposal fails, this is common with async pipes. # Also, this powershell script will terminate anyway. } } } function OnStreamStart($width, $height, $refresh) { $expectedRes = Join-Overrides -width $width -height $height -refresh $refresh Set-ScreenResolution -Width $expectedRes.Width -Height $expectedRes.Height -Freq $expectedRes.Refresh Assert-ResolutionChange -width $expectedRes.Width -height $expectedRes.Height -refresh $expectedRes.Refresh } function OnStreamEnd($hostResolution) { if (($host_resolution_override.Values | Measure-Object -Sum).Sum -gt 1000) { $hostResolution = @{ Width = $host_resolution_override['Width'] Height = $host_resolution_override['Height'] Refresh = $host_resolution_override['Refresh'] } } Set-ScreenResolution -Width $hostResolution.Width -Height $hostResolution.Height -Freq $hostResolution.Refresh } if ($terminate) { Stop-ResolutionMatcherScript | Out-Null } 10 | 11 | File: ResolutionMatcher.ps1 12 | 13 | param($async) $path = (Split-Path $MyInvocation.MyCommand.Path -Parent) Set-Location $path $settings = Get-Content -Path .\settings.json | ConvertFrom-Json # Since pre-commands in sunshine are synchronous, we'll launch this script again in another powershell process if ($null -eq $async) { Start-Process powershell.exe -ArgumentList "-ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`" $($MyInvocation.MyCommand.UnboundArguments) -async $true" -WindowStyle Hidden exit } Start-Transcript -Path .\log.txt . .\ResolutionMatcher-Functions.ps1 $hostResolutions = Get-HostResolution $lock = $false $mutexName = "ResolutionMatcher" $resolutionMutex = New-Object System.Threading.Mutex($false, $mutexName, [ref]$lock) # There is no need to have more than one of these scripts running. if (-not $resolutionMutex.WaitOne(0)) { Write-Host "Another instance of the script is already running. Exiting..." exit } try { # Asynchronously start the ResolutionMatcher, so we can use a named pipe to terminate it. Start-Job -Name ResolutionMatcherJob -ScriptBlock { param($path, $revertDelay) . $path\ResolutionMatcher-Functions.ps1 $lastStreamed = Get-Date Register-EngineEvent -SourceIdentifier ResolutionMatcher -Forward New-Event -SourceIdentifier ResolutionMatcher -MessageData "Start" while ($true) { try { if ((IsCurrentlyStreaming)) { $lastStreamed = Get-Date } else { if (((Get-Date) - $lastStreamed).TotalSeconds -gt $revertDelay) { New-Event -SourceIdentifier ResolutionMatcher -MessageData "End" break; } } } finally { Start-Sleep -Seconds 1 } } } -ArgumentList $path, $settings.revertDelay # To allow other powershell scripts to communicate to this one. Start-Job -Name "ResolutionMatcher-Pipe" -ScriptBlock { $pipeName = "ResolutionMatcher" Remove-Item "\\.\pipe\$pipeName" -ErrorAction Ignore $pipe = New-Object System.IO.Pipes.NamedPipeServerStream($pipeName, [System.IO.Pipes.PipeDirection]::In, 1, [System.IO.Pipes.PipeTransmissionMode]::Byte, [System.IO.Pipes.PipeOptions]::Asynchronous) $streamReader = New-Object System.IO.StreamReader($pipe) Write-Output "Waiting for named pipe to recieve kill command" $pipe.WaitForConnection() $message = $streamReader.ReadLine() if ($message -eq "Terminate") { Write-Output "Terminating pipe..." $pipe.Dispose() $streamReader.Dispose() } } $eventMessageCount = 0 Write-Host "Waiting for the next event to be called... (for starting/ending stream)" while ($true) { $eventMessageCount += 1 Start-Sleep -Seconds 1 $eventFired = Get-Event -SourceIdentifier ResolutionMatcher -ErrorAction SilentlyContinue $pipeJob = Get-Job -Name "ResolutionMatcher-Pipe" if ($null -ne $eventFired) { $eventName = $eventFired.MessageData Write-Host "Processing event: $eventName" if ($eventName -eq "Start") { OnStreamStart -width $env:SUNSHINE_CLIENT_WIDTH -height $env:SUNSHINE_CLIENT_HEIGHT -refresh $env:SUNSHINE_CLIENT_FPS } elseif ($eventName -eq "End") { OnStreamEnd $hostResolutions break; } Remove-Event -EventIdentifier $eventFired.EventIdentifier } elseif ($pipeJob.State -eq "Completed") { Write-Host "Request to terminate has been processed, script will now revert resolution." OnStreamEnd $hostResolutions break; } elseif($eventMessageCount -gt 59) { Write-Host "Still waiting for the next event to fire..." $eventMessageCount = 0 } } } finally { Remove-Item "\\.\pipe\ResolutionMatcher" -ErrorAction Ignore $resolutionMutex.ReleaseMutex() Remove-Event -SourceIdentifier ResolutionMatcher -ErrorAction Ignore Stop-Transcript } 14 | -------------------------------------------------------------------------------- /settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "debug": false, 3 | "gracePeriod": 180, 4 | "startDelay": 1, 5 | "installationOrderPreferences": { 6 | "enabled": true, 7 | // Set the order which the scripts are installed in, if the scripts are currently out of order, they will be reinstalled in the order specified here. 8 | // All of these scripts are optional and will not impact your experience if they are not installed. 9 | // However, I am shamelessly plugging in links to the scripts here, so you can easily find them. 10 | "scriptNames": [ 11 | "MonitorSwapper", // https://github.com/Nonary/MonitorSwapAutomation/releases/latest (Swaps the primary monitor to a dummy plug and then back when finished.) 12 | "ResolutionMatcher", // https://github.com/Nonary/ResolutionAutomation/releases/latest (Automatically sets the resolution to the same as the client streaming.) 13 | "AutoHDR", // https://github.com/Nonary/AutoHDRSwitch/releases/latest (Automatically enables HDR if the client is streaming HDR content.) 14 | "RTSSLimiter", // https://github.com/Nonary/RTSSLimiter/releases/latest (Limits the host framerate to the client's streaming framerate to reduce microstuttering) 15 | "PlayNiteWatcher" // https://github.com/Nonary/PlayniteWatcher/releases/latest (Export any game with the box art in Playnite to the Moonlight client. Enables automatic stream termination and ability to close games from Moonlight.) 16 | ] 17 | }, 18 | // Toggles HDR on and off to fix the blown out colors issue on IDDSampleDriver, set to true to enable. 19 | "IDDSampleFix": true 20 | } --------------------------------------------------------------------------------