diff --git a/config/plasma/applications/ekklesia-wonderink.desktop b/config/plasma/applications/ekklesia-wonderink.desktop index 56c7c4e..6dc5635 100644 --- a/config/plasma/applications/ekklesia-wonderink.desktop +++ b/config/plasma/applications/ekklesia-wonderink.desktop @@ -9,7 +9,16 @@ Terminal=false Categories=Education; StartupNotify=true # How the panel knows that the window on screen belongs to this launcher. -# Chrome derives an app id from the --app URL, and this must match it -# exactly or the launcher and the window become two separate buttons. -StartupWMClass=chrome-app.wonderink.org__-Default +# +# It used to be chrome-app.wonderink.org__-Default, which is the app id +# Chrome derives from an --app URL. Wonder Ink is no longer a Chrome +# window: it is the classroom's own tabbed shell, which tells Wayland its +# app id by naming this desktop entry. Read back from KWin on a running +# session — class=[ekklesia-wonderink], desktopFile=[ekklesia-wonderink] — +# rather than assumed, because a mismatch here is not an error anywhere, +# it is silently two buttons on the panel instead of one. +StartupWMClass=ekklesia-wonderink +# One window, and pressing the panel button again brings it forward rather +# than starting a second one. The shell enforces this itself as well: two +# processes on one Chromium profile directory would fight over its lock. X-GNOME-SingleWindow=true diff --git a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js index 4f2766c..660fb39 100644 --- a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js +++ b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js @@ -73,12 +73,18 @@ leftGap.writeConfig("expanding", true); // story. This applet is the reason the classroom stops writing a dock. var tasks = panel.addWidget("org.kde.plasma.icontasks"); tasks.currentConfigGroup = ["General"]; -// The DVD line is rewritten by classroom-provision to match the machine: -// a room with no optical drive has it removed, so no teacher is offered a -// disc player that cannot exist. Between the marker comments, and nowhere -// else, so the edit is a line replacement and not a parse. +// Two of these three lines are rewritten by classroom-desktop-layout to +// match the machine, and each is marked so the edit is a line replacement +// and not a parse. +// +// The disc player follows the hardware: a room with no optical drive has +// it removed, so no teacher is offered a player that cannot exist. +// +// Wonder Ink follows WONDERINK_ENABLED in device.conf: a device that is +// not a classroom — the laptop that goes out with a volunteer — has no +// lesson application, and the ordinary browser is unaffected either way. tasks.writeConfig("launchers", [ - "applications:ekklesia-wonderink.desktop", + /* CLASSROOM-WONDERINK-LAUNCHER */ "applications:ekklesia-wonderink.desktop", "applications:google-chrome.desktop", /* CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop" ].join(",")); diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index b7bf96b..ca184bb 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -25,6 +25,25 @@ container lab. See [../classroom-v2-experiment.md](../classroom-v2-experiment.md | `v2-both-apps` | both applications, one button each, from the packaged install | | `labelled-tasks` | the labelled task manager, evaluated and rejected: it shows changing page titles rather than application names | +## Wonder Ink with tabs + +0.2.1. A teacher prepares a lesson across several tabs, and none of a +browser's other furniture is on the screen. + +| | | +| --- | --- | +| `wonderink-tabs` | `[ Lesson ] [ Video ] [ Activity ]` — the second and third opened by the lesson itself, one through `target=_blank` and one through `window.open` | +| `wonderink-one-tab` | one tab, and no strip at all: the room looks exactly like the application this replaced | +| `wonderink-tab-closed` | the middle tab closed; the other two carry on | +| `wonderink-fullscreen` | a lesson's video over the whole display, panel and tab strip included | +| `wonderink-after-fullscreen` | and back, maximised above the bar, tabs intact — the bug this caught left the window 200 pixels wide in the corner | +| `wonderink-context-menu` | the whole of the right-click menu: back, forward, reload, print. No view-source, no save-page, no new *window* | +| `wonderink-print-dialog` | a lesson calling `window.print()`, landing on the room's own Elmer queue | +| `wonderink-and-browser` | the point of the split, in one picture: Chrome in front with its address bar, Wonder Ink a separate button beside it | +| `wonderink-autoplay`, `wonderink-autoplay-control` | the Present-view autoplay failure reproduced and then not reproduced: an unmuted video started by a script with no gesture, blocked at Chrome's default and playing as shipped | +| `wonderink-packaged` | the packaged shell against the real app.wonderink.org, launched from the `.deb` on a rebooted machine | +| `wonderink-disabled` | `WONDERINK_ENABLED=false` — no lesson button, the browser and the disc player where they were | + ## Parent Call | | | diff --git a/docs/screenshots/wonderink-after-fullscreen.png b/docs/screenshots/wonderink-after-fullscreen.png new file mode 100644 index 0000000..f378b20 Binary files /dev/null and b/docs/screenshots/wonderink-after-fullscreen.png differ diff --git a/docs/screenshots/wonderink-and-browser.png b/docs/screenshots/wonderink-and-browser.png new file mode 100644 index 0000000..7efb495 Binary files /dev/null and b/docs/screenshots/wonderink-and-browser.png differ diff --git a/docs/screenshots/wonderink-autoplay-control.png b/docs/screenshots/wonderink-autoplay-control.png new file mode 100644 index 0000000..d833c66 Binary files /dev/null and b/docs/screenshots/wonderink-autoplay-control.png differ diff --git a/docs/screenshots/wonderink-autoplay.png b/docs/screenshots/wonderink-autoplay.png new file mode 100644 index 0000000..06f84a5 Binary files /dev/null and b/docs/screenshots/wonderink-autoplay.png differ diff --git a/docs/screenshots/wonderink-context-menu.png b/docs/screenshots/wonderink-context-menu.png new file mode 100644 index 0000000..548f99e Binary files /dev/null and b/docs/screenshots/wonderink-context-menu.png differ diff --git a/docs/screenshots/wonderink-disabled.png b/docs/screenshots/wonderink-disabled.png new file mode 100644 index 0000000..d7b2747 Binary files /dev/null and b/docs/screenshots/wonderink-disabled.png differ diff --git a/docs/screenshots/wonderink-fullscreen.png b/docs/screenshots/wonderink-fullscreen.png new file mode 100644 index 0000000..8abd657 Binary files /dev/null and b/docs/screenshots/wonderink-fullscreen.png differ diff --git a/docs/screenshots/wonderink-one-tab.png b/docs/screenshots/wonderink-one-tab.png new file mode 100644 index 0000000..d8f0cc8 Binary files /dev/null and b/docs/screenshots/wonderink-one-tab.png differ diff --git a/docs/screenshots/wonderink-packaged.png b/docs/screenshots/wonderink-packaged.png new file mode 100644 index 0000000..0562e3f Binary files /dev/null and b/docs/screenshots/wonderink-packaged.png differ diff --git a/docs/screenshots/wonderink-print-dialog.png b/docs/screenshots/wonderink-print-dialog.png new file mode 100644 index 0000000..6cb5d5f Binary files /dev/null and b/docs/screenshots/wonderink-print-dialog.png differ diff --git a/docs/screenshots/wonderink-tab-closed.png b/docs/screenshots/wonderink-tab-closed.png new file mode 100644 index 0000000..2a8e062 Binary files /dev/null and b/docs/screenshots/wonderink-tab-closed.png differ diff --git a/docs/screenshots/wonderink-tabs.png b/docs/screenshots/wonderink-tabs.png new file mode 100644 index 0000000..a529b74 Binary files /dev/null and b/docs/screenshots/wonderink-tabs.png differ diff --git a/docs/wonder-ink.md b/docs/wonder-ink.md index ed13125..b5667b4 100644 --- a/docs/wonder-ink.md +++ b/docs/wonder-ink.md @@ -1,6 +1,155 @@ -# Wonder Ink: Present view video +# Wonder Ink -## The observation +Wonder Ink is a lesson application, not a browser: its own window, its own +profile, and no way from it into the rest of the web. + +Since 0.2.1 it has tabs. + +## Why it stopped being Chrome + +It used to be `google-chrome --app=https://app.wonderink.org/`. App mode +gives exactly the window a classroom wants — no tab strip, no address bar, +reads as a program — and teacher feedback then found the thing app mode +takes away is the thing they need. A lesson is prepared across several +tabs: the lesson, its video, the activity. + +Chrome cannot be asked for "tabs but no omnibox". App mode has no tabs. A +normal window has tabs and an address bar, and no policy, flag or +extension removes the address bar from it. Tabbed web apps come closest, +need a `display_override` in a manifest on a site that is not ours, and +push off-origin links out into a normal browser window — which is the +whole of what this design exists to prevent. + +So the tab strip is the classroom's own, in `classroom-wonderink`, and the +engine is Chromium either way. + +## Why QtWebEngine + +`python3-pyqt6.qtwebengine`, from Debian, updated with the rest of the +system. + +The usual and correct objection is codecs, so it was measured before it +was chosen. On Debian 13, QtWebEngine 6.8.2 (Chromium 122): + +| | | +| --- | --- | +| `canPlayType` H.264, AAC, MP3, VP8, VP9 | all "probably" | +| `MediaSource.isTypeSupported` avc1, mp4a, vp9 | all true | +| a 720p H.264/AAC file | loaded, played to the end, 1280×720 | +| `app.wonderink.org` | loads; the sign-in is an ordinary email and password form, not a Google flow that would mind an embedded browser | + +Two further things the shell does for the same reason: + +* **The user agent** has QtWebEngine's own token stripped, leaving a plain + Chrome string. The engine really is that Chromium, and a site gating on + the UA should not see something it has never heard of. +* **Widevine** is handed to it from Chrome's own bundle when Chrome is + installed, which on a classroom machine it always is. A lesson whose + video service wants DRM is not stopped by the engine change. Absent, the + flag is not passed and nothing else is affected. + +## What a teacher gets + + [ Lesson ] [ Video ] [ Activity ] + +The strip appears only when there is more than one tab, so a room with one +lesson open looks exactly like the application this replaced. Each tab has +a close button. Closing the last one closes Wonder Ink; closing any other +leaves the rest alone. + +A tab can only come from the lesson: a link the site opens in a new +window, a middle click, a Ctrl+click, "open in a new tab" in the menu. +There is no address bar, no search, and no button that makes a blank tab. +That is the seam the whole design turns on — Wonder Ink can take a teacher +anywhere it needs to, including a video CDN nobody told us about, and a +teacher cannot take themselves anywhere. + +**There is deliberately no allowlist of domains.** A list would have to be +right about every service a lesson might ever embed, and the day it is +wrong a teacher is standing in front of a class. + +The right-click menu is built rather than filtered, so a future Qt cannot +grow view-source or save-page back into it without somebody noticing in +the code: back, forward, reload, open in a new tab when there is a link, +cut/copy/paste in a text field, print. + +Keys: Ctrl+W closes a tab, Alt+Left and Alt+Right navigate, Ctrl+P prints, +Ctrl+Tab switches. Ctrl+T, Ctrl+N and Ctrl+L are deliberately unbound. + +## What stayed the same + +* **A separate profile from the ordinary browser.** A teacher's Wonder Ink + sign-in and the room's shared YouTube account are different sessions in + different storage that cannot reach each other. The browser is still + Chrome and nothing in this change touches it. +* **Cookies live only as long as the window.** This is what the Chrome + policy's `CookiesSessionOnlyForUrls` did for wonderink.org, and it is + the reason closing Wonder Ink ends the teacher's session rather than + leaving the next person signed in as the last one. The whole profile + rather than one domain now, because this profile only ever goes where a + lesson sends it. If a room ever wants sign-ins to survive, it is the one + line `setPersistentCookiesPolicy` in `classroom-wonderink`. +* **The old Chrome profile is left where it is**, at + `~/.local/share/ekklesia-classroom/wonderink`. The new one is + `wonderink-shell` beside it, so rolling back to 0.2.0 finds the old one + intact. +* **One scale for the room.** Chrome has to be handed `UI_SCALE` because + it was measured not to follow the compositor on Wayland. Qt does follow + it — measured at `UI_SCALE=2.0`, where a Qt client on that session + reports `devicePixelRatio 2.0` without being told anything — so Wonder + Ink is deliberately *not* told, or the scale would be applied twice. + +## Turning it off + +One future device is a laptop that will not have Wonder Ink at all. + + WONDERINK_ENABLED=false + +in `/etc/ekklesia-classroom/device.conf`, then +`sudo classroom-desktop-layout` and a logout. The launcher comes off the +panel, the command refuses to start and says why, and nothing Wonder Ink +owns runs. The browser, Parent Call and the disc player are untouched, and +the panel stays balanced because the applications are centred rather than +placed. + +The setting is read the same way the disc player's line is written: one +marked line in the packaged layout, edited by `classroom-desktop-layout`. +Absent, it means yes — every room that has never heard of the setting is a +room with Wonder Ink. + +## Present view video + +**Fixed in 0.2.1.** The investigation below is kept because it is what +found the cause, and because the second and third candidates in it were +never ruled out and may still matter. + +Candidate 1 — the autoplay gesture chain — is the one the new shell can +act on, and does. Chrome refuses to begin audible playback that no click +asked for, and in Chrome the only lever was `AutoplayAllowlist` on the +policy that governs the ordinary browser as well; allowing autoplay +everywhere on a classroom machine was a real change and was deliberately +not made on a guess. + +Wonder Ink is no longer governed by that policy. Its profile is Wonder +Ink and nothing else, so `PlaybackRequiresUserGesture` is turned off for +that profile alone and reaches no other page in the room. The ordinary +browser is a separate application with a separate policy and is not +touched. + +Measured both ways in the lab, with a page that starts an *unmuted* video +from a script with no user gesture — which is what Present view does: + +| | | +| --- | --- | +| the setting at Chrome's default | `AUTOPLAY BLOCKED: NotAllowedError` — the exact error predicted below | +| the setting as shipped | `AUTOPLAY OK muted=false`, video playing | + +That is the lab reproducing the failure and then not reproducing it. It +is **not** confirmation that Present view itself is fixed, because the +lab has no Wonder Ink account: if Present view still stops after this, +candidates 2 and 3 below are where to look next. + +### The observation On the classroom endpoint: @@ -9,7 +158,7 @@ On the classroom endpoint: - The **same** videos in Wonder Ink's **Present** view stop immediately when Play is pressed. -## What this rules out +### What this rules out The system's video capability is fine. The same video, the same host, the same browser and the same codecs work a click earlier in the same @@ -22,7 +171,7 @@ not fix Present view. Nothing in the system was changed for this. -## The decisive test +### The decisive test Run it once at the endpoint. This separates "Wonder Ink" from "the classroom's Chrome configuration" in one step: @@ -47,11 +196,11 @@ sudo mv /etc/opt/chrome/policies.off /etc/opt/chrome/policies rm -rf /tmp/plain-chrome ``` -## If it turns out to be this end +### If it turns out to be this end Two candidates, in order of likelihood. **Neither has been applied.** -### 1. The autoplay gesture chain +#### 1. The autoplay gesture chain Chrome refuses to start audible playback without a user gesture. If Present view starts the player programmatically after its own view @@ -82,7 +231,12 @@ installed binary. It has deliberately **not** been added, because allowing autoplay everywhere on a classroom machine is a real change and should follow evidence, not a guess. -### 2. A popup window that sway tiles +*Since 0.2.1 this no longer applies to Wonder Ink, which is not a Chrome +window any more and allows its own lesson to start a video without +changing anything about the browser. See the top of this section. It +still applies to the ordinary browser, where nothing has changed.* + +#### 2. A popup window that sway tiles sway tiles new windows rather than floating them. If Present view opens a second browser window, it gets a tiled geometry the site did not @@ -96,7 +250,7 @@ swaymsg -t get_tree | grep -c google-chrome More than one Chrome window means this is worth pursuing. -### 3. Not the classroom policies +#### 3. Not the classroom policies For completeness, so nobody re-audits them: the managed policy in `/etc/opt/chrome/policies/managed/` sets first-run, sign-in, password diff --git a/lib/classroom_config.py b/lib/classroom_config.py index 8a39ebe..bb2b578 100644 --- a/lib/classroom_config.py +++ b/lib/classroom_config.py @@ -63,6 +63,16 @@ MQTT_HOST=10.40.0.2 MQTT_PORT=1883 MQTT_USERNAME= MQTT_PASSWORD= + +# Whether this device has Wonder Ink at all. +# +# Every classroom does. A laptop that goes out with a volunteer does not, +# and a room that is offered a lesson application it has no licence for is +# a button that can only disappoint. +# +# false takes the launcher off the panel and stops the command starting. +# The ordinary browser, Parent Call and everything else are untouched. +WONDERINK_ENABLED=true """ @@ -104,6 +114,19 @@ AUDIO_OUTPUT=auto # from the room, on purpose. UI_SCALE=1.0 """, + ), + ( + "WONDERINK_ENABLED", + '''# Whether this device has Wonder Ink at all. +# +# Every classroom does. A laptop that goes out with a volunteer does not, +# and a room that is offered a lesson application it has no licence for is +# a button that can only disappoint. +# +# false takes the launcher off the panel and stops the command starting. +# The ordinary browser, Parent Call and everything else are untouched. +WONDERINK_ENABLED=true +''', ), ( "MQTT_ENABLED", diff --git a/packaging/debian/control.template b/packaging/debian/control.template index c17cca7..26e73ca 100644 --- a/packaging/debian/control.template +++ b/packaging/debian/control.template @@ -4,7 +4,7 @@ Section: misc Priority: optional Architecture: amd64 Maintainer: Ekklesia -Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt +Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6 Recommends: vlc Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup Description: Ekklesia managed classroom environment @@ -28,6 +28,14 @@ Description: Ekklesia managed classroom environment draws its own loading cover and control bar as layer-shell surfaces. They are no longer here for a dock. . + python3-pyqt6.qtwebengine is Wonder Ink. Teachers prepare a lesson across + several tabs and Chrome cannot be asked for tabs without an address bar, + so the tab strip is the classroom's and the engine is Debian's Chromium. + Measured on Debian 13 before it was chosen: H.264 and AAC both decode, + which is the usual and correct objection to QtWebEngine and is not true + of this build. Chrome stays exactly where it was, for the ordinary + browser. + . The Conflicts line is deliberate. A welcome tour, a software centre and a phone pairing daemon are all things plasma-desktop recommends and a room a teacher walks into should not have. diff --git a/scripts/classroom-desktop-layout b/scripts/classroom-desktop-layout index 775becb..9251642 100755 --- a/scripts/classroom-desktop-layout +++ b/scripts/classroom-desktop-layout @@ -2,9 +2,22 @@ """Match the classroom's panel to the machine it is on. -There is exactly one thing about the bar that depends on hardware: a room -with no optical drive should not be offered a disc player. This edits the -one marked line in the installed layout, and nothing else. +Two of the bar's launchers depend on the machine rather than on the +release, and this edits the marked line for each of them in the installed +layout, and nothing else. + + the disc player a room with no optical drive should not be offered + one. The machine is asked; nothing is remembered. + + Wonder Ink WONDERINK_ENABLED in device.conf. One future device + is a laptop that will not have Wonder Ink at all, + and a device that is not a classroom should not + carry a lesson application it cannot use. + +The two are deliberately answered differently. A drive is something a +machine can be asked about, and asking beats remembering to edit a file +when a computer moves between rooms. A licence is not, so it is a +setting. Run before the display manager by ekklesia-classroom-layout.service, and again by udev when a drive is plugged in or pulled out. That ordering is @@ -20,9 +33,6 @@ it, so an edit races with it. The layout is a file the package owns and the shell only reads, and classroom-desktop-reset makes the shell read it again at the next login. -DVD_ENABLED is gone. A drive is something the machine can be asked about, -and asking is more reliable than remembering to edit a file when a -computer moves between rooms. """ import glob @@ -30,12 +40,24 @@ import os import re 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"), +) + +from classroom_config import enabled, read_device_config # noqa: E402 + LAYOUT = ( "/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop" "/contents/layouts/org.kde.plasma.desktop-layout.js" ) -MARKER = "/* CLASSROOM-DVD-LAUNCHER */" +DVD_MARKER = "/* CLASSROOM-DVD-LAUNCHER */" +DVD_ENTRY = "ekklesia-dvd.desktop" + +WONDERINK_MARKER = "/* CLASSROOM-WONDERINK-LAUNCHER */" +WONDERINK_ENTRY = "ekklesia-wonderink.desktop" # The kernel names optical drives sr0, sr1, ... regardless of bus. Both # views are checked: /dev is what anything opening the drive will use, and @@ -62,10 +84,11 @@ def has_optical_drive(): return bool(nodes and blocks) -def rewrite(text, wanted): - """Comment the DVD launcher in or out, idempotently.""" +def rewrite(text, marker, entry, wanted): + """Comment one marked launcher in or out, idempotently.""" line = re.compile( - r"^(\s*)(//\s*)?(" + re.escape(MARKER) + r'\s*"applications:ekklesia-dvd\.desktop")', + r"^(\s*)(//\s*)?(" + re.escape(marker) + + r'\s*"applications:' + re.escape(entry) + r'")', re.M, ) @@ -76,6 +99,20 @@ def rewrite(text, wanted): return line.sub(swap, text) +def wonderink_wanted(): + """WONDERINK_ENABLED, defaulting to true. + + A classroom that has never heard of the setting is a classroom, so the + absent case is the one every existing room is in and it has to mean + yes. Said out loud for the same reason the drive is. + """ + config = read_device_config() + wanted = enabled(config, "WONDERINK_ENABLED", default=True) + print(f"classroom-desktop-layout: WONDERINK_ENABLED " + f"[{config.get('WONDERINK_ENABLED', 'unset')}]") + return wanted + + def main(): path = sys.argv[1] if len(sys.argv) > 1 else LAYOUT @@ -86,17 +123,24 @@ def main(): print(f"classroom-desktop-layout: {error}", file=sys.stderr) return 0 - if MARKER not in before: + if DVD_MARKER not in before: print("classroom-desktop-layout: no DVD marker in the layout", file=sys.stderr) return 0 - wanted = has_optical_drive() - after = rewrite(before, wanted) + after = rewrite(before, DVD_MARKER, DVD_ENTRY, has_optical_drive()) + + if WONDERINK_MARKER in before: + after = rewrite( + after, WONDERINK_MARKER, WONDERINK_ENTRY, wonderink_wanted() + ) + else: + print("classroom-desktop-layout: no Wonder Ink marker in the layout", + file=sys.stderr) if after == before: - print(f"classroom-desktop-layout: DVD launcher already " - f"{'present' if wanted else 'absent'}") + print("classroom-desktop-layout: the layout already matches " + "this machine") return 0 # Replace the file rather than edit in place, so a full disk or a @@ -111,8 +155,7 @@ def main(): print(f"classroom-desktop-layout: {error}", file=sys.stderr) return 1 - print(f"classroom-desktop-layout: DVD launcher " - f"{'added' if wanted else 'removed'}") + print("classroom-desktop-layout: the layout now matches this machine") return 0 diff --git a/scripts/classroom-wonderink b/scripts/classroom-wonderink index 3c25cb6..d299ef1 100755 --- a/scripts/classroom-wonderink +++ b/scripts/classroom-wonderink @@ -1,49 +1,625 @@ -#!/bin/bash -set -euo pipefail +#!/usr/bin/env python3 -# Wonder Ink as an application, not a tab. +"""Wonder Ink: an application with tabs, and nothing else a browser has. + +WHY THIS IS NOT CHROME ANY MORE. + +Wonder Ink used to be `google-chrome --app=`, which gave exactly the +window a classroom wants — no tab strip, no address bar, reads as a +program — and teachers then told us the thing app mode takes away is the +thing they need. A lesson is prepared across several tabs: the lesson, +its video, the activity. + +Chrome cannot be asked for "tabs but no omnibox". App mode has no tabs; +a normal window has tabs and an address bar, and there is no policy, +flag or extension that removes the address bar from it. Tabbed web apps +come closest and need a manifest on the site, which is not ours, and +they push off-origin links out into a normal browser window, which is +the whole of what we are trying to avoid. + +So the tab strip is ours and the engine is Chromium either way. This is +QtWebEngine, which Debian packages, updates with the rest of the system +and — measured on Debian 13, because the usual objection to it is +codecs — builds with H.264 and AAC: a 720p H.264 file decoded and played +to the end in the lab, and MediaSource reports avc1, mp4a and vp9 all +supported. app.wonderink.org itself loads and its sign-in is an ordinary +email and password form, not a Google flow that would mind an embedded +browser. + +WHAT A TEACHER GETS. + + [ Lesson ] [ Video ] [ Activity ] + +Tabs appear when there is more than one, each with a close button, and +nothing else: no address bar, no search, no button that makes a new tab. +A tab can only come from the lesson itself — a link the site opens in a +new window, a middle click, a Ctrl+click. That is the seam this whole +design turns on: Wonder Ink can take a teacher anywhere it needs to, +including a video CDN it has never told us about, and a teacher cannot +take themselves anywhere. + +There is no allowlist of domains, on purpose. A list would have to be +right about every service a lesson might ever embed, and the day it is +wrong a teacher is standing in front of a class. + +WHAT STAYS THE SAME. + +A separate profile from the ordinary browser, so a teacher's Wonder Ink +sign-in and the room's shared YouTube account never meet. Cookies live +only as long as the window, which is what the Chrome policy's +CookiesSessionOnlyForUrls did for wonderink.org and is the reason +closing Wonder Ink ends the teacher's session. Nothing here touches the +ordinary browser's profile or Chrome's policies. +""" + +import os +import sys +from pathlib import Path + +sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") +sys.path.insert( + 0, + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"), +) + +from classroom_config import enabled, read_device_config # noqa: E402 + +HOME_PAGE = "https://app.wonderink.org/" + +DATA_HOME = Path( + os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share" +) +CACHE_HOME = Path( + os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache" +) + +# Not the old Chrome directory. Two engines' state in one directory is a +# thing nobody can reason about later, and the Chrome profile is left +# where it is so that a rollback to the previous release finds it intact. +PROFILE = DATA_HOME / "ekklesia-classroom" / "wonderink-shell" +CACHE = CACHE_HOME / "ekklesia-classroom" / "wonderink-shell" + +# Widevine, if this machine has it. # -# Two things make it one. It runs in Chrome's app mode, so the window has -# no tab strip and no address bar and reads as a program. And it runs in -# its own Chrome profile, so the teacher signed into Wonder Ink and the -# room's shared YouTube account are separate sessions that cannot reach -# each other: -# -# * closing this window ends the Wonder Ink session, because the -# wonderink.org cookies are session-only by policy and this profile's -# browser process exits with the window; -# * a Google sign-in performed here cannot be used to re-enter the -# previous teacher's Wonder Ink account from the ordinary browser, -# because the ordinary browser's Google session is in a different -# profile; -# * and the church YouTube login in the ordinary browser survives all of -# it, because nothing here touches that profile. -# -# The machine-wide policy in /etc/opt/chrome/policies applies to every -# profile, so this window is governed by exactly the same rules as the -# browser. +# Chrome is installed on every classroom machine for the ordinary +# browser, and it brings a Widevine module with it. QtWebEngine will use +# that module if it is told where it is, so a lesson whose video service +# happens to want DRM is not stopped by the engine change. Absent — on a +# machine with no Chrome — the flag is simply not passed and everything +# that does not need Widevine is unaffected. +WIDEVINE = Path( + "/opt/google/chrome/WidevineCdm/_platform_specific/linux_x64/libwidevinecdm.so" +) -HOME_PAGE='https://app.wonderink.org/' +# Has to be in the environment before QtWebEngine starts, which is why it +# is here rather than further down with the rest of the setup. +if WIDEVINE.exists(): + flags = os.environ.get("QTWEBENGINE_CHROMIUM_FLAGS", "") + os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = ( + f"{flags} --widevine-path={WIDEVINE}".strip() + ) -# One number for the whole room. Chrome on Wayland was measured not to -# follow the compositor's output scale on its own, so the 4K television -# got a browser at half the size of everything around it. Forcing the -# same number the display is scaled by is a statement of the same fact, -# not a second scaling: Chrome replaces its detected scale with this -# rather than multiplying by it. -SCALE="$(/usr/local/bin/classroom-ui-scale 2>/dev/null || echo 1)" -PROFILE="${XDG_DATA_HOME:-$HOME/.local/share}/ekklesia-classroom/wonderink" +from PyQt6.QtCore import QEvent, Qt, QUrl # noqa: E402 +from PyQt6.QtGui import QAction, QGuiApplication, QKeySequence # noqa: E402 +from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402 +from PyQt6.QtPrintSupport import QPrintDialog, QPrinter # noqa: E402 +from PyQt6.QtWebEngineCore import ( # noqa: E402 + QWebEngineDownloadRequest, + QWebEnginePage, + QWebEngineProfile, + QWebEngineSettings, +) +from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402 +from PyQt6.QtWidgets import ( # noqa: E402 + QApplication, + QMainWindow, + QMenu, + QTabWidget, +) -mkdir -p "$PROFILE" +# One running Wonder Ink per session. A second QtWebEngine process on the +# same profile directory would fight the first for its storage lock, and +# the panel gives a teacher a button that is easy to press twice. +SOCKET = "ekklesia-wonderink" -exec google-chrome \ - --user-data-dir="$PROFILE" \ - --app="$HOME_PAGE" \ - --ozone-platform=wayland \ - --force-device-scale-factor="$SCALE" \ - --start-maximized \ - --no-first-run \ - --no-default-browser-check \ - --password-store=basic \ - --hide-crash-restore-bubble \ - "$@" +# How much of a page title fits on a tab before it stops being a label +# and starts being a paragraph. +TAB_TITLE = 22 + + +def say(message): + print(f"classroom-wonderink: {message}", file=sys.stderr) + + +class LessonPage(QWebEnginePage): + """A page that answers "open this somewhere else" with a new tab. + + Every route a site has to a second window arrives here: target=_blank, + window.open, a middle click, a Ctrl+click, and the context menu's open + in new tab. Chrome's app mode answered all of them with a bare popup + window; answering with a tab is the entire feature. + """ + + def __init__(self, profile, window): + super().__init__(profile, window) + self._window = window + + def createWindow(self, window_type): + background = ( + window_type + == QWebEnginePage.WebWindowType.WebBrowserBackgroundTab + ) + view = self._window.open_tab(focus=not background) + return view.page() + + def javaScriptConsoleMessage(self, level, message, line, source): + # A lesson's own console noise is not the classroom's business and + # would otherwise fill the journal on every page load. + pass + + +class LessonView(QWebEngineView): + """A page, and a right-click menu with nothing dangerous on it.""" + + def __init__(self, profile, window): + super().__init__(window) + self._window = window + self.setPage(LessonPage(profile, window)) + + def contextMenuEvent(self, event): + # Built rather than filtered. QtWebEngine's standard menu carries + # view-source, save-page and open-in-new-*window* entries, and a + # menu the classroom assembles itself cannot grow one of those + # back in a future Qt release without somebody noticing here. + menu = QMenu(self) + + for action in ( + QWebEnginePage.WebAction.Back, + QWebEnginePage.WebAction.Forward, + QWebEnginePage.WebAction.Reload, + ): + menu.addAction(self.page().action(action)) + + request = self.lastContextMenuRequest() + + if request is not None and not request.linkUrl().isEmpty(): + menu.addSeparator() + open_tab = self.page().action( + QWebEnginePage.WebAction.OpenLinkInNewTab + ) + open_tab.setText("Open in a new tab") + menu.addAction(open_tab) + + if request is not None and request.isContentEditable(): + menu.addSeparator() + for action in ( + QWebEnginePage.WebAction.Cut, + QWebEnginePage.WebAction.Copy, + QWebEnginePage.WebAction.Paste, + ): + menu.addAction(self.page().action(action)) + elif request is not None and request.selectedText(): + menu.addSeparator() + menu.addAction(self.page().action(QWebEnginePage.WebAction.Copy)) + + menu.addSeparator() + printing = QAction("Print…", menu) + printing.triggered.connect(self._window.print_current) + menu.addAction(printing) + + menu.popup(event.globalPos()) + + +class WonderInk(QMainWindow): + def __init__(self, profile): + super().__init__() + self._profile = profile + self._fullscreen_view = None + + self.setWindowTitle("Wonder Ink") + + self._tabs = QTabWidget(self) + self._tabs.setDocumentMode(True) + self._tabs.setTabsClosable(True) + self._tabs.setMovable(True) + # No corner widget. This is where a browser puts its new-tab + # button, and the classroom deliberately has nowhere to press. + self._tabs.setElideMode(Qt.TextElideMode.ElideRight) + self._tabs.tabCloseRequested.connect(self.close_tab) + self._tabs.currentChanged.connect(self._current_changed) + self.setCentralWidget(self._tabs) + + self._shortcuts() + + # ---- tabs ---------------------------------------------------------- + + def open_tab(self, url=None, focus=True): + view = LessonView(self._profile, self) + + view.titleChanged.connect(lambda t, v=view: self._retitle(v, t)) + view.iconChanged.connect(lambda i, v=view: self._reicon(v, i)) + view.page().fullScreenRequested.connect(self._fullscreen) + view.page().windowCloseRequested.connect( + lambda v=view: self.close_tab(self._tabs.indexOf(v)) + ) + # A lesson that calls window.print() itself. Measured in the lab: + # without this the button on the page does nothing at all, which + # is worse than no print button, because a teacher presses it and + # concludes the printer is broken. Bound to the page that asked + # rather than to whichever tab is in front. + view.page().printRequested.connect(lambda v=view: self.print_view(v)) + + index = self._tabs.addTab(view, "…") + + if url is not None: + view.load(QUrl(url)) + + if focus: + self._tabs.setCurrentIndex(index) + + self._show_tab_bar() + return view + + def close_tab(self, index): + if index < 0: + return + + view = self._tabs.widget(index) + + # The last tab is the application. Anything else and the lesson + # carries on in the tabs that are left, which is the behaviour a + # teacher expects and the reason tabs are worth having at all. + if self._tabs.count() == 1: + self.close() + return + + self._tabs.removeTab(index) + view.page().deleteLater() + view.deleteLater() + self._show_tab_bar() + + def _show_tab_bar(self): + # One tab is not a choice, so it is not drawn. A room with a + # single lesson open looks exactly like the application it + # replaced, and the strip appears the moment there is somewhere + # to switch to. + self._tabs.tabBar().setVisible(self._tabs.count() > 1) + + def _retitle(self, view, title): + index = self._tabs.indexOf(view) + + if index < 0: + return + + title = (title or "Wonder Ink").strip() + short = title if len(title) <= TAB_TITLE else title[: TAB_TITLE - 1] + "…" + self._tabs.setTabText(index, short) + self._tabs.setTabToolTip(index, title) + + if index == self._tabs.currentIndex(): + self.setWindowTitle(title) + + def _reicon(self, view, icon): + index = self._tabs.indexOf(view) + + if index >= 0: + self._tabs.setTabIcon(index, icon) + + def _current_changed(self, index): + view = self._tabs.widget(index) + + if view is not None: + self.setWindowTitle(view.title() or "Wonder Ink") + view.setFocus() + + def current(self): + return self._tabs.currentWidget() + + # ---- the keys a teacher might already know -------------------------- + + def _shortcuts(self): + def bind(sequence, handler): + action = QAction(self) + action.setShortcut(QKeySequence(sequence)) + action.setShortcutContext(Qt.ShortcutContext.WindowShortcut) + action.triggered.connect(handler) + self.addAction(action) + return action + + # Deliberately absent: Ctrl+T, Ctrl+N, Ctrl+L. There is no new + # tab, no new window and no address bar to focus. + bind(QKeySequence.StandardKey.Close, self._close_current) + bind(QKeySequence.StandardKey.Back, self._back) + bind(QKeySequence.StandardKey.Forward, self._forward) + bind(QKeySequence.StandardKey.Refresh, self._reload) + bind(QKeySequence.StandardKey.Print, self.print_current) + bind(QKeySequence.StandardKey.NextChild, self._next_tab) + bind(QKeySequence.StandardKey.PreviousChild, self._previous_tab) + bind("Ctrl+Shift+Tab", self._previous_tab) + + def _close_current(self): + self.close_tab(self._tabs.currentIndex()) + + def _back(self): + if self.current(): + self.current().back() + + def _forward(self): + if self.current(): + self.current().forward() + + def _reload(self): + if self.current(): + self.current().reload() + + def _next_tab(self): + count = self._tabs.count() + + if count > 1: + self._tabs.setCurrentIndex((self._tabs.currentIndex() + 1) % count) + + def _previous_tab(self): + count = self._tabs.count() + + if count > 1: + self._tabs.setCurrentIndex((self._tabs.currentIndex() - 1) % count) + + # ---- printing ------------------------------------------------------- + + def print_current(self): + self.print_view(self.current()) + + def print_view(self, view): + if view is None: + return + + printer = QPrinter(QPrinter.PrinterMode.HighResolution) + dialog = QPrintDialog(printer, self) + + if dialog.exec() != QPrintDialog.DialogCode.Accepted: + return + + # Qt's own dialog talks to CUPS, so a lesson prints to the room's + # queue the same way everything else in the classroom does. + view.print(printer) + + # ---- full screen ---------------------------------------------------- + + def _fullscreen(self, request): + # A lesson's video filling a classroom television is the point of + # the room, so this is always granted. The tab strip goes with it: + # a video is not a window a teacher needs chrome around. + request.accept() + + if request.toggleOn(): + if self.isFullScreen(): + return + + self._fullscreen_view = self.sender() + self._tabs.tabBar().setVisible(False) + self.showFullScreen() + return + + self._fullscreen_view = None + + # Clearing the one state bit, rather than asking for maximised. + # + # showMaximized() here was measured to leave the window about + # 200 pixels wide in the corner of the screen: it starts + # maximised and so has no ordinary geometry to come back to, and + # under KWin the fullscreen-to-maximised transition lands on + # that empty geometry. Turning off the fullscreen bit and leaving + # the maximised bit alone keeps the window it already had. + self.setWindowState( + self.windowState() & ~Qt.WindowState.WindowFullScreen + ) + + # Belt and braces for a window that was somehow neither. + if not self.isMaximized(): + self.showMaximized() + + self._show_tab_bar() + + def changeEvent(self, event): + # Leaving fullscreen by any route the page takes — its own button, + # Escape, a script — comes through here, so the tab strip comes + # back even when the request above is not what ended it. + super().changeEvent(event) + + if event.type() == QEvent.Type.WindowStateChange: + if not self.isFullScreen() and self._fullscreen_view is None: + self._show_tab_bar() + + +def downloads_to(): + """Somewhere a download can land without asking a teacher anything.""" + target = Path( + os.environ.get("XDG_DOWNLOAD_DIR") or Path.home() / "Downloads" + ) + + try: + target.mkdir(parents=True, exist_ok=True) + except OSError: + target = Path.home() + + return target + + +def on_download(download): + """Accept it, put it somewhere sensible, and say so on screen. + + Wonder Ink hands out leader guides and colouring pages, so a download + that silently does nothing is a lesson a teacher cannot print. There + is no downloads bar to click, so the notification is the feedback. + """ + target = downloads_to() + download.setDownloadDirectory(str(target)) + download.accept() + + name = download.downloadFileName() + + def finished(): + state = download.state() + + if state == QWebEngineDownloadRequest.DownloadState.DownloadCompleted: + notify("Saved", f"{name} is in {target}") + elif state == QWebEngineDownloadRequest.DownloadState.DownloadCancelled: + pass + else: + notify("Download failed", name) + + download.isFinishedChanged.connect(finished) + + +def notify(summary, body): + import subprocess + + try: + subprocess.Popen( + [ + "notify-send", "--app-name=Wonder Ink", + "--icon=ekklesia-wonderink", summary, body, + ] + ) + except OSError: + say(f"{summary}: {body}") + + +def plain_user_agent(profile): + """Chrome's user agent, without the token that says this is not it. + + QtWebEngine advertises "QtWebEngine/6.8.2 Chrome/122...", and a site + that gates on it — a sign-in page deciding whether it trusts the + browser, a video service picking a stream — sees something it has + never heard of. The engine really is that Chromium, so the honest + string is the one without the extra token. + """ + return " ".join( + part for part in profile.httpUserAgent().split() + if not part.startswith("QtWebEngine/") + ) + + +def raise_running_instance(): + """Ask an already-running Wonder Ink to come forward. True if it did.""" + socket = QLocalSocket() + socket.connectToServer(SOCKET) + + if not socket.waitForConnected(500): + return False + + socket.write(b"raise\n") + socket.waitForBytesWritten(500) + socket.disconnectFromServer() + return True + + +def main(): + config = read_device_config() + + # A device that has no Wonder Ink has no Wonder Ink. The panel does not + # offer it, so nothing should reach here; saying it out loud is for the + # technician who ran the command by hand and wants to know why. + if not enabled(config, "WONDERINK_ENABLED", default=True): + say("WONDERINK_ENABLED is false on this device; not starting") + return 0 + + if raise_running_instance(): + return 0 + + QGuiApplication.setDesktopFileName("ekklesia-wonderink") + app = QApplication(sys.argv) + app.setApplicationName("Wonder Ink") + # The name the panel matches a window to its launcher by. Wayland has + # no window class, so this is the app id, and it has to agree with + # StartupWMClass in ekklesia-wonderink.desktop exactly. + app.setDesktopFileName("ekklesia-wonderink") + + PROFILE.mkdir(parents=True, exist_ok=True) + CACHE.mkdir(parents=True, exist_ok=True) + + profile = QWebEngineProfile("wonderink", app) + profile.setPersistentStoragePath(str(PROFILE)) + profile.setCachePath(str(CACHE)) + # The behaviour the Chrome policy's CookiesSessionOnlyForUrls gave + # wonderink.org, kept: closing Wonder Ink ends the teacher's session, + # so the next person to walk into the room is not signed in as the + # last one. The whole profile rather than one domain, because this + # profile only ever goes where a lesson sends it. + profile.setPersistentCookiesPolicy( + QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies + ) + profile.setHttpUserAgent(plain_user_agent(profile)) + profile.downloadRequested.connect(on_download) + # A lesson is not allowed to put a desktop notification on a classroom + # television. The ordinary browser is told the same thing by policy. + profile.setNotificationPresenter(lambda notification: None) + + settings = profile.settings() + settings.setAttribute( + QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True + ) + # A lesson may start its own video. + # + # Chrome refuses to begin audible playback that no click asked for, + # and Wonder Ink's Present view was measured failing on exactly that: + # the same video plays a click earlier in the lesson view and stops + # dead when Present starts it, because the gesture from the Present + # click does not carry through the view transition. See + # docs/wonder-ink.md for the investigation. + # + # The fix was written down there and deliberately not applied, because + # in Chrome the only lever was AutoplayAllowlist on the policy that + # governs the ordinary browser as well. Here there is no such + # problem: this profile is Wonder Ink and nothing else, so allowing a + # lesson to start its own video allows exactly that and reaches no + # other page in the room. The browser is a separate application with + # a separate policy and is not touched. + settings.setAttribute( + QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, False + ) + settings.setAttribute( + QWebEngineSettings.WebAttribute.ScreenCaptureEnabled, False + ) + # No view-source, no developer tools, nothing that turns a lesson into + # a way out of the appliance. + settings.setAttribute( + QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True + ) + settings.setAttribute( + QWebEngineSettings.WebAttribute.PdfViewerEnabled, True + ) + + window = WonderInk(profile) + window.open_tab(HOME_PAGE) + # An ordinary size before the maximised one, so the window has + # something sensible to be if it is ever asked to stop being + # maximised. A window that has only ever been maximised has no + # geometry of its own and comes back as a stub. + window.resize(1280, 800) + window.showMaximized() + + # The other half of the single instance: a second press of the panel + # button connects here and the window comes forward instead of a + # second Wonder Ink starting on the same profile. + QLocalServer.removeServer(SOCKET) + server = QLocalServer(app) + server.listen(SOCKET) + + def come_forward(): + connection = server.nextPendingConnection() + connection.disconnectFromServer() + + if window.isMinimized(): + window.showNormal() + + window.raise_() + window.activateWindow() + + server.newConnection.connect(come_forward) + + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test-classroom-desktop-layout.sh b/tests/test-classroom-desktop-layout.sh index b98d34f..702e592 100755 --- a/tests/test-classroom-desktop-layout.sh +++ b/tests/test-classroom-desktop-layout.sh @@ -32,49 +32,103 @@ def check(label, condition, detail=""): WITH = '''tasks.writeConfig("launchers", [ - "applications:ekklesia-wonderink.desktop", + /* CLASSROOM-WONDERINK-LAUNCHER */ "applications:ekklesia-wonderink.desktop", "applications:google-chrome.desktop", /* CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop" ].join(","));''' + +def dvd(text, wanted): + return module.rewrite(text, module.DVD_MARKER, module.DVD_ENTRY, wanted) + + +def ink(text, wanted): + return module.rewrite( + text, module.WONDERINK_MARKER, module.WONDERINK_ENTRY, wanted + ) + + WITHOUT = WITH.replace(' /* CLASSROOM-DVD-LAUNCHER */', ' // /* CLASSROOM-DVD-LAUNCHER */') +NO_INK = WITH.replace(' /* CLASSROOM-WONDERINK-LAUNCHER */', + ' // /* CLASSROOM-WONDERINK-LAUNCHER */') print("1. the DVD launcher follows the hardware") -check("a machine with a drive keeps it", - module.rewrite(WITH, True) == WITH) +check("a machine with a drive keeps it", dvd(WITH, True) == WITH) check("a machine without a drive loses it", - module.rewrite(WITH, False) == WITHOUT, - module.rewrite(WITH, False)) -check("and gets it back when a drive appears", - module.rewrite(WITHOUT, True) == WITH) + dvd(WITH, False) == WITHOUT, dvd(WITH, False)) +check("and gets it back when a drive appears", dvd(WITHOUT, True) == WITH) check("removing twice changes nothing the second time", - module.rewrite(module.rewrite(WITH, False), False) == WITHOUT) + dvd(dvd(WITH, False), False) == WITHOUT) check("adding twice changes nothing the second time", - module.rewrite(module.rewrite(WITHOUT, True), True) == WITH) + dvd(dvd(WITHOUT, True), True) == WITH) print() -print("2. nothing else in the layout is touched") +print("2. Wonder Ink follows the device setting") +check("a device with Wonder Ink keeps it", ink(WITH, True) == WITH) +check("a device without it loses the launcher", + ink(WITH, False) == NO_INK, ink(WITH, False)) +check("and gets it back when the setting changes", + ink(NO_INK, True) == WITH) +check("turning it off twice changes nothing the second time", + ink(ink(WITH, False), False) == NO_INK) +# The line carries a trailing comma, so a comment that stopped short of it +# would leave the array with a hole in it. +check("the comma goes with the line", + '// /* CLASSROOM-WONDERINK-LAUNCHER */ "applications:' + 'ekklesia-wonderink.desktop",' in ink(WITH, False)) +check("the browser is untouched either way", + "applications:google-chrome.desktop" in ink(WITH, False)) +check("and so is the disc player", + 'CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop"' + in ink(WITH, False)) + +print() +print("3. the two settings are independent") +neither = ink(dvd(WITH, False), False) +check("a laptop with no drive and no Wonder Ink keeps only the browser", + neither.count("// /* CLASSROOM") == 2 + and "applications:google-chrome.desktop" in neither) +check("and comes all the way back", + ink(dvd(neither, True), True) == WITH) + +print() +print("4. nothing else in the layout is touched") other = WITH + "\npanel.addWidget(\"org.kde.plasma.digitalclock\");\n" check("a line without the marker is left alone", - module.rewrite(other, False).endswith('digitalclock");\n')) + dvd(other, False).endswith('digitalclock");\n')) check("the other launchers survive", - "ekklesia-wonderink.desktop" in module.rewrite(WITH, False) - and "google-chrome.desktop" in module.rewrite(WITH, False)) + "ekklesia-wonderink.desktop" in dvd(WITH, False) + and "google-chrome.desktop" in dvd(WITH, False)) print() -print("3. the real layout carries the marker") +print("5. the real layout carries both markers") layout = (root / "config/plasma/lookandfeel/contents/layouts" / "org.kde.plasma.desktop-layout.js").read_text() -check("the shipped layout has exactly one marker", - layout.count(module.MARKER) == 1) -check("and it is on the DVD launcher", - 'CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop"' in layout) +check("exactly one DVD marker on a launcher line", + layout.count(module.DVD_MARKER + ' "applications:') == 1) +check("exactly one Wonder Ink marker on a launcher line", + layout.count(module.WONDERINK_MARKER + ' "applications:') == 1) check("the shipped layout is one a drive-less machine can be given", - module.rewrite(layout, False) != layout) + dvd(layout, False) != layout) +check("and one a Wonder-Ink-less device can be given", + ink(layout, False) != layout) print() -print("4. a missing or unmarked layout is not an error") +print("6. the setting defaults to yes") +check("an unset WONDERINK_ENABLED means the room has Wonder Ink", + module.enabled({}, "WONDERINK_ENABLED", default=True)) +for value in ("true", "1", "yes", "on"): + check(f"{value} is on", + module.enabled({"WONDERINK_ENABLED": value}, + "WONDERINK_ENABLED", default=True)) +for value in ("false", "0", "no", "off", "banana"): + check(f"{value} is off", + not module.enabled({"WONDERINK_ENABLED": value}, + "WONDERINK_ENABLED", default=True)) + +print() +print("7. a missing or unmarked layout is not an error") with tempfile.TemporaryDirectory() as work: empty = Path(work) / "none.js" check("a layout that does not exist exits cleanly", diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh index 2a05aeb..ff759a1 100755 --- a/tests/test-deployment-support.sh +++ b/tests/test-deployment-support.sh @@ -80,7 +80,8 @@ print("4. secondary windows are the window manager's business") check("no window rule claims the browser", "google-chrome" not in rules) check("the browser asks for its own first window", "--start-maximized" in (root / "scripts/classroom-browser").read_text()) -check("Wonder Ink asks for the same", "--start-maximized" in +# Wonder Ink is no longer a Chrome window, so it asks in its own words. +check("Wonder Ink asks for the same", "showMaximized()" in (root / "scripts/classroom-wonderink").read_text()) check("the Chrome window arbiter is gone", not (root / "scripts/classroom-chrome-windows").exists()) check("and is not packaged", "classroom-chrome-windows" not in build) @@ -252,10 +253,17 @@ check("2.0 is inside the range a room may ask for", "UI_SCALE_MAX = 3.0" in config_module) check("the browser is told the same number", '--force-device-scale-factor="$SCALE"' in browser) -check("so is Wonder Ink", - '--force-device-scale-factor="$SCALE"' in wonderink) -check("both ask the one command for it", - "classroom-ui-scale" in browser and "classroom-ui-scale" in wonderink) +check("and asks the one command for it", + "classroom-ui-scale" in browser) +# Wonder Ink is deliberately NOT told. Chrome on Wayland was measured not +# to follow the compositor's output scale, which is why the browser has to +# be handed the number; Qt does follow it. Measured in the lab with +# UI_SCALE=2.0: kscreen-doctor reports the output at scale 2, and a Qt +# client on that session reports devicePixelRatio 2.0 without being told +# anything. Forcing it as well would apply the scale twice. +check("Wonder Ink is not told twice", + "force-device-scale-factor" not in wonderink + and "QT_SCALE_FACTOR" not in wonderink) check("the clock is sized by the bar, not by a number", 'autoFontAndSize", true' in layout and "fontSize" not in layout) diff --git a/tools/lab/converged-check b/tools/lab/converged-check index f99eef9..5e49aa5 100755 --- a/tools/lab/converged-check +++ b/tools/lab/converged-check @@ -288,6 +288,53 @@ check "the old daemon cannot be activated" 1 \ "$(in_lab 'grep -c "^Exec=/bin/false" /usr/local/share/dbus-1/services/fr.emersion.mako.service 2>/dev/null || true')" check "and Plasma owns the notification service" plasmashell "$(notification_owner)" +section "Wonder Ink is an application with tabs" +check "the shell is the classroom's, not a Chrome app window" 0 \ + "$(in_lab 'grep -c "^exec google-chrome" /usr/local/bin/classroom-wonderink || true')" +check "and it is the Qt WebEngine one" 1 \ + "$(in_lab 'grep -c "^from PyQt6.QtWebEngineWidgets import" /usr/local/bin/classroom-wonderink || true')" +check "the engine is installed" ok \ + "$(in_lab 'QT_QPA_PLATFORM=offscreen python3 -c "import PyQt6.QtWebEngineWidgets; print(\"ok\")" 2>/dev/null')" +# The panel matches a window to its launcher by this, and a mismatch is +# not an error anywhere — it is silently two buttons instead of one. +check "the launcher names the shell's app id" ekklesia-wonderink \ + "$(in_lab 'sed -n "s/^StartupWMClass=//p" /usr/share/applications/ekklesia-wonderink.desktop')" +# Comments stripped first. The shell has a comment saying Ctrl+T is +# deliberately absent, and a check that cannot tell that from a binding +# is a check that fails for being right. +check "no code offers a new tab button or an address bar" 0 \ + "$(in_lab "grep -vE '^[[:space:]]*#' /usr/local/bin/classroom-wonderink | grep -cE 'QLineEdit|setCornerWidget|addressBar|bind\\(\"Ctrl\\+T' || true")" +# Separate from the ordinary browser, which is the whole reason the split +# exists: a teacher's Wonder Ink sign-in and the room's shared YouTube +# account must never be in one profile. +check "its profile is its own" own \ + "$(in_lab 'grep -q "wonderink-shell" /usr/local/bin/classroom-wonderink && echo own || echo shared')" +# And the Chrome profile is left where it is, so a rollback to 0.2.0 +# finds a teacher's Wonder Ink directory intact. +check "and Chrome's old one is not reused" 0 \ + "$(in_lab 'grep -c "ekklesia-classroom/wonderink\"" /usr/local/bin/classroom-wonderink || true')" +# The Present-view fix: a lesson may start its own video, and only this +# profile may. The browser's policy is not touched, which the next two +# checks are about. +check "a lesson may start its own video" 1 \ + "$(in_lab 'grep -c "PlaybackRequiresUserGesture, False" /usr/local/bin/classroom-wonderink || true')" +check "and the browser's policy still says nothing about autoplay" 0 \ + "$(in_lab 'grep -ci "autoplay" /etc/opt/chrome/policies/managed/ekklesia-classroom.json || true')" +check "the browser is still Chrome" 1 \ + "$(in_lab 'grep -c "^exec google-chrome" /usr/local/bin/classroom-browser || true')" +check "and the browser is untouched by any of this" 0 \ + "$(in_lab 'grep -ci "pyqt\|webengine" /usr/local/bin/classroom-browser || true')" + +section "Wonder Ink can be turned off per device" +check "device.conf carries the setting" 1 \ + "$(in_lab 'grep -c "^WONDERINK_ENABLED=" /etc/ekklesia-classroom/device.conf || true')" +check "and it defaults to on" true \ + "$(in_lab 'sed -n "s/^WONDERINK_ENABLED=//p" /etc/ekklesia-classroom/device.conf')" +check "the layout carries a marker the setting can switch" 1 \ + "$(in_lab 'grep -c "CLASSROOM-WONDERINK-LAUNCHER \*/ \"applications:ekklesia-wonderink.desktop\"" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')" +check "and it is switched on for this machine" 0 \ + "$(in_lab 'grep -c "// /\* CLASSROOM-WONDERINK-LAUNCHER" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')" + section "the room's own parts survived" check "parent call installed" 1 "$(in_lab 'ls /usr/local/bin/classroom-parent-call 2>/dev/null | wc -l')" check "parent call icon installed" 1 \