diff --git a/lib/classroom_optical.py b/lib/classroom_optical.py new file mode 100644 index 0000000..9bc8571 --- /dev/null +++ b/lib/classroom_optical.py @@ -0,0 +1,125 @@ +"""Optical drive helpers for the Ekklesia Classroom shell. + +Everything here is deliberately dependency free and non-blocking so the +dock can poll drive state from its GTK main loop without stalling and +without spinning the drive up. +""" + +import fcntl +import glob +import os + +# linux/cdrom.h +CDROM_DRIVE_STATUS = 0x5326 +CDROM_DISC_STATUS = 0x5327 +CDROMEJECT = 0x5309 +CDROMCLOSETRAY = 0x5319 + +# CDSL_CURRENT: "whatever slot is loaded right now". +CDSL_CURRENT = 0x7FFFFFFF + +# Drive status values. +CDS_NO_INFO = 0 +CDS_NO_DISC = 1 +CDS_TRAY_OPEN = 2 +CDS_DRIVE_NOT_READY = 3 +CDS_DISC_OK = 4 + +# Disc status values. +CDS_AUDIO = 100 +CDS_DATA_1 = 101 +CDS_DATA_2 = 102 +CDS_XA_2_1 = 103 +CDS_XA_2_2 = 104 +CDS_MIXED = 105 + + +def devices(): + """Every optical device node currently present.""" + return sorted(glob.glob("/dev/sr[0-9]*")) + + +def default_device(): + """The optical device the classroom should use, if any.""" + found = devices() + + if not found: + return None + + return found[0] + + +def _ioctl(device, request, argument=CDSL_CURRENT): + """Run a CD-ROM ioctl, returning None when the drive cannot be read.""" + try: + fd = os.open(device, os.O_RDONLY | os.O_NONBLOCK) + except OSError: + return None + + try: + return fcntl.ioctl(fd, request, argument) + except OSError: + return None + finally: + os.close(fd) + + +def drive_status(device=None): + """CDS_* drive status, or None when there is no readable drive.""" + if device is None: + device = default_device() + + if device is None: + return None + + return _ioctl(device, CDROM_DRIVE_STATUS) + + +def disc_status(device=None): + """CDS_* disc type. Only call this once a disc has become ready.""" + if device is None: + device = default_device() + + if device is None: + return None + + return _ioctl(device, CDROM_DISC_STATUS) + + +def has_disc(device=None): + """True when a disc is loaded and the drive has finished reading it.""" + return drive_status(device) == CDS_DISC_OK + + +def eject(device=None): + """Open the tray. Returns True when the drive accepted the request.""" + if device is None: + device = default_device() + + if device is None: + return False + + return _ioctl(device, CDROMEJECT, 0) is not None + + +def close_tray(device=None): + """Close the tray. Returns True when the drive accepted the request.""" + if device is None: + device = default_device() + + if device is None: + return False + + return _ioctl(device, CDROMCLOSETRAY, 0) is not None + + +def describe(status): + """Human readable drive status, used by classroom-dvd status.""" + return { + None: "no drive", + CDS_NO_INFO: "unknown", + CDS_NO_DISC: "no disc", + CDS_TRAY_OPEN: "tray open", + CDS_DRIVE_NOT_READY: "drive not ready", + CDS_DISC_OK: "disc ready", + }.get(status, "unknown") diff --git a/packaging/debian/control.template b/packaging/debian/control.template index 7d38f88..3a81e89 100644 --- a/packaging/debian/control.template +++ b/packaging/debian/control.template @@ -5,5 +5,6 @@ Priority: optional Architecture: amd64 Maintainer: Ekklesia Depends: sway, swaybg, waybar, mako-notifier, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, cups, python3-gi, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, adwaita-icon-theme, libnotify-bin +Recommends: vlc Description: Ekklesia managed classroom environment A minimal Wayland classroom environment for Ekklesia-managed church computers. diff --git a/packaging/debian/postinst b/packaging/debian/postinst index 5b065db..00ae229 100755 --- a/packaging/debian/postinst +++ b/packaging/debian/postinst @@ -19,19 +19,40 @@ chmod 0644 /home/classroom/.bash_profile chmod 0755 /usr/local/bin/classroom-browser chmod 0755 /usr/local/bin/classroom-window +chmod 0755 /usr/local/bin/classroom-dvd +chmod 0755 /usr/local/bin/classroom-dvd-css-setup chmod 0755 /usr/local/bin/classroom-notify-test +# Reading the optical drive needs group cdrom. Takes effect at the next +# login, which the OTA reboot provides. +if getent group cdrom >/dev/null 2>&1; then + adduser classroom cdrom >/dev/null 2>&1 || true +fi + if [ ! -f /etc/ekklesia-classroom/device.conf ]; then cat > /etc/ekklesia-classroom/device.conf <<'CONFIG' # Ekklesia Classroom device configuration UPDATE_URL=https://updates.riverofglorymn.org CHANNEL=stable + +# Set to true on classrooms that have a DVD drive. +DVD_ENABLED=false CONFIG chmod 0644 /etc/ekklesia-classroom/device.conf fi +# Make the DVD option discoverable on classrooms installed before it +# existed. false is what those rooms already do, so nothing changes. +if ! grep -q '^DVD_ENABLED=' /etc/ekklesia-classroom/device.conf; then + cat >> /etc/ekklesia-classroom/device.conf <<'CONFIG' + +# Set to true on classrooms that have a DVD drive. +DVD_ENABLED=false +CONFIG +fi + systemctl daemon-reload || true systemctl enable ekklesia-classroom-update.timer || true diff --git a/scripts/classroom-dock b/scripts/classroom-dock index c7d5303..c926a0b 100755 --- a/scripts/classroom-dock +++ b/scripts/classroom-dock @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import subprocess +import sys from datetime import datetime from pathlib import Path @@ -11,11 +12,23 @@ gi.require_version("GtkLayerShell", "0.1") from gi.repository import Gtk, Gdk, GdkPixbuf, GLib, GtkLayerShell +# Optional on purpose. The dock is the one thing the classroom cannot +# afford to lose, so a missing DVD helper simply disables disc detection +# instead of taking the whole shell down. +sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") + +try: + import classroom_optical as optical +except ImportError: + optical = None + CONFIG_FILE = Path("/etc/ekklesia-classroom/device.conf") CSS_FILE = Path("/etc/ekklesia-classroom/dock/style.css") ICON_DIR = Path("/etc/ekklesia-classroom/icons") +DVD_POLL_SECONDS = 2 + def read_device_config(): config = {} @@ -110,6 +123,9 @@ class ClassroomShell(Gtk.Window): self.config = read_device_config() + self.dvd_image = None + self.disc_ready = None + GtkLayerShell.init_for_window(self) GtkLayerShell.set_layer( self, @@ -207,9 +223,8 @@ class ClassroomShell(Gtk.Window): dock.pack_start( self.make_button( self.dvd_icon(), - lambda *_: notify( - "DVD", - "DVD support is being prepared.", + lambda *_: launch( + ["/usr/local/bin/classroom-dvd", "toggle"] ), ), False, @@ -217,6 +232,8 @@ class ClassroomShell(Gtk.Window): 0, ) + self.start_disc_watch() + dock.pack_start( self.make_button( self.sanctuary_icon(), @@ -268,6 +285,7 @@ class ClassroomShell(Gtk.Window): ) if image is not None: + self.dvd_image = image return image image = themed_icon( @@ -307,6 +325,38 @@ class ClassroomShell(Gtk.Window): return image + def start_disc_watch(self): + if optical is None: + return + + self.check_disc() + + GLib.timeout_add_seconds( + DVD_POLL_SECONDS, + self.check_disc, + ) + + def check_disc(self): + ready = optical.drive_status() == optical.CDS_DISC_OK + + # The disc indicator: the DVD logo sits back when the drive is + # empty or unplugged and comes up to full strength once a disc is + # ready, the same gray/colour language the LIVE icon uses. + if self.dvd_image is not None: + self.dvd_image.set_opacity(1.0 if ready else 0.55) + + # The first poll only records what is already in the drive, so a + # disc left in over the week does not announce itself at boot. + if self.disc_ready is not None and ready and not self.disc_ready: + notify( + "DVD Ready", + "A disc is ready to play.", + ) + + self.disc_ready = ready + + return True + def update_time(self): self.clock.set_text( datetime.now().strftime("%-I:%M %p") diff --git a/scripts/classroom-dvd b/scripts/classroom-dvd new file mode 100755 index 0000000..ec604f4 --- /dev/null +++ b/scripts/classroom-dvd @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 + +"""Classroom DVD control. + +Discs are never played automatically. Inserting a disc only produces a +notification and changes the dock icon; playback always starts from a +deliberate press of the DVD button. +""" + +import argparse +import os +import shutil +import subprocess +import sys + +sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") +sys.path.insert( + 0, + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"), +) + +import classroom_optical as optical # noqa: E402 + +PLAYER = "vlc" +WINDOW_TOOL = "/usr/local/bin/classroom-window" + + +def notify(title, message, urgency="normal"): + try: + subprocess.Popen( + [ + "notify-send", + "--app-name=Ekklesia Classroom", + f"--urgency={urgency}", + "--", + title, + message, + ], + start_new_session=True, + ) + except OSError: + print(f"{title}: {message}", file=sys.stderr) + + +def player_command(device): + return [ + PLAYER, + "--fullscreen", + "--no-video-title-show", + "--no-qt-privacy-ask", + "--no-qt-updates-notif", + f"dvd://{device}", + ] + + +def play(device): + """Hand the disc to the player through the normal window toggle.""" + if shutil.which(PLAYER) is None: + notify( + "DVD", + "The DVD player is not installed on this classroom computer.", + ) + return 1 + + subprocess.run( + [ + WINDOW_TOOL, + "toggle", + "--match", + "(?i)vlc", + "--", + *player_command(device), + ] + ) + + return 0 + + +def action_toggle(force_play=False): + device = optical.default_device() + + if device is None: + notify("DVD", "No DVD drive is connected to this classroom computer.") + return 1 + + status = optical.drive_status(device) + + if status == optical.CDS_DISC_OK: + return play(device) + + if status == optical.CDS_DRIVE_NOT_READY: + notify("DVD", "Reading the disc. Please try again in a moment.") + return 1 + + if status == optical.CDS_TRAY_OPEN: + notify("DVD", "The disc tray is open. Insert a disc to get started.") + return 1 + + if force_play: + return play(device) + + notify("DVD", "No disc is in the drive. Insert a disc to get started.") + return 1 + + +def action_status(): + device = optical.default_device() + + if device is None: + print("drive: none") + return 1 + + status = optical.drive_status(device) + + print(f"drive: {device}") + print(f"status: {optical.describe(status)}") + print(f"player: {shutil.which(PLAYER) or 'not installed'}") + + if status == optical.CDS_DISC_OK: + print(f"disc: {optical.disc_status(device)}") + return 0 + + return 1 + + +def action_eject(): + device = optical.default_device() + + if device is None: + notify("DVD", "No DVD drive is connected to this classroom computer.") + return 1 + + if not optical.eject(device): + notify("DVD", "The disc could not be ejected. It may still be playing.") + return 1 + + return 0 + + +def main(): + parser = argparse.ArgumentParser(description="Classroom DVD control.") + subparsers = parser.add_subparsers(dest="action", required=True) + + subparsers.add_parser("toggle", help="Dock button behaviour") + subparsers.add_parser("play", help="Start playback even if the drive is unsure") + subparsers.add_parser("status", help="Report drive and disc state") + subparsers.add_parser("eject", help="Open the disc tray") + + arguments = parser.parse_args() + + if arguments.action == "toggle": + return action_toggle() + + if arguments.action == "play": + return action_toggle(force_play=True) + + if arguments.action == "status": + return action_status() + + return action_eject() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/classroom-dvd-css-setup b/scripts/classroom-dvd-css-setup new file mode 100755 index 0000000..d81e141 --- /dev/null +++ b/scripts/classroom-dvd-css-setup @@ -0,0 +1,61 @@ +#!/bin/bash +set -euo pipefail + +# One-time bootstrap for encrypted (commercial) DVD-Video playback. +# +# Most retail DVDs are CSS encrypted. Debian cannot ship the decryption +# library, so libdvdcss has to be built on the machine from the contrib +# package libdvd-pkg. This is deliberately NOT run from the package +# postinst: it needs the network, it compiles a package, and it must not +# run while dpkg already holds the apt lock during an OTA update. +# +# Run this once, as root, on any classroom that needs commercial discs: +# +# sudo classroom-dvd-css-setup +# +# Unencrypted discs (church curriculum, home-burned video) play without it. + +if [[ "$(id -u)" -ne 0 ]]; then + echo "classroom-dvd-css-setup must be run as root." >&2 + exit 1 +fi + +if [[ -e /usr/lib/x86_64-linux-gnu/libdvdcss.so.2 ]]; then + echo "libdvdcss is already installed. Nothing to do." + exit 0 +fi + +echo "Enabling the Debian contrib component..." + +if ! grep -rqs 'contrib' /etc/apt/sources.list /etc/apt/sources.list.d/; then + if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then + sed -i 's/^\(Components:.*\)$/\1 contrib/' \ + /etc/apt/sources.list.d/debian.sources + else + echo "Could not find a Debian sources file to add contrib to." >&2 + echo "Add 'contrib' manually and re-run this script." >&2 + exit 1 + fi +fi + +echo "Installing libdvd-pkg..." + +export DEBIAN_FRONTEND=noninteractive + +apt-get update +apt-get install -y libdvd-pkg + +echo "Building libdvdcss..." + +# libdvd-pkg downloads and builds libdvdcss during reconfigure. +dpkg-reconfigure -f noninteractive libdvd-pkg + +if [[ -e /usr/lib/x86_64-linux-gnu/libdvdcss.so.2 ]]; then + echo + echo "libdvdcss installed. Commercial DVDs should now play." + echo "Test with an actual disc before relying on it." +else + echo + echo "libdvdcss was not installed. Commercial discs will not play." >&2 + exit 1 +fi diff --git a/tools/build-release.sh b/tools/build-release.sh index 4b989e1..1a01bec 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -90,10 +90,23 @@ install -Dm755 \ "$ROOT/scripts/classroom-window" \ "$STAGE/usr/local/bin/classroom-window" +install -Dm755 \ + "$ROOT/scripts/classroom-dvd" \ + "$STAGE/usr/local/bin/classroom-dvd" + +install -Dm755 \ + "$ROOT/scripts/classroom-dvd-css-setup" \ + "$STAGE/usr/local/bin/classroom-dvd-css-setup" + install -Dm755 \ "$ROOT/scripts/classroom-notify-test" \ "$STAGE/usr/local/bin/classroom-notify-test" +# Shared helpers imported by the dock and by classroom-dvd. +install -Dm644 \ + "$ROOT/lib/classroom_optical.py" \ + "$STAGE/usr/local/lib/ekklesia-classroom/classroom_optical.py" + install -Dm644 \ "$ROOT/config/wallpapers/classroom-default.png" \ "$STAGE/usr/share/backgrounds/ekklesia/classroom-default.png"