├── .gitignore ├── Events.ps1 ├── 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 | 24H2DummyFix 3 | logs/ 4 | Primary.xml 5 | Dummy.xml 6 | MonitorSwitcher.exe 7 | -------------------------------------------------------------------------------- /Events.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Position = 0, Mandatory = $true)] 3 | [Alias("n")] 4 | [string]$scriptName 5 | ) 6 | $path = (Split-Path $MyInvocation.MyCommand.Path -Parent) 7 | Set-Location $path 8 | . .\Helpers.ps1 -n $scriptName 9 | 10 | # Load settings from a JSON file located in the same directory as the script 11 | $settings = Get-Settings 12 | 13 | # Initialize a script scoped dictionary to store variables. 14 | # This dictionary is used to pass parameters to functions that might not have direct access to script scope, like background jobs. 15 | if (-not $script:arguments) { 16 | $script:arguments = @{} 17 | } 18 | 19 | $script:attempts = 0 20 | 21 | $configSaveLocation = [System.Environment]::ExpandEnvironmentVariables($settings.configSaveLocation) 22 | 23 | 24 | function OnStreamStart() { 25 | if ($settings.debug) { 26 | $script:arguments['debug'] = $true 27 | } 28 | Write-Debug "Starting OnStreamStart function" 29 | 30 | # Attempt to load the dummy profile for up to 5 times in total. 31 | # Always try to restore it at least once, due to a bug in Windows... if a profile restoration fails (especially when switching back to the primary screen), 32 | # and a stream is initiated again, the display switcher built into windows (Windows + P) may not update and remain stuck on the last used setting. 33 | # This can cause significant problems in some games, including frozen visuals and black screens. 34 | Write-Debug "Loading dummy monitor configuration from Dummy.xml" 35 | & .\MonitorSwitcher.exe -load:Dummy.xml | Out-Null 36 | Start-Sleep -Seconds 2 37 | 38 | for ($i = 0; $i -lt 6; $i++) { 39 | Write-Debug "Attempt $i to check if dummy monitor is active" 40 | $dummyMonitorId = Get-MonitorIdFromXML -filePath ".\Dummy.xml" 41 | if (-not (IsMonitorActive -monitorId $dummyMonitorId)) { 42 | Write-Debug "Dummy monitor is not active, reloading dummy configuration" 43 | & .\MonitorSwitcher.exe -load:Dummy.xml | Out-Null 44 | } 45 | else { 46 | Write-Debug "Dummy monitor is active, exiting loop" 47 | break 48 | } 49 | 50 | if ($i -eq 5) { 51 | Write-Host "Failed to verify dummy plug was activated, did you make sure dummyMonitorId was included and was properly escaped with double backslashes?" 52 | Write-Debug "Failed to activate dummy plug after 5 attempts" 53 | return 54 | } 55 | 56 | Start-Sleep -Seconds 1 57 | } 58 | 59 | Write-Host "Dummy plug activated" 60 | Write-Debug "Dummy plug activated successfully" 61 | } 62 | 63 | 64 | function OnStreamEnd($kwargs) { 65 | Write-Debug "Starting OnStreamEnd function" 66 | 67 | 68 | try { 69 | # Check if the primary monitor is not active 70 | Write-Debug "Now attempting to restore the primary monitor" 71 | $succesfulChange = SetPrimaryScreen 72 | 73 | 74 | 75 | 76 | if ((IsPrimaryMonitorActive)) { 77 | # Primary monitor is active, return true. 78 | Write-Host "Primary monitor(s) have been successfully restored!" 79 | Write-Debug "Primary monitor is active, returning true" 80 | return $true 81 | 82 | } 83 | else { 84 | Write-Debug "Primary monitor failed to be restored, this is most likely because the display is currently not available." 85 | if (($script:attempts++ -eq 1) -or ($script:attempts % 120 -eq 0)) { 86 | # Output a message to the host indicating difficulty in restoring the display. 87 | # This message is shown once initially, and then once every 10 minutes. 88 | Write-Host "Failed to restore display(s), some displays require multiple attempts and may not restore until returning back to the computer. Trying again after 5 seconds... (this message will be suppressed to only show up once every 10-15 minutes)" 89 | } 90 | 91 | if(-not $succesfulChange) { 92 | Write-Debug "Since the monitor switcher indicated that there was an error during the restoration of primary monitor, we are know going to revert back to the dummy profile." 93 | Write-Debug "This fixes an issue on Windows 11 24H2, where DXGI could throw errors about pending display changes." 94 | & .\MonitorSwitcher.exe -load:Dummy.xml | Out-Null 95 | } 96 | 97 | # Return false indicating the primary monitor is still not active. 98 | return $false 99 | } 100 | } 101 | catch { 102 | Write-Debug "Caught an exception, expected in cases like when the user has a TV as a primary display" 103 | # Do Nothing, because we're expecting it to fail in cases like when the user has a TV as a primary display. 104 | return $false 105 | } 106 | 107 | # Return false by default if an exception occurs. 108 | Write-Debug "Returning false by default due to exception" 109 | return $false 110 | } 111 | 112 | 113 | function IsMonitorActive($monitorId) { 114 | Write-Debug "Starting IsMonitorActive function for monitorId: $monitorId" 115 | 116 | # For some displays, the primary screen can't be set until it wakes up from sleep. 117 | # Continually poll the configuration to ensure the display is fully updated. 118 | $filePath = "$configSaveLocation\current_monitor_config.xml" 119 | Write-Debug "Saving current monitor configuration to $filePath" 120 | & .\MonitorSwitcher.exe -save:$filePath | Out-Null 121 | Start-Sleep -Seconds 1 122 | 123 | $currentTime = Get-Date 124 | Write-Debug "Current time: $currentTime" 125 | 126 | # Check when the file was last updated 127 | $fileLastWriteTime = (Get-Item $filePath).LastWriteTime 128 | Write-Debug "File last write time: $fileLastWriteTime" 129 | 130 | # Calculate the time difference in minutes 131 | $timeDifference = ($currentTime - $fileLastWriteTime).TotalMinutes 132 | Write-Debug "Time difference in minutes: $timeDifference" 133 | 134 | # If the file isn't recent, it might be a stale configuration leading to a false positive 135 | if ($timeDifference -gt 1) { 136 | Write-Debug "File was not saved recently. Potential false positive." 137 | return $false 138 | } 139 | 140 | Write-Debug "Reading monitor configuration from $filePath" 141 | [xml]$xml = Get-Content -Path $filePath 142 | 143 | # Find the path info node that matches the given monitorId 144 | # The monitorId is found in the targetInfo.id element. 145 | foreach ($path in $xml.displaySettings.pathInfoArray.DisplayConfigPathInfo) { 146 | if ($path.targetInfo.id -eq $monitorId) { 147 | Write-Debug "Found matching path for monitor ID: $monitorId" 148 | 149 | # Extract refresh rate 150 | $numerator = [int]$path.targetInfo.refreshRate.numerator 151 | $denominator = [int]$path.targetInfo.refreshRate.denominator 152 | $refresh = if ($denominator -ne 0) { $numerator / $denominator } else { 0 } 153 | 154 | # Locate the source mode info to get width and height 155 | $sourceModeIdx = [int]$path.sourceInfo.modeInfoIdx 156 | $sourceModeInfo = $xml.displaySettings.modeInfoArray.modeInfo[$sourceModeIdx] 157 | 158 | # Confirm that this modeInfo is a Source type 159 | if ($sourceModeInfo.DisplayConfigModeInfoType -eq 'Source') { 160 | $width = [int]$sourceModeInfo.DisplayConfigSourceMode.width 161 | $height = [int]$sourceModeInfo.DisplayConfigSourceMode.height 162 | } 163 | else { 164 | Write-Debug "Source mode not found as expected. Returning false." 165 | return $false 166 | } 167 | 168 | Write-Debug "Monitor width: $width, height: $height, refresh rate: $refresh" 169 | 170 | # Inactive displays are expected to have zero width, height, or refresh. 171 | $isActive = ($width -ne 0 -and $height -ne 0 -and $refresh -ne 0) 172 | Write-Debug "Monitor active status: $isActive" 173 | return $isActive 174 | } 175 | } 176 | 177 | Write-Debug "Monitor ID $monitorId not found in configuration" 178 | return $false 179 | } 180 | 181 | 182 | 183 | function SetPrimaryScreen() { 184 | Write-Debug "Starting SetPrimaryScreen function" 185 | 186 | Write-Debug "Checking if currently streaming" 187 | if (IsCurrentlyStreaming) { 188 | Write-Debug "Currently streaming, exiting function as this would cause performance issues to users who are currently streaming." 189 | return 190 | } 191 | else { 192 | Write-Debug "Verified user is currently not streaming." 193 | } 194 | 195 | Write-Debug "Loading primary monitor configuration from Primary.xml" 196 | $output = & .\MonitorSwitcher.exe -load:Primary.xml 197 | 198 | Write-Debug "Output from loading primary monitor configuration: $output" 199 | 200 | if($output -is [System.Array]) { 201 | $changeFailed = $output[0].ToString().Contains("ERROR") 202 | if ($changeFailed) { 203 | Write-Debug "Failed to change primary monitor" 204 | return $false 205 | } 206 | } 207 | 208 | 209 | Write-Debug "Sleeping for 3 seconds to allow configuration to take effect" 210 | Start-Sleep -Seconds 3 211 | 212 | Write-Debug "SetPrimaryScreen function completed" 213 | return $true 214 | } 215 | 216 | function Get-MonitorIdFromXML($filePath) { 217 | Write-Debug "Starting Get-MonitorIdFromXML function for filePath: $filePath" 218 | 219 | # Load the XML from the file 220 | [xml]$xml = Get-Content -Path $filePath 221 | 222 | # Prepare the array to hold primary monitor IDs 223 | $primaryMonitorIds = @() 224 | 225 | # Iterate through each DisplayConfigPathInfo node in the XML 226 | foreach ($path in $xml.displaySettings.pathInfoArray.DisplayConfigPathInfo) { 227 | # Extract the monitor ID from the targetInfo section 228 | $monitorId = $path.targetInfo.id 229 | 230 | # Extract the refresh rate numerator and denominator 231 | $numerator = [int]$path.targetInfo.refreshRate.numerator 232 | $denominator = [int]$path.targetInfo.refreshRate.denominator 233 | 234 | # Calculate the refresh rate 235 | $refreshRate = 0 236 | if ($denominator -ne 0) { 237 | $refreshRate = $numerator / $denominator 238 | } 239 | 240 | Write-Debug "Monitor ID: $monitorId, Refresh rate: $refreshRate" 241 | 242 | # If refresh rate is not zero, consider it an active (primary) monitor 243 | if ($refreshRate -ne 0) { 244 | Write-Debug "Adding active monitor ID: $monitorId" 245 | $primaryMonitorIds += $monitorId 246 | } 247 | else { 248 | Write-Debug "Skipping inactive monitor ID: $monitorId" 249 | } 250 | } 251 | 252 | Write-Debug "Primary monitor IDs: $($primaryMonitorIds -join ', ')" 253 | return $primaryMonitorIds 254 | } 255 | 256 | 257 | 258 | function IsPrimaryMonitorActive() { 259 | $filePath = "$configSaveLocation\current_monitor_config.xml" 260 | 261 | Write-Debug "Saving current monitor configuration to $filePath" 262 | & .\MonitorSwitcher.exe -save:$filePath | Out-Null 263 | Start-Sleep -Seconds 3 264 | 265 | $currentTime = Get-Date 266 | Write-Debug "Current time: $currentTime" 267 | 268 | # Get the file's last write time 269 | $fileLastWriteTime = (Get-Item $filePath).LastWriteTime 270 | Write-Debug "File last write time: $fileLastWriteTime" 271 | 272 | # Calculate the time difference in minutes 273 | $timeDifference = ($currentTime - $fileLastWriteTime).TotalMinutes 274 | Write-Debug "Time difference in minutes: $timeDifference" 275 | 276 | # Check if the file was saved in the last minute, if it has not been saved recently, then we could have a potential false positive. 277 | if ($timeDifference -gt 1) { 278 | Write-Debug "File was not saved recently. Potential false positive." 279 | return $false 280 | } 281 | 282 | [string[]]$primaryProfile = Get-MonitorIdFromXML -filePath "Primary.xml" -as [string[]] 283 | Write-Debug "Primary monitor IDs: $primaryProfile" 284 | 285 | Write-Debug "Getting primary monitor IDs from current configuration file" 286 | [string[]]$currentProfile = Get-MonitorIdFromXML -filePath $filePath -as [string[]] 287 | Write-Debug "Current monitor IDs: $currentProfile" 288 | 289 | $comparisonResults = Compare-Object $primaryProfile $currentProfile 290 | 291 | if ($null -ne $comparisonResults) { 292 | Write-Debug "Primary monitor IDs do not match current configuration. Returning false." 293 | Write-Debug ($comparisonResults | Format-Table | Out-String) 294 | return $false 295 | } 296 | 297 | Write-Debug "Primary monitor IDs match current configuration. Returning true." 298 | return $true 299 | } 300 | 301 | -------------------------------------------------------------------------------- /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 | function Update-JsonProperty { 205 | [CmdletBinding()] 206 | param( 207 | [Parameter(Mandatory = $true)] 208 | [string]$FilePath, 209 | 210 | [Parameter(Mandatory = $true)] 211 | [string]$Property, 212 | 213 | [Parameter(Mandatory = $true)] 214 | [object]$NewValue 215 | ) 216 | 217 | # Read the file as a single string. 218 | $content = Get-Content -Path $FilePath -Raw 219 | 220 | # Remove comments (both // and /* */ style) and trailing commas 221 | $strippedContent = $content -replace '//.*?(\r?\n|$)', '$1' # Remove single line comments 222 | $strippedContent = $strippedContent -replace '/\*[\s\S]*?\*/', '' # Remove multi-line comments 223 | $strippedContent = $strippedContent -replace ',(\s*[\]}])', '$1' # Remove trailing commas 224 | 225 | # Format the new value properly 226 | if ($NewValue -is [string]) { 227 | # Convert the string to a JSON-compliant string. 228 | $formattedValue = (ConvertTo-Json $NewValue -Compress) 229 | } else { 230 | $formattedValue = $NewValue.ToString().ToLower() 231 | } 232 | 233 | # Build a regex pattern for matching the property. 234 | $escapedProperty = [regex]::Escape($Property) 235 | $pattern = '"' + $escapedProperty + '"\s*:\s*[^,}\r\n]+' 236 | 237 | # Check if the property exists in the stripped content 238 | if ($strippedContent -match $pattern) { 239 | # Property exists, just update its value in the original content 240 | $replacement = '"' + $Property + '": ' + $formattedValue 241 | $updatedContent = [regex]::Replace($content, $pattern, $replacement) 242 | } else { 243 | # Property doesn't exist, need to add it 244 | # Find the last property in the JSON object 245 | $lastPropPattern = ',?\s*"([^"]+)"\s*:\s*[^,}\r\n]+' 246 | 247 | if ($content -match '}\s*$') { 248 | # Find the last occurrence of a property 249 | $lastMatch = [regex]::Matches($content, $lastPropPattern)[-1] 250 | $lastPropIndex = $lastMatch.Index + $lastMatch.Length 251 | 252 | # Check if the last property ends with a comma 253 | $endsWithComma = $content.Substring($lastPropIndex).TrimStart() -match '^\s*,' 254 | 255 | # Prepare the new property string 256 | $newPropString = "" 257 | if (!$endsWithComma) { 258 | $newPropString += "," 259 | } 260 | $newPropString += "`n `"$Property`": $formattedValue" 261 | 262 | # Insert the new property before the closing brace 263 | $closingBraceIndex = $content.LastIndexOf('}') 264 | $updatedContent = $content.Substring(0, $closingBraceIndex).TrimEnd() + 265 | $newPropString + 266 | "`n}" + 267 | $content.Substring($closingBraceIndex + 1) 268 | } else { 269 | Write-Error "Unable to find a proper location to insert the new property. JSON file may be malformed." 270 | return 271 | } 272 | } 273 | 274 | # Write the updated content back. 275 | Set-Content -Path $FilePath -Value $updatedContent 276 | } 277 | 278 | 279 | 280 | function Wait-ForStreamEndJobToComplete() { 281 | $job = OnStreamEndAsJob 282 | while ($job.State -ne "Completed") { 283 | $job | Receive-Job 284 | Start-Sleep -Seconds 1 285 | } 286 | $job | Wait-Job | Receive-Job 287 | } 288 | 289 | 290 | if ($terminate -eq 1) { 291 | Write-Host "Stopping Script" 292 | Stop-Script | Out-Null 293 | } 294 | -------------------------------------------------------------------------------- /Install.bat: -------------------------------------------------------------------------------- 1 | powershell.exe -executionpolicy bypass -file ./Installer.ps1 -n MonitorSwapper -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", "*.cfg", "*.Tests.ps1", "dummy_files", "iddsampledriver", "Primary.xml", "Dummy.xml") 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 | # README 2 | 3 | This tool automatically switches your main display to a dummy plug (or any virtual display) that you have set up for **Sunshine**. The goal is to seamlessly switch from your normal monitor setup to a dummy display when you start streaming (e.g., using Moonlight on a mobile device). 4 | 5 | ## How It Works 6 | 7 | - **Normal Operation:** When you’re not streaming, your PC uses your regular monitor arrangement. 8 | - **While Streaming:** As soon as you start a Moonlight stream on another device, the script automatically switches to a dummy monitor configuration (or another specified monitor layout). 9 | 10 | --- 11 | 12 | ## Important Notes 13 | 14 | 1. **Dummy Plug / Virtual Display:** 15 | This script is designed for systems with a dummy plug or virtual display, but it can be used in other scenarios. For instance, you can use it to switch from a dual-monitor setup to a single monitor while streaming. 16 | 17 | 2. **Windows 11 Users:** 18 | Due to a known bug, you must set the default terminal to **Windows Console Host**. 19 | 20 | - On newer Windows 11 versions: 21 | 1. Open **Settings**. 22 | 2. Go to **System > For Developers**. 23 | 3. Locate the **Terminal** setting (which defaults to "Let Windows decide"). 24 | 4. Change it to **Terminal [Windows Console Host]**. 25 | 26 | - On older Windows 11 versions: 27 | 1. Open **Settings**. 28 | 2. Go to **Privacy & Security > Security > For Developers**. 29 | 3. Find the **Terminal** option. 30 | 4. Change it from "Let Windows decide" to **Terminal [Windows Console Host]**. 31 | 32 | Without this setting, the script may not work correctly. 33 | 34 | 3. **Do Not Move the Script’s Folder After Installation:** 35 | If you move the installation folder after setting up the script, it may stop working. In that case, simply reinstall it. 36 | 37 | 4. **Cold Reboots / Hard Crashes Issue:** 38 | On a cold boot (from a fully powered-off state), Windows may not allow the script to switch monitors immediately. 39 | 40 | **Workaround:** 41 | - After a cold boot, start your PC and log in using the "Desktop" stream option in Moonlight. 42 | - End the stream, then start it again. 43 | 44 | After doing this once, subsequent uses should work normally. 45 | 46 | 5. **Sunshine Web UI Setting:** 47 | **Do not set an “Output Name” in the Sunshine Web UI under the Audio/Video tab.** Leave it blank. Setting an output name may break the script’s functionality. 48 | 49 | --- 50 | 51 | ## Requirements 52 | 53 | - **For GFE (GeForce Experience) Users:** 54 | This script no longer supports GFE. If you need the older version that worked with GFE, download the [Legacy Version](https://github.com/Nonary/MonitorSwapAutomation/releases/tag/legacy). 55 | 56 | - **For Sunshine Users:** 57 | - Sunshine version **0.19.1 or higher**. 58 | - Host computer must be running Windows. 59 | 60 | --- 61 | 62 | ## Step-by-Step Setup for Monitor Configuration 63 | 64 | 1. **Sunshine Output Settings:** 65 | - In the Sunshine Web UI, ensure the “Output Name” field is blank under **Audio/Video settings**. 66 | 67 | 2. **Install the Script:** 68 | - Follow the provided installer instructions to set up the script on your computer. 69 | 70 | 3. **Set Your Baseline (Primary) Monitor Setup:** 71 | - Arrange your monitors as desired for normal operation (e.g., when you’re not streaming). 72 | 73 | 4. **Save Your “Primary” Monitor Profile:** 74 | - Open **Terminal/Command Prompt** in the script’s folder. 75 | - Run the following command: 76 | ``` 77 | .\MonitorSwitcher.exe -save:Primary.xml 78 | ``` 79 | - This creates a snapshot of your current monitor configuration as `Primary.xml`. 80 | 81 | 5. **Prepare to Save Your “Dummy” Monitor Profile:** 82 | - Start a Moonlight stream from another device (e.g., phone, tablet) so you can view and control your PC remotely. 83 | - This is essential because your physical monitor will go dark when switching to the dummy monitor. 84 | 85 | 6. **Configure the Dummy Monitor Setup (While Streaming):** 86 | - With the remote stream running: 87 | 1. On your Windows PC, open **Settings > System > Display**. 88 | 2. Click **Identify** to determine the monitor number for streaming. For a dummy plug, identify the monitor number that is not physically visible. 89 | 3. If multiple monitors are active, disconnect secondary monitors: 90 | - Select the monitor to disconnect (e.g., monitor #2). 91 | - Use the dropdown menu to choose **Disconnect this display**. 92 | - Repeat until only the primary monitor is active. 93 | 4. Ensure you are remotely viewing the PC on another device before proceeding, as you will not be able to see the screen physically on this next step. 94 | 5. In **Display settings**, set the dropdown to **Show only on {NUMBER}**, where `{NUMBER}` is the dummy/streaming monitor. 95 | 6. If you do not see **Show only on {NUMBER}** 96 | - Select the dummy display, then click the dropdown and select "Extend desktop to this display" 97 | - Select the dummy display again and expand the "Multiple Displays" group (if not already done) 98 | - Click the "Make this my main display" checkbox 99 | - Click your other monitor that was previously the main display, then click the dropdown again, then click "Disconnect this display". 100 | 7. While at your computer confirm the display settings by clicking "Keep Changes", use your other device that is currently streaming for guidance on moving the mouse. 101 | 102 | 7. **Save Your “Dummy” Monitor Profile:** 103 | - In the Terminal (still in the script’s folder), run the following command: 104 | ``` 105 | .\MonitorSwitcher.exe -save:Dummy.xml 106 | ``` 107 | - This saves your dummy/streaming monitor configuration as `Dummy.xml`. 108 | 109 | 8. **Completing the Setup:** 110 | - End the Moonlight stream session. 111 | - Your display should revert to the original configuration on your physical monitor. 112 | 113 | Now, the script will automatically switch to the dummy display configuration when streaming and restore your original setup when you stop streaming. 114 | 115 | 9. **24H2 Workaround Script:** 116 | If you are using 24H2 and have troubles with Sunshine starting the stream due to 503 errors (missing output/encoder failure) you will need to install the 24H2 workaround script: https://github.com/Nonary/24H2DummyFix/releases/latest 117 | 118 | 119 | ## Troubleshooting 120 | 121 | **If the ResolutionAutomation script doesn’t switch resolutions properly or you wish to run scripts after the monitor has swapped:** 122 | 123 | - Increase the start delay in **settings.json** (located in the script’s folder) to 3 or 4 seconds. This will give more time for other scripts to run after the monitor has swapped. 124 | - For ResolutionAutomation, this is not necessary to do in most cases as it has a fallback to re-apply resolution multiple times. 125 | 126 | **Unable to start stream/503 encoder failure** 127 | If you are using 24H2 and have troubles with Sunshine starting the stream due to 503 errors (missing output/encoder failure) you will need to install the 24H2 workaround script: https://github.com/Nonary/24H2DummyFix/releases/latest 128 | 129 | --- 130 | 131 | ## Change Log 132 | 133 | **v2.0.5** 134 | - Upgraded script to latest version of [Sunshine Script Intaller](https://github.com/Nonary/SunshineScriptInstaller) which has performance improvements. 135 | 136 | **v2.0.4** 137 | - Improved compatibility for Windows 11 24H2, fixing a common scenario that caused Sunshine to be unable to find an output device. 138 | - Removed start delay, which will cause Moonlight to start the stream faster. If this causes issues, you can adjust the start delay back to 3 seconds. 139 | 140 | **v2.0.3** 141 | - Fixed another bug that caused script to exit earlier than intended before restoring primary monitor. 142 | 143 | **v2.0.2** 144 | - More bug fixes that prevented primary monitor from restoring after a stream was ended. 145 | 146 | **v2.0.1** 147 | - Fixed a bug that prevented the primary monitor from restoring after ending a stream. 148 | 149 | **v2.0.0** 150 | - Switched from Nirsoft MultiMonitorTool to MonitorSwitcher for better compatibility with Windows 24H2 and improved reliability. 151 | 152 | --- 153 | -------------------------------------------------------------------------------- /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 | # Since pre-commands in sunshine are synchronous, we'll launch this script again in another powershell process 18 | if ($startInBackground -eq $false) { 19 | $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition 20 | $arguments = "-ExecutionPolicy Bypass -Command `"& '$scriptPath\StreamMonitor.ps1' -scriptName $scriptName -sib 1`"" 21 | Start-Process powershell.exe -ArgumentList $arguments -WindowStyle Hidden 22 | Start-Sleep -Seconds $settings.startDelay 23 | exit 24 | } 25 | 26 | 27 | Remove-OldLogs 28 | Start-Logging 29 | 30 | # OPTIONAL MUTEX HANDLING 31 | # Create a mutex to prevent multiple instances of this script from running simultaneously. 32 | if (-not $mutex) { 33 | ### If you don't use a mutex, you can optionally fan the hammer 34 | for ($i = 0; $i -lt 6; $i++) { 35 | Send-PipeMessage $scriptName NewSession 36 | Send-PipeMessage "$scriptName-OnStreamEnd" Terminate 37 | } 38 | } 39 | # END OF OPTIONAL MUTEX HANDLING 40 | 41 | 42 | try { 43 | 44 | # Asynchronously start the script, so we can use a named pipe to terminate it. 45 | Start-Job -Name "$($scriptName)Job" -ScriptBlock { 46 | param($path, $scriptName, $gracePeriod) 47 | . $path\Helpers.ps1 -n $scriptName 48 | $lastStreamed = Get-Date 49 | 50 | 51 | Register-EngineEvent -SourceIdentifier $scriptName -Forward 52 | New-Event -SourceIdentifier $scriptName -MessageData "Start" 53 | while ($true) { 54 | try { 55 | if ((IsCurrentlyStreaming)) { 56 | $lastStreamed = Get-Date 57 | } 58 | else { 59 | if (((Get-Date) - $lastStreamed).TotalSeconds -gt $gracePeriod) { 60 | New-Event -SourceIdentifier $scriptName -MessageData "GracePeriodExpired" 61 | break; 62 | } 63 | 64 | } 65 | } 66 | finally { 67 | Start-Sleep -Seconds 1 68 | } 69 | } 70 | 71 | } -ArgumentList $path, $scriptName, $settings.gracePeriod | Out-Null 72 | 73 | 74 | # This might look like black magic, but basically we don't have to monitor this pipe because it fires off an event. 75 | Create-Pipe $scriptName | Out-Null 76 | 77 | Write-Host "Waiting for the next event to be called... (for starting/ending stream)" 78 | while ($true) { 79 | Start-Sleep -Seconds 1 80 | $eventFired = Get-Event -SourceIdentifier $scriptName -ErrorAction SilentlyContinue 81 | if ($null -ne $eventFired) { 82 | $eventName = $eventFired.MessageData 83 | Write-Host "Processing event: $eventName" 84 | if ($eventName -eq "Start") { 85 | OnStreamStart 86 | } 87 | elseif ($eventName -eq "NewSession") { 88 | 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." 89 | break; 90 | } 91 | elseif ($eventName -eq "GracePeriodExpired") { 92 | 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." 93 | Wait-ForStreamEndJobToComplete 94 | break; 95 | } 96 | elseif($eventName -eq "GPUAdapterChange"){ 97 | Write-Host "Script is being intentionally terminated without restoring the monitor profile because you have the Hybrid GPU Fix Script Installed, this is not considered an error. It needs to do this so that the GPU preference gets set properly once restarting sunshine." 98 | break; 99 | } 100 | else { 101 | Wait-ForStreamEndJobToComplete 102 | break; 103 | } 104 | Remove-Event -EventIdentifier $eventFired.EventIdentifier 105 | } 106 | } 107 | } 108 | finally { 109 | if ($mutex) { 110 | $mutex.ReleaseMutex() 111 | } 112 | Stop-Logging 113 | } 114 | -------------------------------------------------------------------------------- /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 MonitorSwapper -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 | "startDelay": 0, 4 | "gracePeriod": 900, 5 | "configSaveLocation": "%TEMP%", 6 | "sunshineConfigPath": "", 7 | "installationOrderPreferences": { 8 | "enabled": true, 9 | // 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. 10 | // All of these scripts are optional and will not impact your experience if they are not installed. 11 | // However, I am shamelessly plugging in links to the scripts here, so you can easily find them. 12 | "scriptNames": [ 13 | "MonitorSwapper", // https://github.com/Nonary/MonitorSwapAutomation/releases/latest (Swaps the primary monitor to a dummy plug and then back when finished.) 14 | "ResolutionMatcher", // https://github.com/Nonary/ResolutionAutomation/releases/latest (Automatically sets the resolution to the same as the client streaming.) 15 | "AutoHDR", // https://github.com/Nonary/AutoHDRSwitch/releases/latest (Automatically enables HDR if the client is streaming HDR content.) 16 | "RTSSLimiter", // https://github.com/Nonary/RTSSLimiter/releases/latest (Limits the host framerate to the client's streaming framerate to reduce microstuttering) 17 | "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.) 18 | ] 19 | } 20 | } 21 | 22 | 23 | --------------------------------------------------------------------------------