This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / configs / tablet-screen.nix
8.8 kB 240 lines
1# Android tablet as a wireless external screen. 2# 3# A sway headless output is streamed to Moonlight by Sunshine: hardware H.264/ 4# HEVC/AV1 over UDP, i.e. the game streaming path, ~1 frame of latency on LAN. 5# The output exists but stays disabled until a client streams: Sunshine's 6# prep-cmd enables it at exactly the resolution/refresh rate the client asks 7# for (so pixels map 1:1) and disables it afterwards, which makes sway evacuate 8# its workspace back to the real screens. 9# 10# Touch as a touchpad: Moonlight's "Use the touchscreen as a trackpad" sends 11# relative mouse motion, which Sunshine injects through uinput, so dragging 12# anywhere on the tablet moves the (single, shared) cursor like a touchpad. 13# native_pen_touch is disabled below so absolute touch is never negotiated 14# instead. 15# 16# One-time setup per host: 17# sunshine --creds <user> <password> # or set them in the web UI 18# open https://<host>:47990, "PIN" page, enter the PIN Moonlight shows 19# 20# For the least judder, use 5 GHz, keep tablet and host on the same AP, and in 21# Moonlight pick HEVC with frame pacing on "Prefer lowest latency". Streaming 22# jitter is dominated by the tablet's radio, not by which host NIC is used. 23{ 24 config, 25 lib, 26 pkgs, 27 ... 28}: 29let 30 workspace = "tablet"; 31 32 tablet-screen = pkgs.writeShellApplication { 33 name = "tablet-screen"; 34 runtimeInputs = [ 35 pkgs.sway 36 pkgs.jq 37 ]; 38 text = '' 39 # wlroots names headless outputs HEADLESS-<n> in creation order and never 40 # reuses a name, so the name we get depends on what else created one 41 # first. Reserve one per session and remember which it is; the state lives 42 # in the runtime dir, so it disappears together with the sway session that 43 # owns the output. 44 state=''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/tablet-screen.output 45 46 # sway >=1.11 creates a per-pid IPC socket, and the systemd user manager 47 # has not necessarily imported SWAYSOCK yet when sunshine starts or runs a 48 # prep-cmd, so find the live socket ourselves. 49 if ! swaymsg -t get_version >/dev/null 2>&1; then 50 for sock in /run/user/"$(id -u)"/sway-ipc.*.sock; do 51 if SWAYSOCK=$sock swaymsg -t get_version >/dev/null 2>&1; then 52 export SWAYSOCK=$sock 53 break 54 fi 55 done 56 fi 57 58 names() { 59 swaymsg -r -t get_outputs | jq -c '[.[].name]' 60 } 61 62 exists() { 63 names | jq -e --arg o "$1" 'index($o)' >/dev/null 64 } 65 66 reserve() { 67 if [ -s "$state" ] && exists "$(cat "$state")"; then 68 return 0 69 fi 70 before=$(names) 71 swaymsg -- create_output >/dev/null 72 output=$(names | jq -r --argjson before "$before" '(. - $before)[0] // empty') 73 if [ -z "$output" ]; then 74 echo "tablet-screen: sway created no headless output" >&2 75 return 1 76 fi 77 printf '%s\n' "$output" > "$state" 78 # Keep the tablet on its own workspace so it never steals a numbered one. 79 swaymsg "workspace ${workspace} output $output" >/dev/null 80 swaymsg "output $output disable" >/dev/null 81 } 82 83 target() { 84 reserve 85 cat "$state" 86 } 87 88 case "''${1-}" in 89 reserve) 90 reserve 91 ;; 92 name) 93 target 94 ;; 95 on) 96 shift 97 # Sunshine exports the client's stream request; a resolution given on 98 # the command line wins so the screen can also be used by hand. 99 res=''${SUNSHINE_CLIENT_WIDTH:-1920}x''${SUNSHINE_CLIENT_HEIGHT:-1200} 100 fps=''${SUNSHINE_CLIENT_FPS:-60} 101 scale=1 102 while [ $# -gt 0 ]; do 103 case $1 in 104 --mode) 105 res=''${2%%@*} 106 [ "''${2#*@}" = "$2" ] || fps=''${2#*@} 107 shift 2 108 ;; 109 --scale) 110 scale=$2 111 shift 2 112 ;; 113 *) 114 echo "tablet-screen: unknown option $1" >&2 115 exit 1 116 ;; 117 esac 118 done 119 output=$(target) 120 # No position: sway appends an unpositioned output to the right of 121 # every other one, which keeps the existing screens (and the order 122 # sway-screen-switch counts in) exactly where they are. 123 swaymsg "output $output enable mode ''${res}@''${fps}Hz scale $scale" >/dev/null 124 ;; 125 off) 126 # Disable instead of unplug: sway moves the workspace back to a real 127 # screen, and the output keeps its name for the next stream. 128 if [ -s "$state" ] && exists "$(cat "$state")"; then 129 swaymsg "output $(cat "$state") disable" >/dev/null 130 fi 131 ;; 132 status) 133 if ! { [ -s "$state" ] && exists "$(cat "$state")"; }; then 134 echo "no virtual screen reserved" 135 exit 0 136 fi 137 swaymsg -r -t get_outputs | jq -r --arg o "$(cat "$state")" ' 138 .[] | select(.name == $o) 139 | if .active then 140 "\(.name) active \(.current_mode.width)x\(.current_mode.height)@\((.current_mode.refresh / 1000) | floor) scale=\(.scale) position=\(.rect.x),\(.rect.y)" 141 else 142 "\(.name) disabled" 143 end 144 ' 145 ;; 146 *) 147 echo "usage: tablet-screen reserve|name|on [--mode WxH[@FPS]] [--scale S]|off|status" >&2 148 exit 1 149 ;; 150 esac 151 ''; 152 }; 153 154 appsFile = (pkgs.formats.json { }).generate "sunshine-apps.json" { 155 env = { }; 156 apps = [ 157 { 158 name = "Tablet screen"; 159 prep-cmd = [ 160 { 161 do = "${lib.getExe tablet-screen} on"; 162 undo = "${lib.getExe tablet-screen} off"; 163 } 164 ]; 165 auto-detach = "true"; 166 } 167 { 168 # Same screen rendered at 1.5x, for reading size on a ~10" panel while 169 # still encoding (and displaying) native pixels. 170 name = "Tablet screen (HiDPI)"; 171 prep-cmd = [ 172 { 173 do = "${lib.getExe tablet-screen} on --scale 1.5"; 174 undo = "${lib.getExe tablet-screen} off"; 175 } 176 ]; 177 auto-detach = "true"; 178 } 179 ]; 180 }; 181 182 settingsFile = (pkgs.formats.keyValue { }).generate "sunshine.conf" { 183 sunshine_name = config.networking.hostName; 184 # wlr-screencopy, unlike the kms backend, can also capture an output that 185 # has no CRTC behind it - which is exactly what a headless output is. 186 capture = "wlr"; 187 # Make Moonlight emulate a mouse rather than negotiating absolute touch, 188 # so the touchscreen acts as a touchpad on the shared cursor. 189 native_pen_touch = "disabled"; 190 file_apps = "${appsFile}"; 191 }; 192 193 # output_name has to be known when sunshine starts, but the headless output's 194 # name is only decided when it is created, so reserve it here and pass the 195 # name as a command line override (any config key can be overridden that way). 196 # Running through the capability wrapper gives sunshine CAP_SYS_NICE for its 197 # EGL capture context. 198 sunshine-tablet-screen = pkgs.writeShellApplication { 199 name = "sunshine-tablet-screen"; 200 runtimeInputs = [ tablet-screen ]; 201 text = '' 202 output=$(tablet-screen name) 203 exec ${config.security.wrapperDir}/sunshine ${settingsFile} "output_name=$output" 204 ''; 205 }; 206in 207{ 208 environment.systemPackages = [ tablet-screen ]; 209 210 services.sunshine = { 211 enable = true; 212 openFirewall = true; 213 # Started with sway-session.target below instead, which sway only reaches 214 # after importing WAYLAND_DISPLAY/SWAYSOCK into the user manager. 215 autoStart = false; 216 # Not for KMS capture (unused): this is what makes the module build a 217 # capability wrapper, whose capability set is replaced below. 218 capSysAdmin = true; 219 }; 220 221 # CAP_SYS_NICE lets sunshine put its EGL capture context on a high priority 222 # GPU queue; CAP_SYS_ADMIN would only be needed for KMS capture. 223 security.wrappers.sunshine.capabilities = lib.mkForce "cap_sys_nice+p"; 224 225 systemd.user.services.sunshine = { 226 wantedBy = [ "sway-session.target" ]; 227 partOf = [ "sway-session.target" ]; 228 after = [ "sway-session.target" ]; 229 # Fails while sway is not up yet (no IPC socket to create the output on); 230 # the module's Restart=on-failure retries until the session exists. 231 serviceConfig.ExecStart = lib.mkForce (lib.getExe sunshine-tablet-screen); 232 }; 233 234 # Mouse/keyboard injection goes through /dev/uinput; hand it to the user of 235 # the active session (hardware.uinput, pulled in by services.sunshine, only 236 # sets up the uinput group). 237 services.udev.extraRules = '' 238 KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess" 239 ''; 240}