From 6985b42ae82e804dea5a8063e384eaa586500fb3 Mon Sep 17 00:00:00 2001 From: Chase Payne Date: Mon, 12 Aug 2024 13:45:59 -0500 Subject: [PATCH] AutoHDR Sunshine Script Template (#6) Migrates the HDR script over to the new Sunshine Script Installer template and adds a new behavior to fix IDDSampleDriver glitches. --- .gitignore | 3 + Events.ps1 | 88 +++++++++++++++++ HDRToggle-Functions.ps1 | 95 ------------------- HDRToggle.ps1 | 112 ---------------------- Helpers.ps1 | 202 ++++++++++++++++++++++++++++++++++++++++ Install.bat | 2 +- Installer.ps1 | 193 +++++++++++++++++++++++++------------- Packager.ps1 | 78 ++++++++++++++++ Readme.md | 29 ++++++ StreamMonitor.ps1 | 121 ++++++++++++++++++++++++ Uninstall.bat | 2 +- log.txt | 0 settings.json | 20 ++++ 13 files changed, 673 insertions(+), 272 deletions(-) create mode 100644 .gitignore create mode 100644 Events.ps1 delete mode 100644 HDRToggle-Functions.ps1 delete mode 100644 HDRToggle.ps1 create mode 100644 Helpers.ps1 create mode 100644 Packager.ps1 create mode 100644 Readme.md create mode 100644 StreamMonitor.ps1 delete mode 100644 log.txt create mode 100644 settings.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18a5878 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +Releases +assets/* +logs \ No newline at end of file diff --git a/Events.ps1 b/Events.ps1 new file mode 100644 index 0000000..bf6571f --- /dev/null +++ b/Events.ps1 @@ -0,0 +1,88 @@ +# Determine the path of the currently running script and set the working directory to that path +param( + [Parameter(Position = 0, Mandatory = $true)] + [Alias("n")] + [string]$scriptName +) +$path = (Split-Path $MyInvocation.MyCommand.Path -Parent) +Set-Location $path +. .\Helpers.ps1 -n $scriptName + +# Load settings from a JSON file located in the same directory as the script +$settings = Get-Settings + +# Initialize a script scoped dictionary to store variables. +# This dictionary is used to pass parameters to functions that might not have direct access to script scope, like background jobs. +if (-not $script:arguments) { + $script:arguments = @{} +} + + +$dllPath = "$($PWD.Path)\HDRController.dll".Replace("\", "\\") + +# Define the function signature +Add-Type -TypeDefinition @" + using System.Runtime.InteropServices; + + public static class HDRController { + [DllImport("$dllPath", EntryPoint = "GetGlobalHDRState")] + public static extern bool GetGlobalHDRState(); + + + [DllImport("$dllPath", EntryPoint = "EnableGlobalHDRState")] + public static extern void EnableGlobalHDRState(); + + [DllImport("$dllPath", EntryPoint = "DisableGlobalHDRState")] + public static extern void DisableGlobalHDRState(); + } +"@ + + +# Function to execute at the start of a stream +function OnStreamStart() { + $hostHDR = [HDRController]::GetGlobalHDRState() + + $script:arguments.Add("hostHDR", $hostHDR) + $clientHdrState = $env:SUNSHINE_CLIENT_HDR + Write-Host "Current (Host) HDR State: $hostHDR" + Write-Host "Current (Client) HDR State: $clientHdrState" + + if ($hostHDR -ne $clientHdrState) { + if ($clientHdrState) { + Write-Host "Enabling HDR" + [HDRController]::EnableGlobalHDRState() + } + else { + Write-Host "Turning off HDR" + [HDRController]::DisableGlobalHDRState() + } + } + + if($settings.IDDSampleFix){ + if([HDRController]::GetGlobalHDRState() -and $clientHdrState){ + Write-Host "IDDSample Fix is enabled, now automating turning HDR off and on again." + [HDRController]::DisableGlobalHDRState() + [HDRController]::EnableGlobalHDRState() + Write-Host "HDR has been toggled successfully!" + } + } + elseif($hostHDR -eq $clientHdrState) { + Write-Host "Client already matches the host for HDR, no changes will be applied." + } +} + +# Function to execute at the end of a stream. This function is called in a background job, +# and hence doesn't have direct access to the script scope. $kwargs is passed explicitly to emulate script:arguments. +function OnStreamEnd($kwargs) { + + if ($kwargs["hostHDR"]) { + Write-Host "Enabling HDR" + [HDRController]::EnableGlobalHDRState() + } + else { + Write-Host "Turning off HDR" + [HDRController]::DisableGlobalHDRState() + } + + return $true +} \ No newline at end of file diff --git a/HDRToggle-Functions.ps1 b/HDRToggle-Functions.ps1 deleted file mode 100644 index 40d6acd..0000000 --- a/HDRToggle-Functions.ps1 +++ /dev/null @@ -1,95 +0,0 @@ -param($terminate) - -$dllPath = "$($PWD.Path)\HDRController.dll".Replace("\", "\\") - -# Define the function signature -Add-Type -TypeDefinition @" - using System.Runtime.InteropServices; - - public static class HDRController { - [DllImport("$dllPath", EntryPoint = "GetGlobalHDRState")] - public static extern bool GetGlobalHDRState(); - - - [DllImport("$dllPath", EntryPoint = "EnableGlobalHDRState")] - public static extern void EnableGlobalHDRState(); - - [DllImport("$dllPath", EntryPoint = "DisableGlobalHDRState")] - public static extern void DisableGlobalHDRState(); - } -"@ - -# Call the function and store the result in a variable - - -$script:hdrState = $false -function OnStreamStart($hdrMode) { - $script:hdrState = [HDRController]::GetGlobalHDRState() - $clientHdrState = [System.Boolean]::Parse($hdrMode) - Write-Host "Current (Host) HDR State: $($script:hdrState)" - Write-Host "Current (Client) HDR State: $clientHdrState" - - if ($script:hdrState -ne $clientHdrState) { - if ($clientHdrState) { - Write-Host "Enabling HDR" - [HDRController]::EnableGlobalHDRState() - } - else { - Write-Host "Turning off HDR" - [HDRController]::DisableGlobalHDRState() - } - } - else { - Write-Host "Client already matches the host for HDR, no changes will be applied." - } -} - -function OnStreamEnd() { - if ($script:hdrState) { - Write-Host "Enabling HDR" - [HDRController]::EnableGlobalHDRState() - } - else { - Write-Host "Turning off HDR" - [HDRController]::DisableGlobalHDRState() - } -} - -function IsSunshineUser() { - return $null -ne (Get-Process sunshine -ErrorAction SilentlyContinue) -} - -function IsCurrentlyStreaming() { - if (IsSunshineUser) { - return $null -ne (Get-NetUDPEndpoint -OwningProcess (Get-Process sunshine).Id -ErrorAction Ignore) - } - - return $null -ne (Get-Process nvstreamer -ErrorAction SilentlyContinue) -} - - -function Stop-HDRToggleScript() { - - $pipeExists = Get-ChildItem -Path "\\.\pipe\" | Where-Object { $_.Name -eq "HDRToggle" } - if ($pipeExists.Length -gt 0) { - $pipeName = "HDRToggle" - $pipe = New-Object System.IO.Pipes.NamedPipeClientStream(".", $pipeName, [System.IO.Pipes.PipeDirection]::Out) - $pipe.Connect(3) - $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. - } - } -} - - -if ($terminate) { - Stop-HDRToggleScript | Out-Null -} \ No newline at end of file diff --git a/HDRToggle.ps1 b/HDRToggle.ps1 deleted file mode 100644 index 434cb7e..0000000 --- a/HDRToggle.ps1 +++ /dev/null @@ -1,112 +0,0 @@ -param($async) - - -# 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; -} -$path = Split-Path $MyInvocation.MyCommand.Path -Parent -Set-Location $path -. .\HDRToggle-Functions.ps1 -$lock = $false -Start-Transcript -Path .\log.txt - - -$mutex = New-Object System.Threading.Mutex($false, "HDRToggle", [ref]$lock) - -# There is no need to have more than one of these scripts running. -if (-not $mutex.WaitOne(0)) { - Write-Host "Another instance of the script is already running. Exiting..." - exit -} - -try { - - # Asynchronously start the HDRToggle, so we can use a named pipe to terminate it. - Start-Job -Name HDRToggleJob -ScriptBlock { - param($path) - Set-Location $path - . .\HDRToggle-Functions.ps1 - $lastStreamed = Get-Date - - - Register-EngineEvent -SourceIdentifier HDRToggle -Forward - New-Event -SourceIdentifier HDRToggle -MessageData "Start" - while ($true) { - if ((IsCurrentlyStreaming)) { - $lastStreamed = Get-Date - } - else { - if (((Get-Date) - $lastStreamed).TotalSeconds -gt 120) { - Write-Output "Ending the stream script" - New-Event -SourceIdentifier HDRToggle -MessageData "End" - break; - } - - } - Start-Sleep -Seconds 1 - } - - } -ArgumentList $path - - - # To allow other powershell scripts to communicate to this one. - Start-Job -Name "HDRToggle-Pipe" -ScriptBlock { - $pipeName = "HDRToggle" - 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 HDRToggle -ErrorAction SilentlyContinue - $pipeJob = Get-Job -Name "HDRToggle-Pipe" - if ($null -ne $eventFired) { - $eventName = $eventFired.MessageData - Write-Host "Processing event: $eventName" - if ($eventName -eq "Start") { - OnStreamStart -hdrMode $env:SUNSHINE_CLIENT_HDR - } - elseif ($eventName -eq "End") { - OnStreamEnd - break; - } - Remove-Event -SourceIdentifier HDRToggle - } - elseif ($pipeJob.State -eq "Completed") { - Write-Host "Request to terminate has been processed, script will now revert back to previous HDR state." - OnStreamEnd - Remove-Job $pipeJob - break; - } - elseif ($eventMessageCount -gt 59) { - Write-Host "Still waiting for the next event to fire..." - $eventMessageCount = 0 - } - - - } -} -finally { - Remove-Item "\\.\pipe\HDRToggle" -ErrorAction Ignore - $mutex.ReleaseMutex() - Remove-Event -SourceIdentifier HDRToggle -ErrorAction Ignore - Stop-Transcript -} diff --git a/Helpers.ps1 b/Helpers.ps1 new file mode 100644 index 0000000..1185a18 --- /dev/null +++ b/Helpers.ps1 @@ -0,0 +1,202 @@ +param( + [Parameter(Position = 0, Mandatory = $true)] + [Alias("n")] + [string]$scriptName, + [Alias("t")] + [Parameter(Position = 1, Mandatory = $false)] + [int]$terminate +) +$path = (Split-Path $MyInvocation.MyCommand.Path -Parent) +Set-Location $path +$script:attempt = 0 +function OnStreamEndAsJob() { + return Start-Job -Name "$scriptName-OnStreamEnd" -ScriptBlock { + param($path, $scriptName, $arguments) + + Write-Debug "Setting location to $path" + Set-Location $path + Write-Debug "Loading Helpers.ps1 with script name $scriptName" + . .\Helpers.ps1 -n $scriptName + Write-Debug "Loading Events.ps1 with script name $scriptName" + . .\Events.ps1 -n $scriptName + + Write-Host "Stream has ended, now invoking code" + Write-Debug "Creating pipe with name $scriptName-OnStreamEnd" + $job = Create-Pipe -pipeName "$scriptName-OnStreamEnd" + + while ($true) { + $maxTries = 25 + $tries = 0 + + Write-Debug "Checking job state: $($job.State)" + if ($job.State -eq "Completed") { + Write-Host "Another instance of $scriptName has been started again. This current session is now redundant and will terminate without further action." + Write-Debug "Job state is 'Completed'. Exiting loop." + break; + } + + Write-Debug "Invoking OnStreamEnd with arguments: $arguments" + if ((OnStreamEnd $arguments)) { + Write-Debug "OnStreamEnd returned true. Exiting loop." + break; + } + + while (($tries -lt $maxTries) -and ($job.State -ne "Completed")) { + Start-Sleep -Milliseconds 200 + $tries++ + } + } + + Write-Debug "Sending 'Terminate' message to pipe $scriptName-OnStreamEnd" + Send-PipeMessage "$scriptName-OnStreamEnd" Terminate + } -ArgumentList $path, $scriptName, $script:arguments +} + + +function IsCurrentlyStreaming() { + $sunshineProcess = Get-Process sunshine -ErrorAction SilentlyContinue + + if($null -eq $sunshineProcess) { + return $false + } + return $null -ne (Get-NetUDPEndpoint -OwningProcess $sunshineProcess.Id -ErrorAction Ignore) +} + +function Stop-Script() { + Send-PipeMessage -pipeName $scriptName Terminate +} +function Send-PipeMessage($pipeName, $message) { + Write-Debug "Attempting to send message to pipe: $pipeName" + + $pipeExists = Get-ChildItem -Path "\\.\pipe\" | Where-Object { $_.Name -eq $pipeName } + Write-Debug "Pipe exists check: $($pipeExists.Length -gt 0)" + + if ($pipeExists.Length -gt 0) { + $pipe = New-Object System.IO.Pipes.NamedPipeClientStream(".", $pipeName, [System.IO.Pipes.PipeDirection]::Out) + Write-Debug "Connecting to pipe: $pipeName" + + $pipe.Connect(3000) + $streamWriter = New-Object System.IO.StreamWriter($pipe) + Write-Debug "Sending message: $message" + + $streamWriter.WriteLine($message) + try { + $streamWriter.Flush() + $streamWriter.Dispose() + $pipe.Dispose() + Write-Debug "Message sent and resources disposed successfully." + } + catch { + Write-Debug "Error during disposal: $_" + # We don't care if the disposal fails, this is common with async pipes. + # Also, this powershell script will terminate anyway. + } + } else { + Write-Debug "Pipe not found: $pipeName" + } +} + + +function Create-Pipe($pipeName) { + return Start-Job -Name "$pipeName-PipeJob" -ScriptBlock { + param($pipeName, $scriptName) + Register-EngineEvent -SourceIdentifier $scriptName -Forward + + $pipe = New-Object System.IO.Pipes.NamedPipeServerStream($pipeName, [System.IO.Pipes.PipeDirection]::In, 10, [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) { + Write-Output "Terminating pipe..." + $pipe.Dispose() + $streamReader.Dispose() + New-Event -SourceIdentifier $scriptName -MessageData $message + } + } -ArgumentList $pipeName, $scriptName +} + +function Remove-OldLogs { + + # Get all log files in the directory + $logFiles = Get-ChildItem -Path './logs' -Filter "log_*.txt" -ErrorAction SilentlyContinue + + # Sort the files by creation time, oldest first + $sortedFiles = $logFiles | Sort-Object -Property CreationTime -ErrorAction SilentlyContinue + + if ($sortedFiles) { + # Calculate how many files to delete + $filesToDelete = $sortedFiles.Count - 10 + + # Check if there are more than 10 files + if ($filesToDelete -gt 0) { + # Delete the oldest files, keeping the latest 10 + $sortedFiles[0..($filesToDelete - 1)] | Remove-Item -Force + } + } +} + +function Start-Logging { + # Get the current timestamp + $timeStamp = [int][double]::Parse((Get-Date -UFormat "%s")) + $logDirectory = "./logs" + + # Define the path and filename for the log file + $logFileName = "log_$timeStamp.txt" + $logFilePath = Join-Path $logDirectory $logFileName + + # Check if the log directory exists, and create it if it does not + if (-not (Test-Path $logDirectory)) { + New-Item -Path $logDirectory -ItemType Directory + } + + # Start logging to the log file + Start-Transcript -Path $logFilePath +} + + + +function Stop-Logging { + Stop-Transcript +} + + +function Get-Settings { + # Read the file content + $jsonContent = Get-Content -Path ".\settings.json" -Raw + + # Remove single line comments + $jsonContent = $jsonContent -replace '//.*', '' + + # Remove multi-line comments + $jsonContent = $jsonContent -replace '/\*[\s\S]*?\*/', '' + + # Remove trailing commas from arrays and objects + $jsonContent = $jsonContent -replace ',\s*([\]}])', '$1' + + try { + # Convert JSON content to PowerShell object + $jsonObject = $jsonContent | ConvertFrom-Json + return $jsonObject + } + catch { + Write-Error "Failed to parse JSON: $_" + } +} + +function Wait-ForStreamEndJobToComplete() { + $job = OnStreamEndAsJob + while ($job.State -ne "Completed") { + $job | Receive-Job + Start-Sleep -Seconds 1 + } + $job | Wait-Job | Receive-Job +} + + +if ($terminate -eq 1) { + Write-Host "Stopping Script" + Stop-Script | Out-Null +} diff --git a/Install.bat b/Install.bat index 9435a83..57019e6 100644 --- a/Install.bat +++ b/Install.bat @@ -1 +1 @@ -powershell.exe -executionpolicy bypass -file ./Installer.ps1 True \ No newline at end of file +powershell.exe -executionpolicy bypass -file ./Installer.ps1 -n AutoHDR -i 1 diff --git a/Installer.ps1 b/Installer.ps1 index 7995b47..b838bf8 100644 --- a/Installer.ps1 +++ b/Installer.ps1 @@ -1,57 +1,78 @@ -## 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) +param( + [Parameter(Position = 0, Mandatory = $true)] + [Alias("n")] + [string]$scriptName, + + [Parameter(Position = 1, Mandatory = $true)] + [Alias("i")] + [string]$install +) +Set-Location (Split-Path $MyInvocation.MyCommand.Path -Parent) $filePath = $($MyInvocation.MyCommand.Path) $scriptRoot = Split-Path $filePath -Parent -$scriptPath = "$scriptRoot\HDRToggle.ps1" - +$scriptPath = "$scriptRoot\StreamMonitor.ps1" +. .\Helpers.ps1 -n $scriptName +$settings = Get-Settings # This script modifies the global_prep_cmd setting in the Sunshine configuration file -# to add a command that runs HDRToggle.ps1 -# Check if the current user has administrator privileges -$isAdmin = [bool]([System.Security.Principal.WindowsIdentity]::GetCurrent().groups -match 'S-1-5-32-544') +function Test-UACEnabled { + $key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' + $uacEnabled = Get-ItemProperty -Path $key -Name 'EnableLUA' + return [bool]$uacEnabled.EnableLUA +} + -# 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" +$isAdmin = [bool]([System.Security.Principal.WindowsIdentity]::GetCurrent().Groups -match 'S-1-5-32-544') + +# If the user is not an administrator and UAC is enabled, re-launch the script with elevated privileges +if (-not $isAdmin -and (Test-UACEnabled)) { + Start-Process powershell.exe -Verb RunAs -ArgumentList "-ExecutionPolicy Bypass -NoExit -File `"$filePath`" -n `"$scriptName`" -i `"$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 = "C:\Program Files\Sunshine\config\sunshine.conf" +$confPath = Test-AndRequest-SunshineConfig -InitialPath "C:\Program Files\Sunshine\config\sunshine.conf" $scriptRoot = Split-Path $scriptPath -Parent @@ -75,9 +96,8 @@ function Get-GlobalPrepCommand { } } -# Remove any existing commands that contain HDRToggle from the global_prep_cmd value -function Remove-HDRToggleCommand { - +# Remove any existing commands that contain the scripts name from the global_prep_cmd value +function Remove-Command { # Get the current value of global_prep_cmd as a JSON string $globalPrepCmdJson = Get-GlobalPrepCommand -ConfigPath $confPath @@ -85,9 +105,9 @@ function Remove-HDRToggleCommand { $globalPrepCmdArray = $globalPrepCmdJson | ConvertFrom-Json $filteredCommands = @() - # Remove any HDRToggle Commands + # Remove any existing matching Commands for ($i = 0; $i -lt $globalPrepCmdArray.Count; $i++) { - if (-not ($globalPrepCmdArray[$i].do -like "*HDRToggle*")) { + if (-not ($globalPrepCmdArray[$i].do -like "*$scriptName*")) { $filteredCommands += $globalPrepCmdArray[$i] } } @@ -138,37 +158,84 @@ function Set-GlobalPrepCommand { # Write the modified config array back to the file $config | Set-Content -Path $confPath -Force } +function OrderCommands($commands, $scriptNames) { + $orderedCommands = New-Object System.Collections.ArrayList + + if($commands -isnot [System.Collections.IEnumerable]) { + # PowerShell likes to magically change types on you, so we have to check for this + $commands = @(, $commands) + } + + $orderedCommands.AddRange($commands) + + for ($i = 1; $i -lt $scriptNames.Count; $i++) { + if ($i - 1 -lt 0) { + continue + } + + $before = $scriptNames[$i - 1] + $after = $scriptNames[$i] + + $afterCommand = $orderedCommands | Where-Object { $_.do -like "*$after*" -or $_.undo -like "*$after*" } | Select-Object -First 1 + + $beforeIndex = $null + for ($j = 0; $j -lt $orderedCommands.Count; $j++) { + if ($orderedCommands[$j].do -like "*$before*" -or $orderedCommands[$j].undo -like "*$before*") { + $beforeIndex = $j + break + } + } + $afterIndex = $null + for ($j = 0; $j -lt $orderedCommands.Count; $j++) { + if ($orderedCommands[$j].do -like "*$after*" -or $orderedCommands[$j].undo -like "*$after*") { + $afterIndex = $j + break + } + } + + if ($null -ne $afterIndex -and ($afterIndex -lt $beforeIndex)) { + $orderedCommands.RemoveAt($afterIndex) + $orderedCommands.Insert($beforeIndex, $afterCommand) + + } + + } + + $orderedCommands + +} -# Add a new command to run HDRToggle.ps1 to the global_prep_cmd value -function Add-HDRToggleCommand { +function Add-Command { - # Remove any existing commands that contain HDRToggle from the global_prep_cmd value - $globalPrepCmdArray = Remove-HDRToggleCommand -ConfigPath $confPath + # Remove any existing commands that contain the scripts name from the global_prep_cmd value + $globalPrepCmdArray = Remove-Command -ConfigPath $confPath - # Create a new object with the command to run HDRToggle.ps1 - $HDRToggleCommand = [PSCustomObject]@{ - do = "powershell.exe -executionpolicy bypass -file `"$($scriptPath)`"" + $command = [PSCustomObject]@{ + do = "powershell.exe -executionpolicy bypass -file `"$($scriptPath)`" -n $scriptName" elevated = "false" - undo = "powershell.exe -executionpolicy bypass -file `"$($scriptRoot)\HDRToggle-Functions.ps1`" $true" + undo = "powershell.exe -executionpolicy bypass -file `"$($scriptRoot)\Helpers.ps1`" -n $scriptName -t 1" } # Add the new object to the global_prep_cmd array - [object[]]$globalPrepCmdArray += $HDRToggleCommand + [object[]]$globalPrepCmdArray += $command return [object[]]$globalPrepCmdArray } $commands = @() -if ($install -eq "True") { - $commands = Add-HDRToggleCommand +if ($install -eq 1) { + $commands = Add-Command } else { - $commands = Remove-HDRToggleCommand + $commands = Remove-Command } -Set-GlobalPrepCommand $commands +if ($settings.installationOrderPreferences.enabled) { + $commands = OrderCommands $commands $settings.installationOrderPreferences.scriptNames +} +Set-GlobalPrepCommand $commands -$sunshineService = Get-Service -ErrorAction Ignore | Where-Object {$_.Name -eq 'sunshinesvc' -or $_.Name -eq 'SunshineService'} +$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." diff --git a/Packager.ps1 b/Packager.ps1 new file mode 100644 index 0000000..ffd5ed5 --- /dev/null +++ b/Packager.ps1 @@ -0,0 +1,78 @@ +param( + [string]$scriptName +) + +$whiteListedEntries = @("Packager.ps1", "Releases", "*.txt", ".gitignore", "logs", ".vscode") +$releaseBasePath = "Releases" +$releasePath = Join-Path -Path $releaseBasePath -ChildPath $scriptName +$assetsPath = Join-Path -Path $releaseBasePath -ChildPath "assets" + +# Remove existing release directory if it exists +Remove-Item -Force $releasePath -Recurse -ErrorAction SilentlyContinue + +# Ensure the Releases directory exists +if (-not (Test-Path -Path $releaseBasePath)) { + New-Item -ItemType Directory -Path $releaseBasePath | Out-Null +} + +# Ensure the assets directory exists +if (-not (Test-Path -Path $assetsPath)) { + New-Item -ItemType Directory -Path $assetsPath | Out-Null +} + +# Get all top-level items from the current directory, excluding the Releases directory +$items = Get-ChildItem -Path . | Where-Object { + $_.FullName -notmatch "^\.\\Releases(\\|$)" +} + +# Create a hashtable for quick whitelist lookup +$whitelistHash = @{} +foreach ($whitelist in $whiteListedEntries) { + $whitelistHash[$whitelist] = $true +} + +# Create a hashtable to store asset files and directories for quick lookup +$assetItems = @{} +Get-ChildItem -Path $assetsPath -Recurse | ForEach-Object { + $assetItems[$_.Name] = $_.FullName +} + +# Filter and replace items efficiently +$filteredItems = @() +foreach ($item in $items) { + $itemName = $item.Name + + # Check for whitelist + $isWhitelisted = $false + foreach ($key in $whitelistHash.Keys) { + if ($itemName -like $key) { + $isWhitelisted = $true + break + } + } + + if (-not $isWhitelisted) { + if ($assetItems.ContainsKey($itemName)) { + $filteredItems += Get-Item -Path $assetItems[$itemName] + } else { + $filteredItems += $item + } + } +} + +# Create the release directory named after the script +if (-not (Test-Path -Path $releasePath)) { + New-Item -ItemType Directory -Path $releasePath | Out-Null +} + +# Copy the filtered items to the release directory +foreach ($item in $filteredItems) { + $destinationPath = Join-Path -Path $releasePath -ChildPath $item.Name + if ($item.PSIsContainer) { + Copy-Item -Path $item.FullName -Destination $destinationPath -Recurse -Force + } else { + Copy-Item -Path $item.FullName -Destination $destinationPath -Force + } +} + +Write-Output "Files and directories have been copied to $releasePath" diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..5a439ef --- /dev/null +++ b/Readme.md @@ -0,0 +1,29 @@ +## Requirements: +- Host must be Windows +- HDR Capable Display +- Sunshine 0.21.0 or higher + +## Caveats: + - 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. + * That can be changed at Settings > Privacy & security > Security > For developers > Terminal [Let Windows decide] >> (change to) >> Terminal [Windows Console Host] + - Due to Windows API restrictions, this script does not work on cold reboots (hard crashes or shutdowns of your computer). + * Fortunately recent changes to Sunshine makes this issue much easier to workaround. + * 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. + - The script will stop working if you move the folder, simply reinstall it to resolve that issue. + +## What it Does: +Checks to see if the last connected Moonlight client asked for HDR, if so, it will enable HDR. Otherwise, it will disable it. +Once the stream ends, it will configure the last HDR setting prior to starting the stream. + +(Optionally) If enabled, will toggle HDR on and off automatically to fix issues with the IDDSampleDriver on overblown colors when streaming in HDR. + +## Credits: +The HDR toggling code is from the following repositories: +- https://github.com/Codectory/AutoActions - The original developer of the HDR toggle code that made calling the DLL possible. +- https://github.com/anaisbetts/AutoActions - She added two additional exported functions to make calling the DLL easier. + +## Installation: +1. Store this folder in a location you intend to keep. If you delete this folder or move it, the automation will stop working. +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. +3. To install, double click the Install.bat file. You may get a smart screen warning, this is normal. +4. To uninstall, do the same thing with Uninstall.bat. diff --git a/StreamMonitor.ps1 b/StreamMonitor.ps1 new file mode 100644 index 0000000..6754991 --- /dev/null +++ b/StreamMonitor.ps1 @@ -0,0 +1,121 @@ +param( + [Parameter(Position = 0, Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [Alias("n")] + [string]$scriptName, + + [Parameter(Position = 1)] + [Alias("sib")] + [bool]$startInBackground +) +$path = (Split-Path $MyInvocation.MyCommand.Path -Parent) +Set-Location $path +. .\Helpers.ps1 -n $scriptName +. .\Events.ps1 -n $scriptName +$settings = Get-Settings +$DebugPreference = if ($settings.debug) { "Continue" } else { "SilentlyContinue" } + +# Since pre-commands in sunshine are synchronous, we'll launch this script again in another powershell process +if ($startInBackground -eq $false) { + $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition + $arguments = "-ExecutionPolicy Bypass -Command `"& '$scriptPath\StreamMonitor.ps1' -scriptName $scriptName -sib 1`"" + Start-Process powershell.exe -ArgumentList $arguments -WindowStyle Hidden + Start-Sleep -Seconds $settings.startDelay + exit +} + + +Remove-OldLogs +Start-Logging + +# OPTIONAL MUTEX HANDLING +# Create a mutex to prevent multiple instances of this script from running simultaneously. +$lock = $false +$mutex = New-Object System.Threading.Mutex($false, $scriptName, [ref]$lock) + +# Exit the script if another instance is already running. +if (-not $mutex.WaitOne(0)) { + Write-Host "Exiting: Another instance of the script is currently running." + exit +} + +if (-not $mutex) { + ### If you don't use a mutex, you can optionally fan the hammer + for ($i = 0; $i -lt 6; $i++) { + Send-PipeMessage $scriptName NewSession + Send-PipeMessage "$scriptName-OnStreamEnd" Terminate + } +} + + +# END OF OPTIONAL MUTEX HANDLING + + +try { + + # Asynchronously start the script, so we can use a named pipe to terminate it. + Start-Job -Name "$($scriptName)Job" -ScriptBlock { + param($path, $scriptName, $gracePeriod) + . $path\Helpers.ps1 -n $scriptName + $lastStreamed = Get-Date + + + Register-EngineEvent -SourceIdentifier $scriptName -Forward + New-Event -SourceIdentifier $scriptName -MessageData "Start" + while ($true) { + try { + if ((IsCurrentlyStreaming)) { + $lastStreamed = Get-Date + } + else { + if (((Get-Date) - $lastStreamed).TotalSeconds -gt $gracePeriod) { + New-Event -SourceIdentifier $scriptName -MessageData "GracePeriodExpired" + break; + } + + } + } + finally { + Start-Sleep -Seconds 1 + } + } + + } -ArgumentList $path, $scriptName, $settings.gracePeriod | Out-Null + + + # This might look like black magic, but basically we don't have to monitor this pipe because it fires off an event. + Create-Pipe $scriptName | Out-Null + + Write-Host "Waiting for the next event to be called... (for starting/ending stream)" + while ($true) { + Start-Sleep -Seconds 1 + $eventFired = Get-Event -SourceIdentifier $scriptName -ErrorAction SilentlyContinue + if ($null -ne $eventFired) { + $eventName = $eventFired.MessageData + Write-Host "Processing event: $eventName" + if ($eventName -eq "Start") { + OnStreamStart + } + elseif ($eventName -eq "NewSession") { + 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." + break; + } + elseif ($eventName -eq "GracePeriodExpired") { + 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." + Wait-ForStreamEndJobToComplete + break; + } + else { + Wait-ForStreamEndJobToComplete + break; + } + Remove-Event -EventIdentifier $eventFired.EventIdentifier + } + } +} +finally { + if ($mutex) { + $mutex.ReleaseMutex() + } + Stop-Logging +} diff --git a/Uninstall.bat b/Uninstall.bat index 78ec209..eb99c5c 100644 --- a/Uninstall.bat +++ b/Uninstall.bat @@ -1 +1 @@ -powershell.exe -executionpolicy bypass -file ./Installer.ps1 False \ No newline at end of file +powershell.exe -executionpolicy bypass -file ./Installer.ps1 -n AutoHDR -i 0 diff --git a/log.txt b/log.txt deleted file mode 100644 index e69de29..0000000 diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..66ab89e --- /dev/null +++ b/settings.json @@ -0,0 +1,20 @@ +{ + "debug": false, + "gracePeriod": 180, + "startDelay": 1, + "installationOrderPreferences": { + "enabled": true, + // 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. + // All of these scripts are optional and will not impact your experience if they are not installed. + // However, I am shamelessly plugging in links to the scripts here, so you can easily find them. + "scriptNames": [ + "MonitorSwapper", // https://github.com/Nonary/MonitorSwapAutomation/releases/latest (Swaps the primary monitor to a dummy plug and then back when finished.) + "ResolutionMatcher", // https://github.com/Nonary/ResolutionAutomation/releases/latest (Automatically sets the resolution to the same as the client streaming.) + "AutoHDR", // https://github.com/Nonary/AutoHDRSwitch/releases/latest (Automatically enables HDR if the client is streaming HDR content.) + "RTSSLimiter", // https://github.com/Nonary/RTSSLimiter/releases/latest (Limits the host framerate to the client's streaming framerate to reduce microstuttering) + "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.) + ] + }, + // Toggles HDR on and off to fix the blown out colors issue on IDDSampleDriver, set to true to enable. + "IDDSampleFix": true +} \ No newline at end of file