From 6f311c8acab1a97cc4ee4cf593c9313e9b324b06 Mon Sep 17 00:00:00 2001 From: Johannes Emerich Date: Sun, 31 Mar 2024 11:47:52 +0200 Subject: [PATCH 01/17] Initial HDMI hotplugging efforts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed earlier attempts for easier rebasing + added bits from guyonvarch (85f5efa): https://github.com/guyonvarch/playos/commit/85f5efa28929cb966de79e061b83e63680cb58a6 Co-authored-by: Joris Co-authored-by: Ignas Vyšniauskas --- application.nix | 49 +++++++++++++++++++++++++---------- application/select-display.sh | 35 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 14 deletions(-) create mode 100755 application/select-display.sh diff --git a/application.nix b/application.nix index cfeb72e6..36561c3f 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,25 @@ 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 = { + # TODO: is this needed? + 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..3bf94de3 --- /dev/null +++ b/application/select-display.sh @@ -0,0 +1,35 @@ +set -euo pipefail + +SCALING_PREF=$(cat /var/lib/gui-localization/screen-scaling 2>/dev/null || echo "default") +readonly SCALING_PREF +echo "Using scaling preference '$SCALING_PREF'" + +CONNECTED_OUTPUTS=$(xrandr | grep ' connected' | awk '{ print $1 }') +readonly CONNECTED_OUTPUTS +echo -e "Connected outputs:\n$CONNECTED_OUTPUTS\n" + +if [ -z "$CONNECTED_OUTPUTS" ]; then + + echo "No connected outputs found. Apply xrandr globally." + xrandr --auto + +else + + case "$SCALING_PREF" in + "default" | "full-hd") + for output in $CONNECTED_OUTPUTS; do + echo "Applying full-hd to output '$output'" + xrandr --auto --output "$output" --mode 1920x1080 + done + ;; + "native") + echo "Native scaling preference. Applying auto." + xrandr --auto + ;; + *) + echo "Unknown scaling preference '$SCALING_PREF'. Applying auto." + xrandr --auto + ;; + esac + +fi From a5f6b5fd6312aee5f72ef2a40f9413f105cff677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Thu, 31 Oct 2024 11:07:45 +0200 Subject: [PATCH 02/17] Version that works on QEMU --- application/select-display.sh | 51 ++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/application/select-display.sh b/application/select-display.sh index 3bf94de3..fa242d42 100755 --- a/application/select-display.sh +++ b/application/select-display.sh @@ -2,34 +2,47 @@ set -euo pipefail SCALING_PREF=$(cat /var/lib/gui-localization/screen-scaling 2>/dev/null || echo "default") readonly SCALING_PREF -echo "Using scaling preference '$SCALING_PREF'" CONNECTED_OUTPUTS=$(xrandr | grep ' connected' | awk '{ print $1 }') readonly CONNECTED_OUTPUTS -echo -e "Connected outputs:\n$CONNECTED_OUTPUTS\n" -if [ -z "$CONNECTED_OUTPUTS" ]; then +echo -e "Connected outputs:\n$CONNECTED_OUTPUTS\n" - echo "No connected outputs found. Apply xrandr globally." - xrandr --auto +scaling_pref_params="" -else +echo "Using scaling preference '$SCALING_PREF'" - case "$SCALING_PREF" in +case "$SCALING_PREF" in "default" | "full-hd") - for output in $CONNECTED_OUTPUTS; do - echo "Applying full-hd to output '$output'" - xrandr --auto --output "$output" --mode 1920x1080 - done - ;; + scaling_pref_params=(--mode 1920x1080) + ;; "native") - echo "Native scaling preference. Applying auto." - xrandr --auto - ;; + scaling_pref_params=(--auto) + ;; *) - echo "Unknown scaling preference '$SCALING_PREF'. Applying auto." - xrandr --auto - ;; - esac + scaling_pref_params=(--auto) + ;; +esac + +if [ -z "$CONNECTED_OUTPUTS" ]; then + + echo "No connected outputs found. Attempting to apply xrandr globally." + xrandr "${scaling_pref_params[@]}" + +else + + first_output="" + for output in $CONNECTED_OUTPUTS; do + if [ -z "$first_output" ]; then + first_output=$output + xrandr --output "$output" \ + --primary \ + "${scaling_pref_params[@]}" + else + xrandr --output "$output" \ + --same-as "$first_output" \ + "${scaling_pref_params[@]}" + fi + done fi From 19bf369afa32f80101a42daa0e12520755e1ee29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Thu, 31 Oct 2024 12:16:41 +0200 Subject: [PATCH 03/17] Allow xrandr to fail and only set primary display on success --- application/select-display.sh | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/application/select-display.sh b/application/select-display.sh index fa242d42..2983f510 100755 --- a/application/select-display.sh +++ b/application/select-display.sh @@ -27,22 +27,24 @@ esac if [ -z "$CONNECTED_OUTPUTS" ]; then echo "No connected outputs found. Attempting to apply xrandr globally." - xrandr "${scaling_pref_params[@]}" + xrandr --auto # this is kind of useless? else - first_output="" + + first_functional_output="" for output in $CONNECTED_OUTPUTS; do - if [ -z "$first_output" ]; then - first_output=$output - xrandr --output "$output" \ - --primary \ - "${scaling_pref_params[@]}" + 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_output" \ - "${scaling_pref_params[@]}" + --same-as "$first_functional_output" \ + "${scaling_pref_params[@]}" || echo "Failed to configure display $output" fi done - fi From 0b4145b340f06dd6c085f5d62103c7cc3d57a5ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Thu, 31 Oct 2024 12:35:30 +0200 Subject: [PATCH 04/17] Remove TODO --- application.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/application.nix b/application.nix index 36561c3f..23fe96f4 100644 --- a/application.nix +++ b/application.nix @@ -159,7 +159,6 @@ rec { User = "play"; }; environment = { - # TODO: is this needed? XAUTHORITY = "${config.users.users.play.home}/.Xauthority"; DISPLAY = ":0"; }; From 3415498bbe9adf6e365e8d098c17f93898f8a305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Tue, 5 Nov 2024 19:06:29 +0200 Subject: [PATCH 05/17] Handle primary screen and screen size changes in kiosk --- kiosk/kiosk_browser/__init__.py | 19 ++++++++----------- kiosk/kiosk_browser/main_widget.py | 28 +++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/kiosk/kiosk_browser/__init__.py b/kiosk/kiosk_browser/__init__.py index f9b576c5..8554e51c 100644 --- a/kiosk/kiosk_browser/__init__.py +++ b/kiosk/kiosk_browser/__init__.py @@ -17,21 +17,18 @@ 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) + 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..b781164d 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 = None + self._fullscreen = fullscreen # Proxy proxy = proxy_module.Proxy() @@ -102,3 +107,24 @@ def eventFilter(self, source, event): self._menu_press_since = None return super(MainWidget, self).eventFilter(source, event) + + def handle_screen_change(self, new_primary): + if self._primary_screen is not None: + logging.info(f"Primary screen changed from: {self._primary_screen.name()} to {new_primary.name()}") + self._primary_screen.geometryChanged.disconnect() + + self._primary_screen = new_primary + self._primary_screen.geometryChanged.connect(self._resize_to_screen) + self._resize_to_screen() + + def _resize_to_screen(self): + screen_size = self._primary_screen.size() # type: ignore + 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() From b1d2db759bb59b6cbd3ed1ab15fe84948fc08b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Tue, 5 Nov 2024 19:10:24 +0200 Subject: [PATCH 06/17] Don't do anything if no connected outputs are found --- application/select-display.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/application/select-display.sh b/application/select-display.sh index 2983f510..19d7495b 100755 --- a/application/select-display.sh +++ b/application/select-display.sh @@ -26,8 +26,7 @@ esac if [ -z "$CONNECTED_OUTPUTS" ]; then - echo "No connected outputs found. Attempting to apply xrandr globally." - xrandr --auto # this is kind of useless? + echo "Error: no connected outputs found. Not applying any changes." else From 1b5bad365dbe06cff1aae80e3e669185b4ff62fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Tue, 5 Nov 2024 19:13:26 +0200 Subject: [PATCH 07/17] Remove extra newline --- application/select-display.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/application/select-display.sh b/application/select-display.sh index 19d7495b..7486f136 100755 --- a/application/select-display.sh +++ b/application/select-display.sh @@ -30,7 +30,6 @@ if [ -z "$CONNECTED_OUTPUTS" ]; then else - first_functional_output="" for output in $CONNECTED_OUTPUTS; do if [ -z "$first_functional_output" ]; then From 557d95d34940b9d5985a249ea9601ba929d9390f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Wed, 6 Nov 2024 09:48:09 +0200 Subject: [PATCH 08/17] Maintain the geometryChanged connection as a state variable This ensures that if other functions are connected to the same signal, they don't get disconnected. --- kiosk/kiosk_browser/main_widget.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/kiosk/kiosk_browser/main_widget.py b/kiosk/kiosk_browser/main_widget.py index b781164d..b6c41e10 100644 --- a/kiosk/kiosk_browser/main_widget.py +++ b/kiosk/kiosk_browser/main_widget.py @@ -16,7 +16,7 @@ def __init__(self, kiosk_url: str, settings_url: str, toggle_settings_key: str, fullscreen: bool): super(MainWidget, self).__init__() # Display - self._primary_screen = None + self._primary_screen_con = None self._fullscreen = fullscreen # Proxy @@ -109,16 +109,17 @@ def eventFilter(self, source, event): return super(MainWidget, self).eventFilter(source, event) def handle_screen_change(self, new_primary): - if self._primary_screen is not None: - logging.info(f"Primary screen changed from: {self._primary_screen.name()} to {new_primary.name()}") - self._primary_screen.geometryChanged.disconnect() + 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 = new_primary - self._primary_screen.geometryChanged.connect(self._resize_to_screen) - self._resize_to_screen() + self._primary_screen_con = \ + new_primary.geometryChanged.connect(self._resize_to_screen) - def _resize_to_screen(self): - screen_size = self._primary_screen.size() # type: ignore + 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, From cb06ae974cb83b142c5e277ea85c2dda2da908eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Wed, 6 Nov 2024 16:57:28 +0200 Subject: [PATCH 09/17] Add test for checking that kiosk responds to screen changes gracefully Fails up-to-this commit, fixed by next commit --- testing/kiosk-dual-screen.nix | 153 ++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 testing/kiosk-dual-screen.nix diff --git a/testing/kiosk-dual-screen.nix b/testing/kiosk-dual-screen.nix new file mode 100644 index 00000000..de839b20 --- /dev/null +++ b/testing/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 + + +
+

