Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
scripts/cce-airplay (8.1K)
1 #!/usr/bin/env bash
2 # cce-airplay — this computer as an AirPlay screen-mirroring receiver
3 #
4 # A wrapper around UxPlay (pacman: uxplay), which does the AirPlay work; this
5 # script only makes it behave like a cce app. Launched from
6 # cce-airplay.desktop with no argument it TOGGLES: the first launch starts the
7 # receiver, the next stops it, so the launcher entry is the on/off switch.
8 #
9 # cce-airplay [toggle|start|stop|status]
10 #
11 # Status reaches the user as notifications, because nothing else of UxPlay's
12 # is visible: it has no window until a phone is mirroring, and it prints the
13 # pairing PIN only to its terminal (as ASCII art, LOGI in uxplay.cpp's
14 # display_pin), which a launcher-started process does not have. So its output
15 # is read line by line, and the lines below are matched as they appear in
16 # UxPlay 1.73's source at its default (INFO) log level — the "Open
17 # connections" count is debug-only, which is why there is no "disconnected"
18 # notice:
19 #
20 # *** CLIENT MUST NOW ENTER PIN = "1234" AS AIRPLAY PASSWORD
21 # connection request from <name> (<model>) with deviceID = <id>
22 # registered new client: <name> DeviceID = <id> PK = ...
23 #
24 # Access control is a PIN on first contact (-pin, random each time) and a
25 # register of the devices that passed it (-reg), so a phone pairs once and
26 # connects freely after — while anyone else on the network gets a PIN prompt
27 # rather than the screen. The server key (-key) must persist with it: the
28 # registration is against that key, and UxPlay with no -key path makes a new
29 # one per run, which would ask for the PIN every time.
30 #
31 # The video window is GStreamer's waylandsink, chosen explicitly. The default
32 # (autovideosink) ranks xvimagesink first, an X11 window through Xwayland;
33 # glimagesink always maps at 320x240 whatever the stream. waylandsink maps at
34 # the stream's size and letterboxes into any size the compositor configures,
35 # and its app_id is "uxplay" (the process name), which is what the
36 # config.kdl rule `mode_rule mode="floating" app_id="uxplay" center=(bool)true`
37 # keys on to open it in the middle of the current view rather than at the
38 # desk's origin. -s asks the phone for a stream that fits 90% of the smallest
39 # output in LOGICAL pixels, because waylandsink's window is the stream's size
40 # in logical pixels: UxPlay's 1920x1080 default puts a portrait phone 1080
41 # tall on a 1200-tall screen, under the bar.
42 #
43 # Options of your own go in ~/.uxplayrc (one per line, no leading dash); the
44 # command line here is read after it and wins where they overlap. The
45 # receiver's name is the hostname unless CCE_AIRPLAY_NAME is set.
46
47 set -u
48
49 CCE_CTL="$HOME/.local/bin/ccectl"
50 STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/cce/airplay"
51 LOG="${XDG_RUNTIME_DIR:-/tmp}/cce-airplay.log"
52 PIDFILE="${XDG_RUNTIME_DIR:-/tmp}/cce-airplay.pid"
53 NAME="${CCE_AIRPLAY_NAME:-$(uname -n)}"
54
55 notify() {
56 notify-send -a AirPlay -i cce-airplay "$@"
57 }
58
59 # UxPlay instances of this user, however started. A session restore relaunches
60 # a window's process by its own argv — uxplay, not this script — so a receiver
61 # can be running with no wrapper above it; stop has to reach that one too.
62 receiver_pids() {
63 pgrep -u "$UID" -x uxplay
64 }
65
66 # "-s WxH" for 90% of the smallest enabled output's logical size, so the
67 # window fits whichever screen it opens on, or nothing (UxPlay's default) when
68 # the compositor cannot be asked. `outputs --json` is one object per line.
69 stream_size() {
70 "$CCE_CTL" outputs --json 2>/dev/null | python3 -c '
71 import json, sys
72 outs = []
73 for line in sys.stdin:
74 try:
75 o = json.loads(line)
76 except ValueError:
77 continue
78 if o.get("enabled") and o.get("logical_w") and o.get("logical_h"):
79 outs.append(o)
80 if outs:
81 w = min(o["logical_w"] for o in outs)
82 h = min(o["logical_h"] for o in outs)
83 print("-s %dx%d" % (int(w * 0.9) // 2 * 2, int(h * 0.9) // 2 * 2))
84 '
85 }
86
87 stop() {
88 local pids
89 pids=$(receiver_pids) || { echo "cce-airplay: not running"; return 0; }
90 # Asked before the kill: the wrapper drops its pidfile as the receiver
91 # exits, so afterwards it always reads as absent.
92 local wrapped=false
93 wrapper_alive && wrapped=true
94 # SIGTERM is UxPlay's clean exit (a g_unix_signal_add handler), which
95 # withdraws its mDNS record so the phone stops listing this computer.
96 kill $pids
97 for _ in $(seq 30); do
98 receiver_pids >/dev/null || break
99 sleep 0.1
100 done
101 # A wrapper above it reports the exit itself; a bare restored receiver
102 # has nobody to, so report here.
103 if ! $wrapped; then
104 notify "AirPlay stopped" "$NAME is no longer an AirPlay screen."
105 fi
106 }
107
108 # Whether a `start` is still alive above the receiver. A pidfile rather than
109 # pgrep -f on this script's name: every subshell this script forks carries the
110 # same cmdline until it execs, so a name match can find the caller itself.
111 wrapper_alive() {
112 local pid
113 pid=$(cat "$PIDFILE" 2>/dev/null) && [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
114 }
115
116 start() {
117 if receiver_pids >/dev/null; then
118 echo "cce-airplay: already running"
119 return 0
120 fi
121 if ! command -v uxplay >/dev/null; then
122 notify -u critical "AirPlay unavailable" "UxPlay is not installed (pacman -S uxplay)."
123 return 1
124 fi
125 if ! systemctl is-active --quiet avahi-daemon; then
126 notify -u critical "AirPlay unavailable" \
127 "avahi-daemon is not running, so no iPhone can find this computer."
128 return 1
129 fi
130 mkdir -p "$STATE_DIR"
131 chmod 700 "$STATE_DIR"
132
133 local args=(-n "$NAME" -nh
134 -pin -reg "$STATE_DIR/register" -key "$STATE_DIR/key.pem"
135 -vs waylandsink)
136 local size
137 size=$(stream_size)
138 [[ -n "$size" ]] && args+=($size)
139
140 notify "AirPlay ready" \
141 "Mirror to “$NAME” from Control Center › Screen Mirroring. Open AirPlay again to stop."
142
143 local pin_id="" device="" line
144 : >"$LOG"
145 echo $$ >"$PIDFILE"
146 trap 'rm -f "$PIDFILE"' EXIT
147 # stdbuf: UxPlay's stdout is block-buffered into a pipe, which would hold
148 # the PIN back until 4 KiB of later output pushed it through.
149 stdbuf -oL -eL uxplay "${args[@]}" 2>&1 | while IFS= read -r line; do
150 printf '%s\n' "$line" >>"$LOG"
151 case "$line" in
152 *'CLIENT MUST NOW ENTER PIN = "'*)
153 local pin=${line#*PIN = \"}
154 pin=${pin%%\"*}
155 # Persistent (-t 0) until the pairing settles it: the phone
156 # waits on the user, however long that takes.
157 pin_id=$(notify -p -u critical -t 0 ${pin_id:+-r "$pin_id"} \
158 "AirPlay PIN: $pin" \
159 "Enter $pin on ${device:-your iPhone} to pair it with $NAME.")
160 ;;
161 *'registered new client: '*)
162 local who=${line#*registered new client: }
163 who=${who%% DeviceID = *}
164 notify ${pin_id:+-r "$pin_id"} -t 5000 "AirPlay paired" \
165 "$who can now mirror to $NAME without a PIN."
166 pin_id=""
167 ;;
168 *'connection request from '*)
169 device=${line#*connection request from }
170 device=${device%% (*}
171 notify -t 4000 "AirPlay" "Mirroring from $device"
172 ;;
173 esac
174 done
175 local status=${PIPESTATUS[0]}
176
177 # 0 is a clean stop (SIGTERM from `stop`); 143 is the same signal landing
178 # before UxPlay's main loop installed its handler.
179 if [[ $status -eq 0 || $status -eq 143 ]]; then
180 notify "AirPlay stopped" "$NAME is no longer an AirPlay screen."
181 else
182 local why
183 why=$(grep -E 'ERROR|error|cannot|failed|Failed|not found|instances' "$LOG" | tail -n 3)
184 notify -u critical "AirPlay stopped unexpectedly" \
185 "${why:-UxPlay exited with status $status.} Log: $LOG"
186 fi
187 return "$status"
188 }
189
190 case "${1:-toggle}" in
191 toggle)
192 if receiver_pids >/dev/null; then stop; else start; fi
193 ;;
194 start) start ;;
195 stop) stop ;;
196 status)
197 if receiver_pids >/dev/null; then
198 echo "running as \"$NAME\" (log: $LOG)"
199 else
200 echo "stopped"
201 exit 1
202 fi
203 ;;
204 *)
205 echo "usage: cce-airplay [toggle|start|stop|status]" >&2
206 exit 2
207 ;;
208 esac