auto xrandr for two monitors bash script

Slug: autoxrandr

23030 characters 2508 words
#!/usr/bin/env pwsh <# pwsh-monitor-setup.ps1 — Interactive + non-interactive multi-monitor configurator for X11 (Linux) TARGET: Debian/Ubuntu/Derivatives, Fedora/RHEL/CentOS, openSUSE, Arch-based (pwsh, xrandr) WM/TERM agnostic (works with ratpoison/Alacritty etc.) KEY CAPABILITIES - Interactive discovery of output order (left→right) and resolution selection per monitor using xrandr. - Verifies applied geometry; shows a Tk red-border overlay to confirm the active monitor. - Saves chosen layout to JSON and can re-apply non-interactively on login/autostart. - Robust logging (Start-Transcript), strict error behavior, and explicit exit codes. NEW/REQUESTED IMPROVEMENTS - -ConfigPath <file> to override default config path (default: ~/.config/pwsh-monitor-layout.json). - -Help: Prints usage, options, examples, and exit codes then exits 0. - Emits a one-line machine-parseable JSON summary on success for both interactive and apply-saved paths. - Portability hardening: auto-install gated by distro/pm detection (apt-get/dnf/zypper/pacman). - -NoInstall: disable auto-install; fail fast if dependencies missing. - Headless handling: detect missing X11/xrandr availability and exit with explicit code. USAGE (examples) # First run (interactive): pwsh -File ./pwsh-monitor-setup.ps1 # Auto-install missing deps (root required): sudo pwsh -File ./pwsh-monitor-setup.ps1 -AutoInstall # Save config to a specific path: pwsh -File ./pwsh-monitor-setup.ps1 -ConfigPath "$HOME/.config/my-monitor-layout.json" # Non-interactive apply of saved layout (good for autostart): pwsh -File ./pwsh-monitor-setup.ps1 -ApplySavedLayout # Print help: pwsh -File ./pwsh-monitor-setup.ps1 -Help OPTIONS -DebugMode Verbose logging during execution. -AutoInstall Attempt to install dependencies via detected package manager. -NoInstall Do not install; fail fast if dependencies are missing. -ApplySavedLayout Apply saved layout from the config JSON and exit. -OverlaySeconds <int> Seconds to keep the red overlay visible when testing (default: 5). -TargetUser <string> Login user for ownership of autostart/config when run as root (default: SUDO_USER/USER/linaro). -ScriptInstallPath Path to place a self-copy used by autostart (default: $HOME/pwsh-monitor-setup.ps1). -ConfigPath Override config JSON path (default: $HOME/.config/pwsh-monitor-layout.json). -Help Show this help and exit. EXIT CODES 0 Success 1 General error / user abort / final apply failed 2 Missing dependency and -NoInstall provided (or auto-install disabled/unsupported) 20 Headless / no X11 session detected (no DISPLAY or xrandr unusable) 21 No connected monitors detected 22 Invalid/empty configuration when using -ApplySavedLayout 23 Config file not found for -ApplySavedLayout 24 JSON parse error for -ConfigPath 25 Permission or ownership errors during autostart/config setup MACHINE-PARSABLE SUMMARY On success, prints a single line JSON to stdout like: {"ok":true,"mode":"interactive","primary":"HDMI-1","order":["HDMI-1","DP-1"],"chosen":{"HDMI-1":"1920x1080","DP-1":"2560x1440"},"configFile":"/home/user/.config/pwsh-monitor-layout.json"} #> [CmdletBinding()] param( [switch] $DebugMode, [switch] $AutoInstall, [switch] $NoInstall, [switch] $ApplySavedLayout, [switch] $Help, [int] $OverlaySeconds = 5, [string] $TargetUser = ($env:SUDO_USER ?? $env:USER ?? 'linaro'), [string] $ScriptInstallPath = "$HOME/pwsh-monitor-setup.ps1", [string] $ConfigPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ($DebugMode) { $VerbosePreference = 'Continue' } function Stop-TranscriptSafe { try { Stop-Transcript | Out-Null } catch {} } # Transcript $tsFile = "/tmp/pwsh-monitor-setup-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" try { Start-Transcript -Path $tsFile -Force | Out-Null } catch { $tsFile = Join-Path $HOME "pwsh-monitor-setup-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" try { Start-Transcript -Path $tsFile -Force | Out-Null } catch {} } Write-Verbose "Transcript: $tsFile" # ---------- Helpers ---------- function Show-Help { $path = $PSCommandPath if (-not $path) { $path = $MyInvocation.MyCommand.Path } $content = Get-Content -Raw -Path $path $helpBlock = [regex]::Match($content, '(?s)<#\s*(.*?)\s*#>').Groups[1].Value if ([string]::IsNullOrWhiteSpace($helpBlock)) { Write-Output "Usage: pwsh -File $path [-AutoInstall|-NoInstall] [-ApplySavedLayout] [-ConfigPath <file>] [-OverlaySeconds <int>] [-DebugMode] [-Help]" } else { Write-Output $helpBlock.Trim() } } function Test-Cmd([string]$Name) { $null -ne (Get-Command -Name $Name -ErrorAction SilentlyContinue) } function Test-IsRoot { try { ((& id '-u' 2>$null) -eq 0) } catch { try { ((& whoami) -eq 'root') } catch { ($env:USER -eq 'root') } } } function Detect-PackageManager { if (Test-Cmd 'apt-get') { return 'apt' } if (Test-Cmd 'dnf') { return 'dnf' } if (Test-Cmd 'zypper') { return 'zypper' } if (Test-Cmd 'pacman') { return 'pacman' } return $null } function Install-Dep([string]$pkg) { $pm = Detect-PackageManager if (-not $pm) { throw 'No supported package manager found.' } if (-not (Test-IsRoot)) { throw 'Auto-install requires root.' } switch ($pm) { 'apt' { & apt-get update -y; & apt-get install -y $pkg } 'dnf' { & dnf install -y $pkg } 'zypper' { & zypper -n install -y $pkg } 'pacman' { & pacman -Sy --noconfirm $pkg } } } function Ensure-Dep([string]$cmd, [string]$pkg, [string]$friendly) { if (Test-Cmd $cmd) { return } if ($NoInstall) { throw "Missing dependency '$friendly' and -NoInstall was specified." } if (-not $AutoInstall) { throw "Missing dependency '$friendly'. Rerun with -AutoInstall or install '$pkg' manually." } Install-Dep $pkg if (-not (Test-Cmd $cmd)) { throw "Dependency '$friendly' not available after install." } } function Test-PythonTk { @' import sys try: import tkinter as t root = t.Tk() root.withdraw() print("OK") except Exception as e: print("ERR", e) sys.exit(1) '@ | Set-Content -Path ($tmp = [IO.Path]::GetTempFileName() + '.py') -Encoding UTF8 try { $p = Start-Process -FilePath 'python3' -ArgumentList @($tmp) -PassThru -NoNewWindow -Wait return ($p.ExitCode -eq 0) } finally { try { Remove-Item $tmp -Force -ErrorAction SilentlyContinue } catch {} } } function Assert-XSessionOrExit { if (-not $env:DISPLAY) { $env:DISPLAY = ':0' } for ($i=0; $i -lt 6; $i++) { try { & xrandr --current | Out-Null; return } catch { Start-Sleep -Milliseconds 300 } } Write-Error 'No X11 display/xrandr available (headless or X not running).' Stop-TranscriptSafe; exit 20 } # ---------- Dependency checks ---------- try { Ensure-Dep 'xrandr' 'x11-xserver-utils' 'xrandr' Ensure-Dep 'python3' 'python3' 'python3' } catch { Write-Error $_.Exception.Message Stop-TranscriptSafe; exit 2 } # ---------- X11 availability ---------- Assert-XSessionOrExit # tkinter (package name differs by distro) if (-not (Test-PythonTk)) { try { $pm = Detect-PackageManager switch ($pm) { 'apt' { Install-Dep 'python3-tk' } 'dnf' { Install-Dep 'python3-tkinter' } 'zypper' { Install-Dep 'python3-tk' } 'pacman' { Install-Dep 'tk' } default { throw 'Unsupported package manager for tkinter.' } } } catch { Write-Error "Unable to install/verify tkinter: $($_.Exception.Message)" Stop-TranscriptSafe; exit 2 } if (-not (Test-PythonTk)) { Write-Error 'python3-tk/tkinter not available after installation.' Stop-TranscriptSafe; exit 2 } } # ---------- X11 env + authority ---------- $root = Test-IsRoot $homeTarget = if ($root) { "/home/$TargetUser" } else { $HOME } if (-not $env:DISPLAY) { $env:DISPLAY = ':0' } if ($root) { $xaUser = "/home/$TargetUser/.Xauthority" if (Test-Path $xaUser) { $env:XAUTHORITY = $xaUser } elseif (Test-Path '/root/.Xauthority') { $env:XAUTHORITY = '/root/.Xauthority' } } elseif (-not $env:XAUTHORITY) { $env:XAUTHORITY = Join-Path $HOME '.Xauthority' } # Config path default if (-not $ConfigPath -or [string]::IsNullOrWhiteSpace($ConfigPath)) { $ConfigPath = Join-Path (Join-Path $homeTarget '.config') 'pwsh-monitor-layout.json' } # ---------- Early exits ---------- if ($Help) { Show-Help; Stop-TranscriptSafe; exit 0 } # ---------- xrandr helpers ---------- function Get-XrandrLines { (& xrandr --query | Out-String) -split "`n" } function Get-ConnectedOutputs { Get-XrandrLines | ForEach-Object { if ($_ -match '^\s*(\S+)\s+connected') { $Matches[1] } } } function Sort-OutputsPreferred([string[]]$Outputs) { $order = @('HDMI','DP','DVI','VGA','eDP') $picked = [System.Collections.Generic.List[string]]::new() foreach ($p in $order) { $Outputs | Where-Object { $_ -like "$p*" } | ForEach-Object { [void]$picked.Add($_) } } $Outputs | Where-Object { $picked -notcontains $_ } | ForEach-Object { [void]$picked.Add($_) } $picked | Select-Object -Unique } function Get-OutputModes([string]$Output) { $lines = Get-XrandrLines $in = $false $modes = [System.Collections.Generic.List[string]]::new() foreach ($ln in $lines) { if (-not $in) { if ($ln -match "^\s*$([Regex]::Escape($Output))\s+connected") { $in = $true; continue } } else { if ($ln -match '^\S+\s+(connected|disconnected)') { break } if ($ln -match '^\s+([0-9]{3,5}x[0-9]{3,5}\S*)') { $t = $Matches[1] if (-not $modes.Contains($t)) { [void]$modes.Add($t) } } } } if ($modes.Count -eq 0) { throw "No modes parsed for $Output" } return $modes.ToArray() } function Get-Size([string]$Token) { if ($Token -notmatch '^(\d{3,5})x(\d{3,5})') { throw "Invalid mode token: $Token" } [pscustomobject]@{ W = [int]$Matches[1]; H = [int]$Matches[2] } } function Get-CurrentGeometry([string]$Output) { $pattern = "^{0}\b" -f [Regex]::Escape($Output) $ln = Get-XrandrLines | Where-Object { $_ -match $pattern } | Select-Object -First 1 if (-not $ln) { return $null } $m = [regex]::Match($ln, '(\d{3,5}x\d{3,5})\+(\d+)\+(\d+)') if (-not $m.Success) { return $null } $wxh = $m.Groups[1].Value $x = [int]$m.Groups[2].Value $y = [int]$m.Groups[3].Value $m2 = [regex]::Match($wxh, '^(\d{3,5})x(\d{3,5})$') if (-not $m2.Success) { return $null } [pscustomobject]@{ W = [int]$m2.Groups[1].Value; H = [int]$m2.Groups[2].Value; X = $x; Y = $y } } function Invoke-Xrandr([string[]]$XRArgs) { $p = Start-Process -FilePath 'xrandr' -ArgumentList $XRArgs -PassThru -NoNewWindow -Wait if ($DebugMode) { Write-Verbose ("xrandr " + ($XRArgs -join ' ') + " => " + $p.ExitCode) } $p.ExitCode } function Apply-Layout([System.Collections.Specialized.OrderedDictionary]$Map) { $x = 0 foreach ($o in $Map.Keys) { $tok = [string]$Map[$o] $code = Invoke-Xrandr @('--output', $o, '--mode', $tok, '--pos', ("{0}x0" -f $x), '--rotate', 'normal') if ($code -ne 0) { Write-Warning ("xrandr failed for {0} token {1} (exit {2})" -f $o, $tok, $code) return $false } Start-Sleep -Milliseconds 120 $sz = Get-Size $tok $x += $sz.W } Start-Sleep -Milliseconds 180 return $true } function Verify-OutputGeometry([string]$Output, [string]$Token) { $want = Get-Size $Token $cur = Get-CurrentGeometry $Output if (-not $cur) { if ($DebugMode) { Write-Verbose "Verify: no geometry for $Output" } return $false } $ok = ($cur.W -eq $want.W -and $cur.H -eq $want.H) if ($DebugMode) { Write-Verbose ("Verify: {0} current={1}x{2} vs want={3}x{4} => {5}" -f $Output, $cur.W, $cur.H, $want.W, $want.H, $ok) } $ok } function Show-OverlayTk([int]$W,[int]$H,[int]$X,[int]$Y,[int]$Seconds) { $py = @' import sys, tkinter as t w, h, x, y, sec = map(int, sys.argv[1:6]) root = t.Tk() root.overrideredirect(1) root.attributes("-topmost", True) root.geometry(f"{w}x{h}+{x}+{y}") t.Frame(root, width=w, height=h, highlightbackground="red", highlightthickness=8).pack() root.after(sec*1000, root.destroy) root.mainloop() '@ $tmp = [IO.Path]::GetTempFileName() + '.py' [IO.File]::WriteAllText($tmp, $py) $p = Start-Process -FilePath 'python3' -ArgumentList @($tmp, $W, $H, $X, $Y, $Seconds) -PassThru -NoNewWindow $timeout = [Math]::Max($Seconds + 3, 8) $exited = $true try { Wait-Process -Id $p.Id -Timeout $timeout } catch { $exited = $false } if (-not $exited) { try { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } catch {} Write-Warning 'Overlay timeout; killed.' } try { Remove-Item $tmp -Force -ErrorAction SilentlyContinue } catch {} } function Prompt-YNQ([string]$Msg) { while ($true) { $r = Read-Host $Msg switch -Regex ($r) { '^(?i)y$' { return 'y' } '^(?i)n$' { return 'n' } '^(?i)q$' { return 'q' } default { Write-Host 'Please type y, n, or q.' -ForegroundColor Yellow } } } } # ---------- Apply saved layout fast-path ---------- if ($ApplySavedLayout) { if (-not (Test-Path $ConfigPath)) { Write-Warning "Config not found: $ConfigPath" Stop-TranscriptSafe; exit 23 } try { $raw = Get-Content -Raw -Path $ConfigPath $obj = $raw | ConvertFrom-Json } catch { Write-Error "Failed to parse config JSON: $($_.Exception.Message)" Stop-TranscriptSafe; exit 24 } if (-not $obj) { Write-Error 'Empty/invalid configuration object.'; Stop-TranscriptSafe; exit 22 } $orderedChosen = [System.Collections.Specialized.OrderedDictionary]::new() foreach ($p in $obj.PSObject.Properties) { $orderedChosen[$p.Name] = [string]$p.Value } if ($orderedChosen.Count -eq 0) { Write-Error 'No entries in configuration.'; Stop-TranscriptSafe; exit 22 } if (-not (Apply-Layout $orderedChosen)) { Write-Warning 'Applying saved layout reported errors.' Stop-TranscriptSafe; exit 1 } $primary = $null; $maxA = -1 foreach ($o in $orderedChosen.Keys) { $sz = Get-Size ([string]$orderedChosen[$o]) $a = $sz.W * $sz.H if ($a -gt $maxA) { $primary = $o; $maxA = $a } } $null = Invoke-Xrandr @('--output', $primary, '--primary') $summary = [pscustomobject]@{ ok = $true mode = 'apply-saved' primary = $primary order = @($orderedChosen.Keys) chosen = $orderedChosen configFile = $ConfigPath } | ConvertTo-Json -Compress Write-Output $summary Stop-TranscriptSafe; exit 0 } # ---------- Probe outputs ---------- $outs = Get-ConnectedOutputs if (-not $outs -or $outs.Count -eq 0) { Write-Error 'No connected monitors detected.'; Stop-TranscriptSafe; exit 21 } # ---------- Interactive ordering ---------- $orderedOuts = [System.Collections.Generic.List[string]]::new() if ($outs.Count -gt 1) { $remaining = [System.Collections.Generic.List[string]]::new() $remaining.AddRange([string[]]$outs) Write-Host "Multiple monitors detected: $($remaining -join ', ')" -ForegroundColor Cyan while ($remaining.Count -gt 0) { if ($orderedOuts.Count -eq 0) { Write-Host "Which monitor should be on the far left?" } else { Write-Host "Which monitor should be to the right of $($orderedOuts[-1])?" } for ($i=0; $i -lt $remaining.Count; $i++) { Write-Host " [$($i+1)] $($remaining[$i])" } $ans = Read-Host "Enter number (1-$($remaining.Count))" if (($ans -match '^\d+$') -and ([int]$ans -ge 1) -and ([int]$ans -le $remaining.Count)) { $choiceIndex = [int]$ans - 1 $orderedOuts.Add($remaining[$choiceIndex]) $remaining.RemoveAt($choiceIndex) } else { Write-Warning "Invalid selection." } } } else { $orderedOuts.Add($outs[0]) } Write-Host "Final monitor order (left-to-right): $($orderedOuts -join ' -> ')" -ForegroundColor Green # ---------- Gather modes ---------- $All = @{} $First = @{} foreach ($o in $orderedOuts) { $m = Get-OutputModes $o $All[$o] = $m $First[$o] = $m[0] if ($DebugMode) { Write-Verbose ("Modes[{0}] (#{1}): {2}" -f $o, $m.Count, ($m -join ' ')) } } $Chosen = [System.Collections.Specialized.OrderedDictionary]::new() foreach ($o in $orderedOuts) { $Chosen[$o] = $null } # ---------- Per-output interactive selection ---------- foreach ($current in $orderedOuts) { Write-Host ("======== Configuring {0} ========" -f $current) -ForegroundColor Cyan $cands = @($All[$current]) $accepted = $false for ($i = 0; $i -lt $cands.Count; $i++) { $tok = $cands[$i] Write-Host ("---> [{0}/{1}] Trying {2} for {3}" -f ($i + 1), $cands.Count, $tok, $current) -ForegroundColor Green $proposal = [System.Collections.Specialized.OrderedDictionary]::new() foreach ($o in $orderedOuts) { if ($o -eq $current) { $proposal[$o] = $tok } else { $proposal[$o] = ($Chosen[$o] ?? $First[$o]) } } if (-not (Apply-Layout $proposal)) { Write-Warning ("Skip {0}: xrandr exit!=0" -f $tok); continue } if (-not (Verify-OutputGeometry $current $tok)) { Write-Warning ("Skip {0}: geometry mismatch" -f $tok); continue } $xoff = 0 foreach ($o in $orderedOuts) { if ($o -eq $current) { break } $sz = Get-Size ([string]$proposal[$o]) $xoff += $sz.W } $szC = Get-Size $tok Show-OverlayTk -W $szC.W -H $szC.H -X $xoff -Y 0 -Seconds $OverlaySeconds $ans = Prompt-YNQ ("Was the red rectangle fully enclosed on {0} at {1}? [y=accept / n=next / q=quit]" -f $current, $tok) if ($ans -eq 'y') { $Chosen[$current] = $tok; $accepted = $true; break } if ($ans -eq 'q') { Write-Warning 'User aborted.'; Stop-TranscriptSafe; exit 1 } } if (-not $accepted) { $fb = $First[$current] Write-Warning ("No accepted mode for {0}. Falling back to {1}" -f $current, $fb) $Chosen[$current] = $fb } } # ---------- Final apply ---------- Write-Host "Final configuration selected. Applying..." -ForegroundColor Cyan if (-not (Apply-Layout $Chosen)) { Write-Warning 'Final layout application reported errors.' Stop-TranscriptSafe; exit 1 } # choose primary as largest area $primary = $null; $maxA = -1 foreach ($o in $orderedOuts) { $sz = Get-Size ([string]$Chosen[$o]) $a = $sz.W * $sz.H if ($a -gt $maxA) { $primary = $o; $maxA = $a } } $null = Invoke-Xrandr @('--output', $primary, '--primary') # ---------- Save config + autostart ---------- try { $configDir = Split-Path -Parent $ConfigPath if (-not (Test-Path $configDir)) { if ($root) { & sudo -u $TargetUser mkdir -p -- $configDir } else { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } } $tmpChosen = [ordered]@{} foreach ($k in $Chosen.Keys) { $tmpChosen[$k] = [string]$Chosen[$k] } ($tmpChosen | ConvertTo-Json) | Set-Content -Path $ConfigPath -Encoding UTF8 if ($root) { & chown "${TargetUser}:${TargetUser}" $ConfigPath } Write-Host "[INFO] Configuration saved to $ConfigPath" -ForegroundColor DarkCyan $autoDir = Join-Path $homeTarget '.config/autostart' if (-not (Test-Path $autoDir)) { if ($root) { & sudo -u $TargetUser mkdir -p -- $autoDir } else { New-Item -ItemType Directory -Path $autoDir -Force | Out-Null } } if (-not (Test-Path $ScriptInstallPath)) { $src = $PSCommandPath; if (-not $src) { $src = $MyInvocation.MyCommand.Path } Copy-Item -Path $src -Destination $ScriptInstallPath -Force if ($root) { & chown "${TargetUser}:${TargetUser}" $ScriptInstallPath } & chmod +x $ScriptInstallPath } $desktopFile = Join-Path $autoDir 'pwsh-monitor-setup.desktop' $execLine = "pwsh -File $ScriptInstallPath -ApplySavedLayout -ConfigPath `"$ConfigPath`"" @" [Desktop Entry] Type=Application Exec=$execLine Hidden=false NoDisplay=false X-GNOME-Autostart-enabled=true Name=PwshAutoMonitor Comment=Autoconfigure monitors layout (pwsh) "@ | Set-Content -Path $desktopFile -Encoding UTF8 -NoNewline if ($root) { & chown "${TargetUser}:${TargetUser}" $desktopFile } & chmod 0644 $desktopFile Write-Host ("[INFO] Autostart installed at {0} for user {1}" -f $desktopFile, $TargetUser) -ForegroundColor DarkCyan } catch { Write-Warning ("Autostart/config setup issue: {0}" -f $_.Exception.Message) } # ---------- Success summary (single-line JSON) ---------- $summary2 = [pscustomobject]@{ ok = $true mode = 'interactive' primary = $primary order = @($orderedOuts) chosen = $Chosen configFile = $ConfigPath } | ConvertTo-Json -Compress Write-Output $summary2 Stop-TranscriptSafe exit 0
URL: https://ib.bsb.br/autoxrandr