This repository has no description
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}