git.lucas.co / cce-compositor
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