Files
SunshineDisplaySwitch/select_sunshine_monitor.bat
T
Andre 995cf19fc9 feat: configure Sunshine display selection
Coarse DisplaySwitch projection modes cannot reliably target the virtual display once multiple physical monitors are connected. The setup needs to persist Sunshine's device-specific configuration and document the recovery path so stream startup and abnormal shutdown handling remain predictable.
2026-06-09 20:45:40 +02:00

624 lines
19 KiB
Batchfile

@echo off
setlocal
set "SDS_SELF=%~f0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -Sta -Command "$path=$env:SDS_SELF; $raw=Get-Content -LiteralPath $path -Raw; $marker='### POWERSHELL ###'; $index=$raw.LastIndexOf($marker); if($index -lt 0){throw 'PowerShell block not found.'}; $code=$raw.Substring($index + $marker.Length); Invoke-Expression $code"
exit /b %ERRORLEVEL%
### POWERSHELL ###
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$SunshineDir = if ([string]::IsNullOrWhiteSpace($env:SDS_SUNSHINE_DIR)) { Join-Path $env:ProgramFiles 'Sunshine' } else { $env:SDS_SUNSHINE_DIR }
$DeviceId = $env:SDS_DEVICE_ID
$ListOnly = $env:SDS_LIST_ONLY -eq '1'
$NoRestart = $env:SDS_NO_RESTART -eq '1'
$Silent = $env:SDS_SILENT -eq '1'
$Action = $env:SDS_ACTION
$BatchPath = $env:SDS_SELF
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Test-CanWriteFile {
param([string]$Path)
try {
$stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::Read)
$stream.Close()
return $true
}
catch {
return $false
}
}
function Get-JsonArrayAfterMarker {
param(
[string]$Text,
[string]$Marker
)
$markerIndex = $Text.LastIndexOf($Marker, [StringComparison]::OrdinalIgnoreCase)
if ($markerIndex -lt 0) {
return $null
}
$start = $Text.IndexOf('[', $markerIndex + $Marker.Length)
if ($start -lt 0) {
return $null
}
$depth = 0
$inString = $false
$escaped = $false
for ($i = $start; $i -lt $Text.Length; $i++) {
$char = $Text[$i]
if ($inString) {
if ($escaped) {
$escaped = $false
continue
}
if ($char -eq '\') {
$escaped = $true
continue
}
if ($char -eq '"') {
$inString = $false
}
continue
}
if ($char -eq '"') {
$inString = $true
continue
}
if ($char -eq '[') {
$depth++
continue
}
if ($char -eq ']') {
$depth--
if ($depth -eq 0) {
return $Text.Substring($start, $i - $start + 1)
}
}
}
return $null
}
function Format-Rational {
param($Rational)
$rationalValue = Get-ObjectProperty -Object $Rational -Name 'value'
if ($null -eq $rationalValue) {
return ''
}
$denominator = [double](Get-ObjectProperty -Object $rationalValue -Name 'denominator')
if ($denominator -eq 0) {
return ''
}
$value = [double](Get-ObjectProperty -Object $rationalValue -Name 'numerator') / $denominator
$formatted = ('{0:N2}' -f $value).TrimEnd('0').TrimEnd((Get-Culture).NumberFormat.NumberDecimalSeparator)
return "$formatted Hz"
}
function Get-ObjectProperty {
param(
$Object,
[string]$Name
)
if ($null -eq $Object) {
return $null
}
$property = $Object.PSObject.Properties[$Name]
if ($null -eq $property) {
return $null
}
return $property.Value
}
function ConvertTo-DisplayRows {
param([object[]]$Devices)
if ($Devices.Count -eq 1 -and $Devices[0] -is [Array]) {
$Devices = $Devices[0]
}
$rows = @()
foreach ($device in $Devices) {
$resolution = ''
$refreshRate = ''
$primary = $false
$active = $false
$info = Get-ObjectProperty -Object $device -Name 'info'
if ($null -ne $info) {
$active = $true
$resolutionInfo = Get-ObjectProperty -Object $info -Name 'resolution'
if ($null -ne $resolutionInfo) {
$resolution = '{0}x{1}' -f (Get-ObjectProperty -Object $resolutionInfo -Name 'width'), (Get-ObjectProperty -Object $resolutionInfo -Name 'height')
}
$refreshRate = Format-Rational -Rational (Get-ObjectProperty -Object $info -Name 'refresh_rate')
$primaryInfo = Get-ObjectProperty -Object $info -Name 'primary'
if ($null -ne $primaryInfo) {
$primary = [bool]$primaryInfo
}
}
$edid = ''
$deviceEdid = Get-ObjectProperty -Object $device -Name 'edid'
if ($null -ne $deviceEdid) {
$edidParts = @()
$manufacturerId = Get-ObjectProperty -Object $deviceEdid -Name 'manufacturer_id'
$productCode = Get-ObjectProperty -Object $deviceEdid -Name 'product_code'
$serialNumber = Get-ObjectProperty -Object $deviceEdid -Name 'serial_number'
if ($manufacturerId) { $edidParts += $manufacturerId }
if ($productCode) { $edidParts += $productCode }
if ($null -ne $serialNumber) { $edidParts += "SN:$serialNumber" }
$edid = $edidParts -join ' '
}
$rows += [pscustomobject]@{
Name = [string](Get-ObjectProperty -Object $device -Name 'friendly_name')
DeviceId = [string](Get-ObjectProperty -Object $device -Name 'device_id')
DisplayName = [string](Get-ObjectProperty -Object $device -Name 'display_name')
Active = $active
Primary = $primary
Resolution = $resolution
RefreshRate = $refreshRate
Edid = $edid
}
}
return $rows
}
function Get-SunshineDisplayDevicesDirect {
param([string]$InstallDir)
$sunshineExe = Join-Path $InstallDir 'sunshine.exe'
if (-not (Test-Path -LiteralPath $sunshineExe)) {
throw "Sunshine executable not found: $sunshineExe"
}
$stdoutPath = Join-Path $env:TEMP ('sunshine-display-probe-{0}.out' -f ([guid]::NewGuid()))
$stderrPath = Join-Path $env:TEMP ('sunshine-display-probe-{0}.err' -f ([guid]::NewGuid()))
$probeLogPath = Join-Path $env:TEMP ('sunshine-display-probe-{0}.log' -f ([guid]::NewGuid()))
$process = $null
function Stop-ProbeProcess {
param($Process)
if ($null -eq $Process -or $Process.HasExited) {
return
}
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
try {
$Process.WaitForExit(2000) | Out-Null
}
catch {
}
}
try {
$arguments = @('-1', "log_path=$probeLogPath")
$process = Start-Process -FilePath $sunshineExe -ArgumentList $arguments -WorkingDirectory $InstallDir -WindowStyle Hidden -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -PassThru
$deadline = (Get-Date).AddSeconds(10)
$json = $null
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 200
if (Test-Path -LiteralPath $stdoutPath) {
$text = Get-Content -LiteralPath $stdoutPath -Raw
$json = Get-JsonArrayAfterMarker -Text $text -Marker 'Currently available display devices:'
if (-not [string]::IsNullOrWhiteSpace($json)) {
break
}
}
if ($process.HasExited) {
break
}
}
Stop-ProbeProcess -Process $process
if ([string]::IsNullOrWhiteSpace($json)) {
$stdout = if (Test-Path -LiteralPath $stdoutPath) { Get-Content -LiteralPath $stdoutPath -Raw } else { '' }
$stderr = if (Test-Path -LiteralPath $stderrPath) { Get-Content -LiteralPath $stderrPath -Raw } else { '' }
throw "Sunshine did not return a display-device list.`n`nstdout:`n$stdout`n`nstderr:`n$stderr"
}
return ConvertTo-DisplayRows -Devices @($json | ConvertFrom-Json)
}
finally {
Stop-ProbeProcess -Process $process
Remove-Item -LiteralPath $stdoutPath, $stderrPath, $probeLogPath -ErrorAction SilentlyContinue
}
}
function Set-ConfigValue {
param(
[string[]]$Lines,
[string]$Key,
[string]$Value
)
$pattern = '^\s*' + [regex]::Escape($Key) + '\s*='
$replacement = "$Key = $Value"
$found = $false
for ($i = 0; $i -lt $Lines.Count; $i++) {
if ($Lines[$i] -match $pattern) {
$Lines[$i] = $replacement
$found = $true
break
}
}
if (-not $found) {
$Lines += $replacement
}
return ,$Lines
}
function Remove-ConfigValues {
param(
[string[]]$Lines,
[string[]]$Keys
)
$patterns = @($Keys | ForEach-Object { '^\s*' + [regex]::Escape($_) + '\s*=' })
$kept = @()
foreach ($line in $Lines) {
$remove = $false
foreach ($pattern in $patterns) {
if ($line -match $pattern) {
$remove = $true
break
}
}
if (-not $remove) {
$kept += $line
}
}
return ,$kept
}
function Remove-LegacyDisplayPrepCommands {
param([string[]]$Lines)
$pattern = '^\s*global_prep_cmd\s*=\s*(.+?)\s*$'
for ($i = 0; $i -lt $Lines.Count; $i++) {
if ($Lines[$i] -notmatch $pattern) {
continue
}
$json = $Matches[1]
try {
$commands = @($json | ConvertFrom-Json)
if ($commands.Count -eq 1 -and $commands[0] -is [Array]) {
$commands = $commands[0]
}
}
catch {
return ,$Lines
}
$kept = @()
foreach ($command in $commands) {
$do = if ($null -ne $command.do) { [string]$command.do } else { '' }
$undo = if ($null -ne $command.undo) { [string]$command.undo } else { '' }
$combined = "$do $undo"
$isLegacyDisplayCommand = $combined -match '(?i)(idd_an\.bat|idd_aus\.bat|DisplaySwitch(\.exe)?\s+(/internal|/external|/extend|/clone|[1234]))'
if (-not $isLegacyDisplayCommand) {
$kept += $command
}
}
$Lines[$i] = 'global_prep_cmd = ' + (ConvertTo-Json -InputObject $kept -Compress -Depth 10)
return ,$Lines
}
return ,$Lines
}
function Save-SunshineConfig {
param(
[string]$ConfigPath,
[pscustomobject]$SelectedDisplay
)
if (-not (Test-Path -LiteralPath $ConfigPath)) {
throw "Sunshine config not found: $ConfigPath"
}
$backupPath = '{0}.bak-{1}' -f $ConfigPath, (Get-Date -Format 'yyyyMMdd-HHmmss')
Copy-Item -LiteralPath $ConfigPath -Destination $backupPath
$lines = @(Get-Content -LiteralPath $ConfigPath)
$lines = Remove-LegacyDisplayPrepCommands -Lines $lines
$lines = Set-ConfigValue -Lines $lines -Key 'output_name' -Value $SelectedDisplay.DeviceId
$lines = Set-ConfigValue -Lines $lines -Key 'dd_configuration_option' -Value 'ensure_only_display'
$lines = Set-ConfigValue -Lines $lines -Key 'dd_resolution_option' -Value 'auto'
$lines = Set-ConfigValue -Lines $lines -Key 'dd_refresh_rate_option' -Value 'auto'
$lines = Set-ConfigValue -Lines $lines -Key 'dd_config_revert_delay' -Value '3000'
Set-Content -LiteralPath $ConfigPath -Value $lines -Encoding UTF8
return $backupPath
}
function Reset-SunshineDisplayConfig {
param([string]$ConfigPath)
if (-not (Test-Path -LiteralPath $ConfigPath)) {
throw "Sunshine config not found: $ConfigPath"
}
$backupPath = '{0}.bak-{1}' -f $ConfigPath, (Get-Date -Format 'yyyyMMdd-HHmmss')
Copy-Item -LiteralPath $ConfigPath -Destination $backupPath
$keysToRemove = @(
'output_name'
'dd_configuration_option'
'dd_resolution_option'
'dd_refresh_rate_option'
'dd_config_revert_delay'
)
$lines = @(Get-Content -LiteralPath $ConfigPath)
$lines = Remove-ConfigValues -Lines $lines -Keys $keysToRemove
Set-Content -LiteralPath $ConfigPath -Value $lines -Encoding UTF8
return $backupPath
}
function Show-MonitorMenu {
param([object[]]$Devices)
Write-Host ''
Write-Host 'Sunshine Streaming-Monitor auswaehlen'
Write-Host '===================================='
Write-Host ''
for ($i = 0; $i -lt $Devices.Count; $i++) {
$device = $Devices[$i]
$state = if ($device.Active) { 'aktiv' } else { 'inaktiv' }
$primary = if ($device.Primary) { ', primaer' } else { '' }
$displayName = if ([string]::IsNullOrWhiteSpace($device.DisplayName)) { '-' } else { $device.DisplayName }
$resolution = if ([string]::IsNullOrWhiteSpace($device.Resolution)) { '-' } else { $device.Resolution }
$refreshRate = if ([string]::IsNullOrWhiteSpace($device.RefreshRate)) { '-' } else { $device.RefreshRate }
$edid = if ([string]::IsNullOrWhiteSpace($device.Edid)) { '-' } else { $device.Edid }
Write-Host ('[{0}] {1}' -f ($i + 1), $device.Name)
Write-Host (' ID : {0}' -f $device.DeviceId)
Write-Host (' DisplayName : {0}' -f $displayName)
Write-Host (' Status : {0}{1}' -f $state, $primary)
Write-Host (' Modus : {0}, {1}' -f $resolution, $refreshRate)
Write-Host (' EDID : {0}' -f $edid)
Write-Host ''
}
Write-Host '[R] Sunshine-Monitor-Einstellungen auf Default zuruecksetzen'
Write-Host '[Q] Abbrechen'
Write-Host ''
while ($true) {
$choice = (Read-Host 'Auswahl').Trim()
if ($choice -match '^(q|Q)$') {
return [pscustomobject]@{ Action = 'quit'; Display = $null }
}
if ($choice -match '^(r|R)$') {
return [pscustomobject]@{ Action = 'reset'; Display = $null }
}
$number = 0
if ([int]::TryParse($choice, [ref]$number) -and $number -ge 1 -and $number -le $Devices.Count) {
return [pscustomobject]@{ Action = 'select'; Display = $Devices[$number - 1] }
}
Write-Host 'Ungueltige Auswahl. Bitte Zahl, R oder Q eingeben.'
}
}
function Pause-IfInteractive {
if (-not $Silent -and -not $ListOnly -and $env:SDS_NO_PAUSE -ne '1') {
Write-Host ''
Read-Host 'Enter zum Schliessen' | Out-Null
}
}
function Restart-Elevated {
param(
[string]$RequestedAction,
[string]$SelectedDeviceId
)
$commands = @(
('set "SDS_ACTION={0}"' -f $RequestedAction)
('set "SDS_SUNSHINE_DIR={0}"' -f $SunshineDir)
('set "SDS_NO_RESTART={0}"' -f ($(if ($NoRestart) { '1' } else { '0' })))
)
if (-not [string]::IsNullOrWhiteSpace($SelectedDeviceId)) {
$commands += ('set "SDS_DEVICE_ID={0}"' -f $SelectedDeviceId)
}
if (-not [string]::IsNullOrWhiteSpace($env:SDS_CONFIG_PATH)) {
$commands += ('set "SDS_CONFIG_PATH={0}"' -f $env:SDS_CONFIG_PATH)
}
if ($Silent) {
$commands += 'set "SDS_SILENT=1"'
}
$commands += ('"{0}"' -f $BatchPath)
$command = $commands -join ' && '
Start-Process -FilePath $env:ComSpec -ArgumentList ('/c ' + $command) -Verb RunAs
}
function Restart-SunshineServiceIfRequested {
param([switch]$NoRestart)
if ($NoRestart) {
return
}
$answer = (Read-Host 'Sunshine jetzt neu starten, damit die Aenderung sofort aktiv wird? [j/N]').Trim()
if ($answer -notmatch '^(j|J|y|Y)$') {
return
}
$services = @(Get-Service | Where-Object {
$_.Name -like '*Sunshine*' -or $_.DisplayName -like '*Sunshine*'
})
if ($services.Count -eq 0) {
Write-Host 'Kein Sunshine-Dienst gefunden. Starte Sunshine manuell neu.'
return
}
foreach ($service in $services) {
Restart-Service -Name $service.Name -Force
}
Write-Host 'Sunshine wurde neu gestartet.'
}
$configPath = if ([string]::IsNullOrWhiteSpace($env:SDS_CONFIG_PATH)) { Join-Path $SunshineDir 'config\sunshine.conf' } else { $env:SDS_CONFIG_PATH }
try {
if ($Action -eq 'reset') {
if (-not (Test-CanWriteFile -Path $configPath) -and -not (Test-IsAdministrator)) {
Restart-Elevated -RequestedAction 'reset' -SelectedDeviceId ''
exit
}
$backupPath = Reset-SunshineDisplayConfig -ConfigPath $configPath
if ($Silent) {
Write-Output ("Reset: Sunshine display settings removed")
Write-Output ("Config: {0}" -f $configPath)
Write-Output ("Backup: {0}" -f $backupPath)
exit
}
Write-Host ''
Write-Host 'Sunshine-Monitor-Einstellungen wurden auf Default zurueckgesetzt.'
Write-Host ("Config : {0}" -f $configPath)
Write-Host ("Backup : {0}" -f $backupPath)
Write-Host ''
Restart-SunshineServiceIfRequested -NoRestart:$NoRestart
Pause-IfInteractive
exit
}
if ([string]::IsNullOrWhiteSpace($DeviceId)) {
$devices = @(Get-SunshineDisplayDevicesDirect -InstallDir $SunshineDir)
}
else {
$devices = @(Get-SunshineDisplayDevicesDirect -InstallDir $SunshineDir)
}
if ($ListOnly) {
$devices | Format-Table Name, DeviceId, DisplayName, Active, Primary, Resolution, RefreshRate -AutoSize
exit
}
if ([string]::IsNullOrWhiteSpace($DeviceId)) {
$menuResult = Show-MonitorMenu -Devices $devices
if ($menuResult.Action -eq 'quit') {
Write-Host 'Abgebrochen.'
Pause-IfInteractive
exit
}
if ($menuResult.Action -eq 'reset') {
if (-not (Test-CanWriteFile -Path $configPath) -and -not (Test-IsAdministrator)) {
Restart-Elevated -RequestedAction 'reset' -SelectedDeviceId ''
exit
}
$backupPath = Reset-SunshineDisplayConfig -ConfigPath $configPath
Write-Host ''
Write-Host 'Sunshine-Monitor-Einstellungen wurden auf Default zurueckgesetzt.'
Write-Host ("Config : {0}" -f $configPath)
Write-Host ("Backup : {0}" -f $backupPath)
Write-Host ''
Restart-SunshineServiceIfRequested -NoRestart:$NoRestart
Pause-IfInteractive
exit
}
$selectedDisplay = $menuResult.Display
}
else {
$selectedDisplay = $devices | Where-Object { $_.DeviceId -ieq $DeviceId } | Select-Object -First 1
if ($null -eq $selectedDisplay) {
throw "Display device id not found in Sunshine output: $DeviceId"
}
}
if ($null -eq $selectedDisplay) {
exit
}
if (-not (Test-CanWriteFile -Path $configPath) -and -not (Test-IsAdministrator)) {
Restart-Elevated -RequestedAction 'select' -SelectedDeviceId $selectedDisplay.DeviceId
exit
}
$backupPath = Save-SunshineConfig -ConfigPath $configPath -SelectedDisplay $selectedDisplay
if ($Silent) {
Write-Output ("Selected: {0}" -f $selectedDisplay.Name)
Write-Output ("DeviceId: {0}" -f $selectedDisplay.DeviceId)
Write-Output ("Config: {0}" -f $configPath)
Write-Output ("Backup: {0}" -f $backupPath)
exit
}
Write-Host ''
Write-Host 'Sunshine-Konfiguration gespeichert.'
Write-Host ("Ausgewaehlt : {0}" -f $selectedDisplay.Name)
Write-Host ("ID : {0}" -f $selectedDisplay.DeviceId)
Write-Host ("Config : {0}" -f $configPath)
Write-Host ("Backup : {0}" -f $backupPath)
Write-Host ''
Restart-SunshineServiceIfRequested -NoRestart:$NoRestart
Pause-IfInteractive
}
catch {
Write-Error $_
Pause-IfInteractive
exit 1
}