diff --git a/application.nix b/application.nix index cfeb72e6..23fe96f4 100644 --- a/application.nix +++ b/application.nix @@ -21,7 +21,19 @@ rec { (import ./application/overlays version) ]; - module = { config, lib, pkgs, ... }: { + module = { config, lib, pkgs, ... }: + let + selectDisplay = pkgs.writeShellApplication { + name = "select-display"; + runtimeInputs = with pkgs; [ + gnugrep + gawk + xorg.xrandr + bash + ]; + text = (builtins.readFile ./application/select-display.sh); + }; + in { imports = [ ./application/playos-status.nix @@ -67,6 +79,9 @@ rec { xset s noblank xset -dpms + # Select best display to output to + ${selectDisplay}/bin/select-display || true + # Localization for xsession if [ -f /var/lib/gui-localization/lang ]; then export LANG=$(cat /var/lib/gui-localization/lang) @@ -75,19 +90,6 @@ rec { setxkbmap $(cat /var/lib/gui-localization/keymap) || true fi - # Set preferred screen resolution - scaling_pref=$(cat /var/lib/gui-localization/screen-scaling 2>/dev/null || echo "default") - case "$scaling_pref" in - "default" | "full-hd") - xrandr --size 1920x1080;; - "native") - # Nothing to do, let system decide. - ;; - *) - echo "Unknown scaling preference '$scaling_pref'. Ignoring." - ;; - esac - # Enable Qt WebEngine Developer Tools (https://doc.qt.io/qt-6/qtwebengine-debugging.html) export QTWEBENGINE_REMOTE_DEBUGGING="127.0.0.1:3355" @@ -145,6 +147,24 @@ rec { wantedBy = [ "multi-user.target" ]; }; + # Monitor hotplugging + services.udev.extraRules = '' + ACTION=="change", SUBSYSTEM=="drm", RUN+="${pkgs.systemd}/bin/systemctl start select-display.service" + ''; + systemd.services."select-display" = { + description = "Select best display to output to"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${selectDisplay}/bin/select-display"; + User = "play"; + }; + environment = { + XAUTHORITY = "${config.users.users.play.home}/.Xauthority"; + DISPLAY = ":0"; + }; + after = [ "graphical.target" ]; + }; + # Audio sound.enable = true; hardware.pulseaudio = { diff --git a/application/select-display.sh b/application/select-display.sh new file mode 100755 index 00000000..7486f136 --- /dev/null +++ b/application/select-display.sh @@ -0,0 +1,48 @@ +set -euo pipefail + +SCALING_PREF=$(cat /var/lib/gui-localization/screen-scaling 2>/dev/null || echo "default") +readonly SCALING_PREF + +CONNECTED_OUTPUTS=$(xrandr | grep ' connected' | awk '{ print $1 }') +readonly CONNECTED_OUTPUTS + +echo -e "Connected outputs:\n$CONNECTED_OUTPUTS\n" + +scaling_pref_params="" + +echo "Using scaling preference '$SCALING_PREF'" + +case "$SCALING_PREF" in + "default" | "full-hd") + scaling_pref_params=(--mode 1920x1080) + ;; + "native") + scaling_pref_params=(--auto) + ;; + *) + scaling_pref_params=(--auto) + ;; +esac + +if [ -z "$CONNECTED_OUTPUTS" ]; then + + echo "Error: no connected outputs found. Not applying any changes." + +else + + first_functional_output="" + for output in $CONNECTED_OUTPUTS; do + if [ -z "$first_functional_output" ]; then + if xrandr --output "$output" --primary "${scaling_pref_params[@]}"; then + first_functional_output=$output + echo "Configured display $output as primary" + else + echo "Failed to configure display $output" + fi + else + xrandr --output "$output" \ + --same-as "$first_functional_output" \ + "${scaling_pref_params[@]}" || echo "Failed to configure display $output" + fi + done +fi diff --git a/kiosk/kiosk_browser/__init__.py b/kiosk/kiosk_browser/__init__.py index f9b576c5..25e22bc4 100644 --- a/kiosk/kiosk_browser/__init__.py +++ b/kiosk/kiosk_browser/__init__.py @@ -17,21 +17,19 @@ def start(kiosk_url, settings_url, toggle_settings_key, fullscreen = True): mainWidget = main_widget.MainWidget( kiosk_url = parseUrl(kiosk_url), settings_url = parseUrl(settings_url), - toggle_settings_key = QKeySequence(toggle_settings_key) + toggle_settings_key = QKeySequence(toggle_settings_key), + fullscreen = fullscreen ) mainWidget.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) - screen_size = app.primaryScreen().size() - - if fullscreen: - # Without a Window Manager, showFullScreen does not work under X, - # so set the window size to the primary screen size. - mainWidget.resize(screen_size) - mainWidget.showFullScreen() - else: - mainWidget.resize(QSize(round(screen_size.width() / 2), round(screen_size.height() / 2))) - mainWidget.show() + # Note: Qt primary screen != xrandr primary screen + # Qt will set primary when screen becomes visible, while on + # xrandr it only changes when `--primary` is explicitly specified + app.primaryScreenChanged.connect(mainWidget.handle_screen_change, + type=Qt.ConnectionType.QueuedConnection) + primary = app.primaryScreen() + mainWidget.handle_screen_change(primary) # Quit application gracefully when receiving SIGINT or SIGTERM # This is important to trigger flushing of in-memory DOM storage to disk diff --git a/kiosk/kiosk_browser/main_widget.py b/kiosk/kiosk_browser/main_widget.py index 70d3136b..91954244 100644 --- a/kiosk/kiosk_browser/main_widget.py +++ b/kiosk/kiosk_browser/main_widget.py @@ -1,5 +1,6 @@ from PyQt6 import QtWidgets, QtCore, QtGui import time +import logging from kiosk_browser import browser_widget, captive_portal, dialogable_widget, proxy as proxy_module @@ -11,8 +12,12 @@ class MainWidget(QtWidgets.QWidget): - Use proxy configured in Connman. """ - def __init__(self, kiosk_url: str, settings_url: str, toggle_settings_key: str): + def __init__(self, kiosk_url: str, settings_url: str, + toggle_settings_key: str, fullscreen: bool): super(MainWidget, self).__init__() + # Display + self._primary_screen_con = None + self._fullscreen = fullscreen # Proxy proxy = proxy_module.Proxy() @@ -102,3 +107,27 @@ def eventFilter(self, source, event): self._menu_press_since = None return super(MainWidget, self).eventFilter(source, event) + + def handle_screen_change(self, new_primary): + logging.info(f"Primary screen changed to {new_primary.name()}") + if self._primary_screen_con is not None: + QtCore.QObject.disconnect(self._primary_screen_con) + + self._primary_screen_con = \ + new_primary.geometryChanged.connect(self._resize_to_screen) + + # Precautionary sleep to allow Chromium to update screens + time.sleep(1) + self._resize_to_screen(new_primary.geometry()) + + def _resize_to_screen(self, new_geom): + screen_size = new_geom.size() + logging.info(f"Resizing widget based on new screen size: {screen_size}") + if self._fullscreen: + # Without a Window Manager, showFullScreen does not work under X, + # so set the window size to the primary screen size. + self.resize(screen_size) + self.showFullScreen() + else: + self.resize(QtCore.QSize(round(screen_size.width() / 2), round(screen_size.height() / 2))) + self.show() diff --git a/testing/integration/kiosk-screen-resolution.nix b/testing/integration/kiosk-screen-resolution.nix new file mode 100644 index 00000000..6b521216 --- /dev/null +++ b/testing/integration/kiosk-screen-resolution.nix @@ -0,0 +1,200 @@ +# See ../manual/kiosk-dual-screen.nix for a test with two output displays that +# can be run in interactive mode. +let + pkgs = import ../../pkgs { }; + serverPort = 8080; + kioskUrl = "http://localhost:${toString serverPort}/"; + kiosk = import ../../kiosk { + pkgs = pkgs; + system_name = "PlayOS"; + system_version = "1.0.0"; + }; + inherit (builtins) toString; +in +pkgs.nixosTest { + name = "Kiosk gracefully switches between output modes"; + + nodes.machine = { config, ... }: { + imports = [ + (pkgs.importFromNixos "tests/common/user-account.nix") + ]; + + virtualisation.qemu.options = [ + "-enable-kvm" + ]; + + services.static-web-server.enable = true; + services.static-web-server.listen = "[::]:8080"; + services.static-web-server.root = "/tmp/www"; + systemd.tmpfiles.rules = [ + "d ${config.services.static-web-server.root} 0777 root root -" + ]; + + services.xserver = let sessionName = "kiosk-browser"; + in { + enable = true; + + desktopManager = { + xterm.enable = false; + session = [{ + name = sessionName; + start = '' + # Disable screen-saver control (screen blanking) + xset s off + xset s noblank + xset -dpms + + ${kiosk}/bin/kiosk-browser \ + ${kioskUrl} ${kioskUrl} + + waitPID=$! + ''; + }]; + }; + + displayManager = { + xserverArgs = [ + "-nocursor" + ]; + # Always automatically log in play user + lightdm = { + enable = true; + greeter.enable = false; + autoLogin.timeout = 0; + }; + + autoLogin = { + enable = true; + user = "alice"; + }; + + defaultSession = sessionName; + }; + }; + }; + + extraPythonPackages = ps: [ + ps.colorama + ps.types-colorama + ps.pillow + ps.types-pillow + ]; + + testScript = '' + ${builtins.readFile ../helpers/nixos-test-script-helpers.py} + import time + import tempfile + from collections import Counter + from PIL import Image, ImageChops + + def num_diff_pixels(a, b): + im1 = a.convert("RGB") + im2 = b.convert("RGB") + diff = ImageChops.difference(im1, im2) + return sum((1 for p in diff.getdata() if p != (0, 0, 0))) + + def xrandr(params): + return machine.succeed(f"su -c 'xrandr --output Virtual-1 {params}' alice") + + def get_dm_restarts(): + _, restarts_str = machine.systemctl("show display-manager.service -p NRestarts") + [_, num] = restarts_str.split("NRestarts=") + return int(num.strip()) + + def get_kiosk_pid(): + kiosk_pids = machine.succeed("pgrep kiosk-browser | sort") + return int(kiosk_pids.strip()) + + machine.start() + + machine.wait_for_file("/tmp/www") + # 4x4 grid = 16 divs, even col/row numbers divide resolution neatly + divs = "
" * 16 + machine.succeed("""cat << EOF > /tmp/www/index.html + + + + + """ + divs + """ + + EOF""") + + machine.wait_for_unit("graphical.target") + machine.wait_for_file("/home/alice/.Xauthority") + + time.sleep(10) # give time for Chromium to start + + original_kiosk_pid = get_kiosk_pid() + + with TestCase("kiosk gracefully responds to screen and mode changes") as t,\ + tempfile.TemporaryDirectory() as d: + xrandr("--mode 640x480") + time.sleep(3) # give kiosk time to resize + + machine.screenshot(d + "/screen1.png") + screen1 = Image.open(d + "/screen1.png").convert("RGB") + + # sanity check: size is correct + t.assertEqual(screen1.size, (640, 480)) + + # sanity check: only contains yellow and red pixels + screen1_colors = Counter(screen1.getdata()) + t.assertDictEqual( + screen1_colors, + { (255, 255, 0): 640*480 // 2, + (255, 0, 0): 640*480 // 2 + }, + "Expected only red and yellow colours on the screen" + ) + + # note: must have the same aspect ratio as the initial resolution + xrandr("--mode 800x600") + time.sleep(3) # give kiosk time to resize + + machine.screenshot(d + "/screen2.png") + screen2 = Image.open(d + "/screen2.png") + screen2_scaled = screen2.resize(screen1.size) + + t.assertEqual( + num_diff_pixels(screen1, screen2_scaled), + 0, + "Screenshots do not match after rescaling!" + ) + + xrandr("--off") + xrandr("--mode 640x480") + time.sleep(3) # give kiosk time to resize + + machine.screenshot(d + "/screen3.png") + screen3 = Image.open(d + "/screen3.png") + + t.assertEqual( + num_diff_pixels(screen1, screen3), + 0, + "Initial and final screenshots do not match" + ) + + t.assertEqual(original_kiosk_pid, get_kiosk_pid(), + "Kiosk PIDs do not match, it crashed - check logs.") + t.assertEqual(0, get_dm_restarts(), + "display manager was restarted, it crashed - check logs!") +''; +} diff --git a/testing/manual/kiosk-dual-screen.nix b/testing/manual/kiosk-dual-screen.nix new file mode 100644 index 00000000..de839b20 --- /dev/null +++ b/testing/manual/kiosk-dual-screen.nix @@ -0,0 +1,153 @@ +# Note: this test ONLY works in interactive mode because otherwise QEMU reports +# only a single connected display to the guest VM. There is probably a way to +# work around this, but could not find a way. +# +# Run using: +# $(nix-build -A driverInteractive kiosk-dual-screen.nix)/bin/nixos-test-driver --no-interactive +let + pkgs = import ../pkgs { }; + serverPort = 8080; + kioskUrl = "http://localhost:${toString serverPort}/"; + kiosk = import ../kiosk { + pkgs = pkgs; + system_name = "PlayOS"; + system_version = "1.0.0"; + }; + inherit (builtins) toString; +in +pkgs.nixosTest { + name = "Kiosk gracefully switches between output screens and modes"; + + nodes.machine = { config, ... }: { + imports = [ + (pkgs.importFromNixos "tests/common/user-account.nix") + ]; + + virtualisation.qemu.options = [ + "-enable-kvm" + "-device" "virtio-vga,max_outputs=2" + ]; + + services.static-web-server.enable = true; + services.static-web-server.listen = "[::]:8080"; + services.static-web-server.root = "/tmp/www"; + systemd.tmpfiles.rules = [ + "d ${config.services.static-web-server.root} 0777 root root -" + ]; + + services.xserver = let sessionName = "kiosk-browser"; + in { + enable = true; + + desktopManager = { + xterm.enable = false; + session = [{ + name = sessionName; + start = '' + # Disable screen-saver control (screen blanking) + xset s off + xset s noblank + xset -dpms + + ${kiosk}/bin/kiosk-browser \ + ${kioskUrl} ${kioskUrl} + + waitPID=$! + ''; + }]; + }; + + displayManager = { + # Always automatically log in play user + lightdm = { + enable = true; + greeter.enable = false; + autoLogin.timeout = 0; + }; + + autoLogin = { + enable = true; + user = "alice"; + }; + + defaultSession = sessionName; + }; + }; + }; + + extraPythonPackages = ps: [ + ps.colorama + ps.types-colorama + ps.diffimg + ]; + + #enableOCR = true; + + testScript = '' + ${builtins.readFile ./helpers/nixos-test-script-helpers.py} + import time + import tempfile + import diffimg # type: ignore + + def xrandr(output, params): + return machine.succeed(f"su -c 'xrandr --output {output} {params}' alice") + + def get_dm_restarts(): + _, restarts_str = machine.systemctl("show display-manager.service -p NRestarts") + [_, num] = restarts_str.split("NRestarts=") + return int(num.strip()) + + def get_kiosk_pid(): + kiosk_pids = machine.succeed("pgrep kiosk-browser | sort") + return int(kiosk_pids.strip()) + + machine.start() + + machine.wait_for_file("/tmp/www") + machine.succeed("""cat << EOF > /tmp/www/index.html + + +