├── .gitignore ├── monitor-fallback.timer ├── monitor-fallback.service ├── LICENSE ├── README.md ├── monitor-fallback.bash └── dpa /.gitignore: -------------------------------------------------------------------------------- 1 | .trunk/ 2 | dpa.backup 3 | dpa.new 4 | output_kde.txt 5 | test.bash 6 | what.txt 7 | -------------------------------------------------------------------------------- /monitor-fallback.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Dummy Plug monitor fallback 3 | Requires=monitor-fallback.service 4 | 5 | [Timer] 6 | Unit=monitor-fallback.service 7 | OnUnitActiveSec=60s 8 | 9 | [Install] 10 | WantedBy=timers.target 11 | -------------------------------------------------------------------------------- /monitor-fallback.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Dummy Plug monitor fallback 3 | Requires=monitor-fallback.service 4 | 5 | [Service] 6 | Type=simple 7 | ExecStart=%h/code/xenhat/dummy-plug-automation/monitor-fallback.bash 8 | 9 | 10 | [Install] 11 | WantedBy=graphical-session.target 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 XenHat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dummy Plug helper for Linux 2 | 3 | Automation for remote streaming using a dummy plug on Linux, supporting both Xorg/X11 and Wayland. 4 | 5 | ## Supported Desktop Environments and Window Managers 6 | 7 | - [X] KDE5/6 8 | - [ ] GNOME 48+ *re-write needed* 9 | - [ ] Hyprland *re-write needed* 10 | - [ ] bspwm *re-write needed* 11 | - [X] Niri 12 | - [X] Best effort using xrandr and wlrandr for others 13 | - [ ] COSMIC (Planned) 14 | 15 | ## Usage 16 | 17 | usage: `dpa [do|undo]` 18 | 19 | Example usage using a global prep command in Sunshine: 20 | 21 | ```conf 22 | global_prep_cmd = [{"do":"sh -c \"~/code/xenhat/dummy-plug-automation/dpa do\"","undo":"sh -c \"~/code/xenhat/dummy-plug-automation/dpa undo\""}] 23 | ``` 24 | 25 | ## Notes 26 | 27 | This utility currently assumes that your main monitor is connected through Display Port (as DP-1), and your dummy plug/stream display is connected through HDMI (as HDMI-A-1). 28 | 29 | There are some experimental environment variables to configure this script at the moment: 30 | ```bash 31 | # ~/.profile 32 | DEFAULT_SEAT_DISPLAY=DP-1 # the "Main" monitor to use for use when at the computer 33 | DEFAULT_STREAM_DISPLAY=HDMI-A-1 # the "Stream" monitor to use for streaming, i.e. a Dummy Plug 34 | DEFAULT_RESOLUTION=2560x1440 35 | DEFAULT_REFRESH_RATE=240 # The refresh rate to attempt to set when quitting the stream session 36 | DEFAULT_VRR_MODE=automatic # VRR Mode, A.K.A Freesync/GSync 37 | ``` 38 | -------------------------------------------------------------------------------- /monitor-fallback.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | while sleep 1; do 3 | if pgrep -x Hyprland >/dev/null 2>&1; then 4 | # shellcheck disable=SC2312 5 | if ! hyprctl monitors 2>&1 | grep -P '^(Monitor\s.*|\s+disabled.*)$' | grep 'disabled: false' -q; then 6 | # Forcefully enabling dummy plug 7 | echo "Enabling fallback monitor!" 8 | hyprctl keyword monitor HDMI-A-1,enabled 9 | elif hyprctl monitors | grep -A0 -B0 "Monitor HDMI-A-1" -q && hyprctl monitors 2>&1 | grep -P '^(Monitor\s.*|\s+disabled: false.*)$' | grep -A0 Monitor --no-group-separator | grep -v 'HDMI-A-1' -q; then 10 | echo "Disabling Dummy Plug" 11 | hyprctl keyword monitor HDMI-A-1,disabled 12 | fi 13 | continue 14 | fi 15 | get_session_type() { 16 | # Get systemd session type 17 | # FIXME: hyprland doesn't show as "active", but as a seat on tty3 18 | # user_sessions=$(loginctl list_sessions | grep "$USER") 19 | # shellcheck disable=SC2312 20 | _session_type=$(loginctl 2>/dev/null show-session "$(awk '/tty/ {print $1}' <(loginctl list-sessions | grep "${USER}" | grep "active" -w))" -p Type | awk -F= '{print $2}') 21 | 22 | if [[ -n ${_session_type} ]]; then 23 | echo "${_session_type}" 24 | else 25 | environment=$(env) 26 | if grep -qc "WAYLAND_DISPLAY" <<<"${environment}"; then 27 | echo "wayland" 28 | elif grep -qc "DISPLAY" <<<"${environment}"; then 29 | echo "x11" 30 | fi 31 | fi 32 | echo "none" 33 | } 34 | # if [[ "$(loginctl 2>/dev/null show-session "$(awk '/tty/ {print $1}' <(loginctl list-sessions | grep "$USER" | grep "active" -w))" -p Type | awk -F= '{print $2}')" == "wayland" ]]; then 35 | # shellcheck disable=SC2312 36 | if [[ "$(get_session_type)" == "wayland" ]]; then 37 | export WAYLAND_DISPLAY=wayland-1 38 | if [[ "$(wlr-randr | grep 'Enabled: yes' -c --no-group-separator)" -lt 1 ]]; then 39 | if [[ ${XDG_SESSION_DESKTOP} == "Hyprland" ]]; then 40 | hyprctl keyword monitor HDMI-A-1,enabled 41 | else 42 | wlr-randr --output HDMI-A-1 --on 43 | fi 44 | elif wlr-randr | grep -B5 'Enabled: yes' --no-group-separator | grep -v '^\s' | grep -v HDMI-A-1 -q; then 45 | if [[ ${XDG_SESSION_DESKTOP} == "Hyprland" ]]; then 46 | hyprctl keyword monitor HDMI-A-1,disabled 47 | else 48 | wlr-randr --output HDMI-A-1 --off 49 | fi 50 | fi 51 | fi 52 | done 53 | -------------------------------------------------------------------------------- /dpa: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # TODOs and FIXMEs { 3 | #TODO: Properly label every step, I'm getting lost 4 | #TODO: re-implement COSMIC 5 | #TODO: re-implement hyprland 6 | # TODO: re-implement automatic display switching, make it optional first 7 | #FIXME: Make sure dummy plug is enabled on connect. Seems to fail in Hyprland 8 | #TODO: test X11 for awesomewm, dwm, etc 9 | #TODO: NVIDIA and Intel gpus support 10 | #TODO: Fetch preferred mode from EDID 11 | #TODO: Multiple monitors support for saving and resuming modes 12 | #TODO: Associate settings to serial number 13 | #TODO: Add niri support using `niri msg output ` 14 | #FIXME: Non-predictable connector name. 15 | #FIXME: Resolution change is fragile on Hyprland 16 | #TODO: Save the current VRR mode if it exists to a file to restore it later 17 | #TODO: Organize refactor to do as little work as possible; 18 | #TODO: Find and save available modes as soon as possible 19 | #TODO: Standardize behavior between desktop environments WRT seat display being present 20 | #TODO: rework code to not assume the dummy plug is HDMI. There are some DisplayPort ones. 21 | #FIXME: Automatic resolution picking is currently broken. Will apply as is. 22 | # 23 | # } 24 | _enable_auto_res_fallback=false 25 | # Logging { 26 | # Log facility from 27 | # https://blog.brujordet.no/post/bash/debugging_bash_like_a_sire/ 28 | LOG_LEVEL=${LOG_LEVEL:-1} 29 | function log::_write_log { 30 | local timestamp file function_name log_level 31 | log_level=$1 32 | shift 33 | 34 | if log::level_is_active "${log_level}"; then 35 | timestamp=$(date +'%y.%m.%d %H:%M:%S') 36 | file="${BASH_SOURCE[2]##*/}" 37 | function_name="${FUNCNAME[2]}" 38 | logger "dpa::$(printf '%s [%s] [%s - %s]: %s\n' \ 39 | "${log_level}" "${timestamp}" "${file}" "${function_name}" "${*}")" 40 | 41 | printf >&2 '%s [%s] [%s - %s]: %s\n' \ 42 | "${log_level}" "${timestamp}" "${file}" "${function_name}" "${*}" 43 | # ;; 44 | fi 45 | } 46 | function log::info { 47 | log::_write_log "INFO" "$@" 48 | } 49 | function log::warning { 50 | log::_write_log "WARN" "$@" 51 | } 52 | function log::level_is_active { 53 | local check_level current_level 54 | check_level=$1 55 | 56 | declare -A log_levels=( 57 | [DEBUG]=1 58 | [INFO]=2 59 | [WARN]=3 60 | [ERROR]=4 61 | ) 62 | 63 | check_level="${log_levels["${check_level}"]}" 64 | current_level="${log_levels["${LOG_LEVEL}"]}" 65 | 66 | ((check_level >= current_level)) 67 | } 68 | function log::error { 69 | log::_write_log "ERROR" "$@" 70 | local stack_offset=1 71 | printf '%s:\n' 'Stacktrace:' >&2 72 | 73 | for stack_id in "${!FUNCNAME[@]}"; do 74 | if [[ ${stack_offset} -le ${stack_id} ]]; then 75 | local source_file="${BASH_SOURCE[${stack_id}]}" 76 | local function="${FUNCNAME[${stack_id}]}" 77 | local line="${BASH_LINENO[$((stack_id - 1))]}" 78 | printf >&2 '\t%s:%s:%s\n' "${source_file}" "${function}" "${line}" 79 | fi 80 | done 81 | } 82 | # } 83 | # Trap errors immediately { 84 | trap 'log::error "[TRAP] An error has occurred"' ERR 85 | # } 86 | # Dependencies { 87 | if _enable_auto_res_fallback && ! command -v agrep >/dev/null; then 88 | message="agrep not found, cannot continue" 89 | log::error "${message}" 90 | exit 2 91 | fi 92 | # } 93 | # Global Variables { 94 | is_there_other_active_displays=false 95 | _seat_resolution=${DEFAULT_RESOLUTION:-2560x1440} 96 | _seat_refresh_rate=${DEFAULT_REFRESH_RATE:-60} 97 | _default_vrr_mode=${DEFAULT_VRR_MODE:-automatic} 98 | _seat_display=${DEFAULT_SEAT_DISPLAY:=DP-1} 99 | _stream_display=${DEFAULT_STREAM_DISPLAY:=HDMI-A-1} 100 | _client_height=${SUNSHINE_CLIENT_HEIGHT:-720} 101 | _client_width=${SUNSHINE_CLIENT_WIDTH:-1280} 102 | _client_refresh_rate=${SUNSHINE_CLIENT_FPS:-60} 103 | _client_best_size="" 104 | # } 105 | # Common functions { 106 | get_systemd_session_type() { 107 | # Get systemd session type 108 | #FIXME: hyprland doesn't show as "active", but as a seat on tty3 109 | # user_sessions=$(loginctl list_sessions | grep "$USER") 110 | if [[ -n $XDG_SESSION_TYPE ]]; then 111 | _session_type="$XDG_SESSION_TYPE" 112 | else 113 | # shellcheck disable=SC2312 114 | _session_type=$( 115 | loginctl 2>/dev/null show-session \ 116 | "$(awk '/tty/ {print $1}' <(loginctl list-sessions | 117 | grep "$USER" | 118 | grep -v manager))" -p Type | awk -F= '{print $2}' 119 | ) 120 | fi 121 | 122 | if [[ -n $_session_type ]]; then 123 | echo "$_session_type" 124 | exit 0 125 | else 126 | environment=$(env) 127 | if grep -qc "WAYLAND_DISPLAY" <<<"$environment"; then 128 | echo "wayland" 129 | exit 0 130 | elif grep -qc "DISPLAY" <<<"$environment"; then 131 | echo "x11" 132 | exit 0 133 | fi 134 | fi 135 | echo "none" 136 | } 137 | get_connectors_list() { 138 | for edid in /sys/class/drm/card*-*-*/; do 139 | case ${edid} in *-Writeback-*) continue ;; esac 140 | basename "${edid}" | sed 's/card[0-9]*\-//' 141 | done 142 | } 143 | get_enabled_connectors_list() { 144 | for edid in /sys/class/drm/card*-*-*/; do 145 | case ${edid} in *-Writeback-*) continue ;; esac 146 | if [[ "$(< "$edid/enabled")" == "disabled" ]]; then continue; fi 147 | basename "${edid}" | sed 's/card[0-9]*\-//' 148 | done 149 | } 150 | function detect_displays() { 151 | mapfile -t enabled_connectors < <(get_enabled_connectors_list) 152 | log::warning "Enabled Connectors: ${#enabled_connectors[@]} (${enabled_connectors[*]})" 153 | if grep "$_seat_display" <<< "${enabled_connectors[*]}"; then 154 | log::warning "Seat display '$_seat_display' found" 155 | else 156 | log::warning "Seat display'$_seat_display' NOT found" 157 | fi 158 | if grep "$_stream_display" <<< "${enabled_connectors[*]}"; then 159 | log::warning "Stream display '$_stream_display' found" 160 | else 161 | log::warning "Stream display '$_stream_display' NOT found" 162 | fi 163 | if [[ ${#enabled_connectors[@]} -lt 2 ]]; then 164 | log::warning "Only one display found" 165 | _stream_display=${enabled_connectors[0]} 166 | _seat_display=${enabled_connectors[0]} 167 | log::warning "New stream display: $_stream_display" 168 | fi 169 | } 170 | # } 171 | # KDE Desktop { 172 | KDE::get_display_configuration() { 173 | log::info "unimplemented" 174 | } 175 | function KDE::get_closest_mode() { 176 | #TODO: Get supported modes via kscreen-doctor instead as it lists and allows more 177 | get_closest_mode 178 | } 179 | function KDE::do() { 180 | log::info "Finish Me" 181 | if [[ "$_stream_display" == "$_seat_display" ]]; then 182 | adjusted_hz=$(printf '%.0f' "$_client_refresh_rate") 183 | log::info "Attempting kscreen-doctor output.${_stream_display}.mode.${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT}@${adjusted_hz}" 184 | kscreen-doctor output."${_stream_display}".mode."${SUNSHINE_CLIENT_WIDTH}"x"${SUNSHINE_CLIENT_HEIGHT}"@"${adjusted_hz}" 185 | fi 186 | } 187 | function KDE::undo() { 188 | log::info "Finish Me" 189 | for connector in $(get_connectors_list); do 190 | if [[ ${connector} == "${_stream_display}" ]] && [[ "${_stream_display}" != "${_seat_display}" ]]; then 191 | log::info "Skipping over ${connector}" 192 | continue 193 | fi 194 | if [[ ${connector} == "${_seat_display}" ]]; then 195 | log::info "Handling Seat display ${connector}" 196 | kscreen-doctor output."${connector}".mode."${_seat_resolution}"@"${_seat_refresh_rate}" 197 | kscreen-doctor output."${connector}.vrrpolicy.${_default_vrr_mode}" 198 | fi 199 | log::info "Enabling ${connector}" 200 | kscreen-doctor output."${connector}".enable 201 | done 202 | # Disable stream output last 203 | if [[ ${_stream_display} != "${_seat_display}" ]]; then 204 | log::info "Disabling ${_stream_display}" 205 | kscreen-doctor output."${_stream_display}".disable 206 | fi 207 | } 208 | # } 209 | # GNOME Desktop { 210 | function GNOME::get_display_configuration() { 211 | _stream_display=$(gdctl show | 212 | grep 'Primary: yes' -A2 | 213 | tr -s ' ' | 214 | tr -d '└' | 215 | tr -d '─' | 216 | tail -n1 | 217 | cut -d ' ' -f 2) 218 | } 219 | # Format: @ 220 | function GNOME::set() { 221 | log::info "Setting '$1' to '$2'" 222 | gdctl set --logical-monitor --primary --monitor "$1" --mode "$2" 223 | } 224 | function GNOME::get_closest_mode() { 225 | get_closest_mode 226 | } 227 | function GNOME::do() { 228 | for connector in $(get_enabled_connectors_list); do 229 | connector=${connector/HDMI-A-/HDMI-} 230 | log::info "connector: '$connector'" 231 | if [[ "$connector" == "$_stream_display" ]]; then 232 | result=$(get_closest_mode "$connector" "${_client_width}x${_client_height}" "${_client_refresh_rate}") 233 | adjusted_hz=$(printf '%.3f' "$_client_refresh_rate") 234 | log::info "Adjusting refresh rate from '$_client_refresh_rate' to '$adjusted_hz'" 235 | GNOME::set "$connector" "${_client_best_size}@${adjusted_hz}" 236 | #TODO: Properly disable monitors 237 | # else 238 | # log::info "Disabling $connector" 239 | # gdctl --logical-monitor --monitor "$1" --mode "$2" 240 | else 241 | log::warning "Uh..... What to do with '$connector'?" 242 | fi 243 | done 244 | } 245 | function GNOME::undo() { 246 | log::info "Finish Me" 247 | for connector in $(get_connectors_list); do 248 | log::info "Handling connector '$connector'" 249 | connector=${connector/HDMI-A-/HDMI-} 250 | if [[ "$connector" == "$_seat_display" ]]; then 251 | result=$(get_closest_mode "$connector" "${_seat_resolution}" "${_seat_refresh_rate}") 252 | adjusted_hz=$(printf '%.3f' "$_seat_refresh_rate") 253 | log::info "Adjusting refresh rate from '$_seat_refresh_rate' to '$adjusted_hz'" 254 | log::info "Setting '$connector' to '${_seat_resolution}@${adjusted_hz}'" 255 | GNOME::set "$connector" "${_seat_resolution}@${adjusted_hz}" 256 | else 257 | log::warning "Finish implementing this display: '$connector'" 258 | #FIXME: Restore multi-monitor setup 259 | #FIXME: requires upcoming generic get_closest_mode 260 | # GNOME::set $(get_generic_mode "$DEFAULT_RESOLUTION" "$DEFAULT_REFRESH_RATE") 261 | # 262 | # if [[ "$DEFAULT_VRR_MODE" == "automatic" ]] || [[ "$DEFAULT_VRR_MODE" == "enabled" ]]; then 263 | 264 | # GNOME::set "$connector" "${DEFAULT_RESOLUTION}@${DEFAULT_REFRESH_RATE}+vrr" 265 | # else 266 | #adjusted_hz=$(printf '%.3f' "${DEFAULT_REFRESH_RATE}") 267 | # GNOME::set "$connector" "2560x1440@59.951" 268 | # fi 269 | fi 270 | done 271 | GNOME::set "$" 272 | } 273 | # } 274 | # Niri Window Manager { 275 | function niri::get_display_configuration() { 276 | _stream_resolution=${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT} 277 | _stream_refresh_rate=${SUNSHINE_CLIENT_FPS} 278 | log::info "Stream resolution: $_stream_resolution" 279 | log::info "Stream refresh rate: $_stream_refresh_rate" 280 | } 281 | function niri::do() { 282 | 283 | log::info "Performing niri logic" 284 | log::info "Setting ${_stream_display} to enabled" 285 | niri msg output "${_stream_display}" on 286 | sleep 1 287 | 288 | log::info "Setting ${_stream_display} mode to ${_stream_resolution}" 289 | log::warning "This tends to crash a lot, especially when some variables are undefined." 290 | log::warning "If this happens, try restarting your session." 291 | niri msg output "${_stream_display}" mode "${_stream_resolution}" 292 | } 293 | function niri::undo() { 294 | niri msg output "${_seat_display}" on 295 | sleep 1 296 | niri msg output "${_seat_display}" mode "${DEFAULT_RESOLUTION}" 297 | } 298 | # } 299 | # Main logic { 300 | mainloop() { 301 | log::warning "Program Start" 302 | log::warning "Automatic resolution picking is currently removed." 303 | log::warning "Stream settings will be applied as-is." 304 | # is_dummy_enabled=false 305 | 306 | # Print the startup state { 307 | log::info "Global variables at start: 308 | _seat_resolution=$_seat_resolution 309 | _seat_refresh_rate=$_seat_refresh_rate 310 | _default_vrr_mode=$_default_vrr_mode 311 | _seat_display=$_seat_display 312 | _stream_display=$_stream_display 313 | _client_height=$_client_height 314 | _client_width=$_client_width 315 | _client_refresh_rate=$_client_refresh_rate 316 | _client_best_size=$_client_best_size 317 | " 318 | # } 319 | 320 | 321 | case "$XDG_CURRENT_DESKTOP" in 322 | "GNOME"|"KDE"|"niri") 323 | detect_displays 324 | "${XDG_CURRENT_DESKTOP}::get_display_configuration" 325 | "${XDG_CURRENT_DESKTOP}::$1" 326 | ;; 327 | *) 328 | log::info "Entering generic environment methods" 329 | get_display_configuration 330 | get_closest_mode 331 | set_display_mode "$1" 332 | ;; 333 | esac 334 | #FIXME: This will sometimes report 'Assert: PRIMARY:DUMMY == DP-1:DP-2' which 335 | # is wrong. Should be DP2:HDMI-A-1 336 | # log::info "Assert: PRIMARY:DUMMY == ${_seat_display}:${_stream_display}" 337 | #FIXME: This often reports the wrong connector i.e. DP-1 instead of DP-2 338 | log::info "Seat display: ${_seat_display}" 339 | log::info "Stream display: ${_stream_display}" 340 | if [[ ${_stream_display} == "" ]]; then 341 | log::info "Could not determine output to stream out!!!" 342 | exit 3 343 | fi 344 | 345 | # Set the preferred monitor for WINE's wayland driver 346 | WAYLANDDRV_PRIMARY_MONITOR=${_stream_display} 347 | export WAYLANDDRV_PRIMARY_MONITOR 348 | 349 | unset _preferred_monitor _session_type _primary _client_height _client_width _refresh 350 | log::warning "Program End" 351 | } 352 | mainloop "$@" 353 | # } 354 | # Modeline { 355 | # vi: foldmarker={,} foldmethod=marker foldlevel=0 tabstop=4 filetype=bash 356 | # } 357 | --------------------------------------------------------------------------------