Hello world

+
+ + + EOF""") + + machine.wait_for_unit("graphical.target") + machine.wait_for_file("/home/alice/.Xauthority") + + # machine.wait_for_text("Hello world", timeout=20) + # OCR does not work, for whatever reason, so instead we just sleep + 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("Virtual-1", "--primary --mode 640x480") + time.sleep(3) # give controller time to resize + + # note: QEMU always captures only the Virtual-1 display + machine.screenshot(d + "/screen1.png") + + xrandr("Virtual-2", "--mode 800x600") + xrandr("Virtual-1", "--off") # kiosk used to crash here pre-fix + + xrandr("Virtual-1", "--primary --mode 640x480") + machine.screenshot(d + "/screen2.png") + + diff = diffimg.diff(d + "/screen1.png", d + "/screen2.png") + + # sleep to ensure kiosk is finished with dumping core if crashed + time.sleep(20) + + 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!") + t.assertLess(diff, 10 / (640 * 480.0), # allow at most 10 pixels to differ + "Initial and final screenshots do not match!") +''; +} + From 4212df8649aeda6351feedfb2f26b148281d2378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Wed, 6 Nov 2024 16:58:32 +0200 Subject: [PATCH 10/17] Fix: use QueuedConnection to avoid blocking other Qt signal receivers --- kiosk/kiosk_browser/__init__.py | 3 ++- kiosk/kiosk_browser/main_widget.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/kiosk/kiosk_browser/__init__.py b/kiosk/kiosk_browser/__init__.py index 8554e51c..25e22bc4 100644 --- a/kiosk/kiosk_browser/__init__.py +++ b/kiosk/kiosk_browser/__init__.py @@ -26,7 +26,8 @@ def start(kiosk_url, settings_url, toggle_settings_key, fullscreen = True): # 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) + app.primaryScreenChanged.connect(mainWidget.handle_screen_change, + type=Qt.ConnectionType.QueuedConnection) primary = app.primaryScreen() mainWidget.handle_screen_change(primary) diff --git a/kiosk/kiosk_browser/main_widget.py b/kiosk/kiosk_browser/main_widget.py index b6c41e10..91954244 100644 --- a/kiosk/kiosk_browser/main_widget.py +++ b/kiosk/kiosk_browser/main_widget.py @@ -116,6 +116,8 @@ def handle_screen_change(self, new_primary): 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): From 35eb638a2bfa9b0267e18ef437932cab5e6eec01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Fri, 15 Nov 2024 16:46:14 +0200 Subject: [PATCH 11/17] Add integration test for kiosk output scaling based on screen mode --- .../integration/kiosk-screen-resolution.nix | 185 ++++++++++++++++++ testing/{ => manual}/kiosk-dual-screen.nix | 0 2 files changed, 185 insertions(+) create mode 100644 testing/integration/kiosk-screen-resolution.nix rename testing/{ => manual}/kiosk-dual-screen.nix (100%) diff --git a/testing/integration/kiosk-screen-resolution.nix b/testing/integration/kiosk-screen-resolution.nix new file mode 100644 index 00000000..1433dd90 --- /dev/null +++ b/testing/integration/kiosk-screen-resolution.nix @@ -0,0 +1,185 @@ +# 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 = { + # 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 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 controller time to resize + + machine.screenshot(d + "/screen1.png") + screen1 = Image.open(d + "/screen1.png") + t.assertEqual(screen1.size, (640, 480)) # sanity check + + # note: must have the same aspect ratio as the initial resolution + xrandr("--mode 800x600") + time.sleep(3) # give controller time to resize + + machine.screenshot(d + "/screen2.png") + screen2 = Image.open(d + "/screen2.png") + screen2_scaled = screen2.resize(screen1.size) + + # Note: not identical due to mouse pointer being in different locations + t.assertLess( + num_diff_pixels(screen1, screen2_scaled), + 200, + "Screenshots do not match after rescaling!" + ) + + xrandr("--off") + xrandr("--mode 640x480") + time.sleep(3) # give controller time to resize + + machine.screenshot(d + "/screen3.png") + screen3 = Image.open(d + "/screen3.png") + + t.assertLess( + num_diff_pixels(screen1, screen3), + 200, + "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/kiosk-dual-screen.nix b/testing/manual/kiosk-dual-screen.nix similarity index 100% rename from testing/kiosk-dual-screen.nix rename to testing/manual/kiosk-dual-screen.nix From 7c31677f9615c7e8119e2dcac4f03c3a491ebc8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Tue, 19 Nov 2024 16:16:27 +0200 Subject: [PATCH 12/17] Mention the correct subject in comments --- testing/integration/kiosk-screen-resolution.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/testing/integration/kiosk-screen-resolution.nix b/testing/integration/kiosk-screen-resolution.nix index 1433dd90..3c6ddc5b 100644 --- a/testing/integration/kiosk-screen-resolution.nix +++ b/testing/integration/kiosk-screen-resolution.nix @@ -142,7 +142,7 @@ pkgs.nixosTest { with TestCase("kiosk gracefully responds to screen and mode changes") as t,\ tempfile.TemporaryDirectory() as d: xrandr("--mode 640x480") - time.sleep(3) # give controller time to resize + time.sleep(3) # give kiosk time to resize machine.screenshot(d + "/screen1.png") screen1 = Image.open(d + "/screen1.png") @@ -150,7 +150,7 @@ pkgs.nixosTest { # note: must have the same aspect ratio as the initial resolution xrandr("--mode 800x600") - time.sleep(3) # give controller time to resize + time.sleep(3) # give kiosk time to resize machine.screenshot(d + "/screen2.png") screen2 = Image.open(d + "/screen2.png") @@ -165,7 +165,7 @@ pkgs.nixosTest { xrandr("--off") xrandr("--mode 640x480") - time.sleep(3) # give controller time to resize + time.sleep(3) # give kiosk time to resize machine.screenshot(d + "/screen3.png") screen3 = Image.open(d + "/screen3.png") From 507f0d9e6b5e58d2404cc412367150f9b6b1e2da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Tue, 19 Nov 2024 16:46:12 +0200 Subject: [PATCH 13/17] Ensure proper rendering of test grid in Firefox --- testing/integration/kiosk-screen-resolution.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/testing/integration/kiosk-screen-resolution.nix b/testing/integration/kiosk-screen-resolution.nix index 3c6ddc5b..cf8b6f38 100644 --- a/testing/integration/kiosk-screen-resolution.nix +++ b/testing/integration/kiosk-screen-resolution.nix @@ -112,6 +112,7 @@ pkgs.nixosTest {