feat: give Wonder Ink tabs, and let a device say it has no Wonder Ink

Teachers prepare a lesson across several tabs — the lesson, its video,
the activity — and app mode is the one thing that cannot give them that.

Chrome cannot be asked for "tabs but no omnibox". App mode has no tabs; a
normal window has an address bar and no policy, flag or extension removes
it; tabbed web apps need 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 ours, and the
engine is Chromium either way.

QtWebEngine, from Debian, measured before it was chosen rather than
after. The usual and correct objection is codecs: on Debian 13 it reports
H.264, AAC, MP3, VP8 and VP9 all playable, MediaSource supports avc1,
mp4a and vp9, and a 720p H.264 file decoded and played to the end in the
lab. app.wonderink.org loads, and its sign-in is an ordinary email and
password form rather than a Google flow that would mind an embedded
browser. The user agent has the QtWebEngine token stripped so a site
gating on it sees the Chromium this actually is, and Widevine is handed
over from Chrome's own bundle when the machine has Chrome, which a
classroom always does.

    [ Lesson ] [ Video ] [ Activity ]

The strip appears only above one tab, so an ordinary lesson looks exactly
like the application it replaces. A tab can only come from the lesson —
target=_blank, window.open, a middle click, a Ctrl+click — and there is
no address bar, no search and no button that makes a blank tab. No
allowlist of domains either, on purpose: a list would have to be right
about every video service a lesson might ever embed.

Three bugs the lab caught and this fixes. A lesson calling window.print()
did nothing at all, which is worse than no print button because a teacher
concludes the printer is broken; printRequested is now bound to the page
that asked. Leaving fullscreen left the window 200 pixels wide in the
corner — a window that has only ever been maximised has no ordinary
geometry to return to — so the fullscreen bit is cleared rather than
maximised being re-requested. And the Present view video failure written
up in docs/wonder-ink.md is acted on at last: that investigation named
the autoplay gesture chain as the likeliest cause and declined to fix it,
because in Chrome the only lever governed the ordinary browser as well.
Here it does not — this profile is Wonder Ink and nothing else — so a
lesson may start its own video and no other page in the room may.
Reproduced both ways in the lab with an unmuted video started by a script
with no gesture: NotAllowedError at Chrome's default, playing as shipped.
That is not proof Present view is fixed; the lab has no Wonder Ink
account, and candidates 2 and 3 in that document are still open.

Measured on a rebooted machine, from the package: tabs open from a link
and from window.open, switch, and close; closing one leaves the others;
closing the last closes Wonder Ink; H.264 video plays and fills the
display in fullscreen and comes back maximised with its tabs; the print
dialog offers the room's own Elmer queue; a download lands in ~/Downloads;
back and forward work from a right-click menu that is built rather than
filtered; pressing the panel button twice raises one window rather than
starting a second on the same profile; KWin reports the window as
ekklesia-wonderink, which is one panel button and not two.

Separate from the browser as before, in its own profile, with cookies
that live only as long as the window — what the Chrome policy did for
wonderink.org, and the reason closing Wonder Ink ends a teacher's
session. Chrome's old profile directory is left untouched beside it, and
the browser's own policy is not edited by any of this.

Wonder Ink is now optional per device, through WONDERINK_ENABLED in
device.conf, because one future device is a laptop that will not have it.
It rides the mechanism the disc player already uses: one marked line in
the packaged layout, edited by classroom-desktop-layout. False takes the
launcher off the panel and makes the command refuse to start; the
browser, Parent Call and the disc player are untouched and the panel
stays balanced. Absent means yes, so every existing room is unchanged.

One scale, still. Chrome has to be handed UI_SCALE because it was
measured not to follow the compositor; Qt does follow it — devicePixelRatio
2.0 at UI_SCALE=2.0, measured — so Wonder Ink is deliberately not told,
or the room would get it twice.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-19 21:29:17 -05:00
co-authored by Claude Opus 5
parent 23dff0b7b4
commit de5025ad22
23 changed files with 1049 additions and 102 deletions
@@ -9,7 +9,16 @@ Terminal=false
Categories=Education; Categories=Education;
StartupNotify=true StartupNotify=true
# How the panel knows that the window on screen belongs to this launcher. # 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. # It used to be chrome-app.wonderink.org__-Default, which is the app id
StartupWMClass=chrome-app.wonderink.org__-Default # 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 X-GNOME-SingleWindow=true
@@ -73,12 +73,18 @@ leftGap.writeConfig("expanding", true);
// story. This applet is the reason the classroom stops writing a dock. // story. This applet is the reason the classroom stops writing a dock.
var tasks = panel.addWidget("org.kde.plasma.icontasks"); var tasks = panel.addWidget("org.kde.plasma.icontasks");
tasks.currentConfigGroup = ["General"]; tasks.currentConfigGroup = ["General"];
// The DVD line is rewritten by classroom-provision to match the machine: // Two of these three lines are rewritten by classroom-desktop-layout to
// a room with no optical drive has it removed, so no teacher is offered a // match the machine, and each is marked so the edit is a line replacement
// disc player that cannot exist. Between the marker comments, and nowhere // and not a parse.
// else, 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", [ tasks.writeConfig("launchers", [
"applications:ekklesia-wonderink.desktop", /* CLASSROOM-WONDERINK-LAUNCHER */ "applications:ekklesia-wonderink.desktop",
"applications:google-chrome.desktop", "applications:google-chrome.desktop",
/* CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop" /* CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop"
].join(",")); ].join(","));
+19
View File
@@ -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 | | `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 | | `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 ## Parent Call
| | | | | |
Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+162 -8
View File
@@ -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: On the classroom endpoint:
@@ -9,7 +158,7 @@ On the classroom endpoint:
- The **same** videos in Wonder Ink's **Present** view stop immediately - The **same** videos in Wonder Ink's **Present** view stop immediately
when Play is pressed. 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 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 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. 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 Run it once at the endpoint. This separates "Wonder Ink" from "the
classroom's Chrome configuration" in one step: 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 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.** 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 Chrome refuses to start audible playback without a user gesture. If
Present view starts the player programmatically after its own view 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 allowing autoplay everywhere on a classroom machine is a real change and
should follow evidence, not a guess. 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 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 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. 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 For completeness, so nobody re-audits them: the managed policy in
`/etc/opt/chrome/policies/managed/` sets first-run, sign-in, password `/etc/opt/chrome/policies/managed/` sets first-run, sign-in, password
+23
View File
@@ -63,6 +63,16 @@ MQTT_HOST=10.40.0.2
MQTT_PORT=1883 MQTT_PORT=1883
MQTT_USERNAME= MQTT_USERNAME=
MQTT_PASSWORD= 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. # from the room, on purpose.
UI_SCALE=1.0 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", "MQTT_ENABLED",
+9 -1
View File
@@ -4,7 +4,7 @@ Section: misc
Priority: optional Priority: optional
Architecture: amd64 Architecture: amd64
Maintainer: Ekklesia 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 Recommends: vlc
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup
Description: Ekklesia managed classroom environment 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. draws its own loading cover and control bar as layer-shell surfaces.
They are no longer here for a dock. 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 The Conflicts line is deliberate. A welcome tour, a software centre and
a phone pairing daemon are all things plasma-desktop recommends and a a phone pairing daemon are all things plasma-desktop recommends and a
room a teacher walks into should not have. room a teacher walks into should not have.
+60 -17
View File
@@ -2,9 +2,22 @@
"""Match the classroom's panel to the machine it is on. """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 Two of the bar's launchers depend on the machine rather than on the
with no optical drive should not be offered a disc player. This edits the release, and this edits the marked line for each of them in the installed
one marked line in the installed layout, and nothing else. 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 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 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 the shell only reads, and classroom-desktop-reset makes the shell read it
again at the next login. 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 import glob
@@ -30,12 +40,24 @@ import os
import re import re
import sys 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 = ( LAYOUT = (
"/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop" "/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop"
"/contents/layouts/org.kde.plasma.desktop-layout.js" "/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 # The kernel names optical drives sr0, sr1, ... regardless of bus. Both
# views are checked: /dev is what anything opening the drive will use, and # 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) return bool(nodes and blocks)
def rewrite(text, wanted): def rewrite(text, marker, entry, wanted):
"""Comment the DVD launcher in or out, idempotently.""" """Comment one marked launcher in or out, idempotently."""
line = re.compile( 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, re.M,
) )
@@ -76,6 +99,20 @@ def rewrite(text, wanted):
return line.sub(swap, text) 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(): def main():
path = sys.argv[1] if len(sys.argv) > 1 else LAYOUT 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) print(f"classroom-desktop-layout: {error}", file=sys.stderr)
return 0 return 0
if MARKER not in before: if DVD_MARKER not in before:
print("classroom-desktop-layout: no DVD marker in the layout", print("classroom-desktop-layout: no DVD marker in the layout",
file=sys.stderr) file=sys.stderr)
return 0 return 0
wanted = has_optical_drive() after = rewrite(before, DVD_MARKER, DVD_ENTRY, has_optical_drive())
after = rewrite(before, wanted)
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: if after == before:
print(f"classroom-desktop-layout: DVD launcher already " print("classroom-desktop-layout: the layout already matches "
f"{'present' if wanted else 'absent'}") "this machine")
return 0 return 0
# Replace the file rather than edit in place, so a full disk or a # 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) print(f"classroom-desktop-layout: {error}", file=sys.stderr)
return 1 return 1
print(f"classroom-desktop-layout: DVD launcher " print("classroom-desktop-layout: the layout now matches this machine")
f"{'added' if wanted else 'removed'}")
return 0 return 0
+617 -41
View File
@@ -1,49 +1,625 @@
#!/bin/bash #!/usr/bin/env python3
set -euo pipefail
# 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 # Chrome is installed on every classroom machine for the ordinary
# no tab strip and no address bar and reads as a program. And it runs in # browser, and it brings a Widevine module with it. QtWebEngine will use
# its own Chrome profile, so the teacher signed into Wonder Ink and the # that module if it is told where it is, so a lesson whose video service
# room's shared YouTube account are separate sessions that cannot reach # happens to want DRM is not stopped by the engine change. Absent — on a
# each other: # 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"
)
# 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()
)
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,
)
# 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"
# 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.
# #
# * closing this window ends the Wonder Ink session, because the # showMaximized() here was measured to leave the window about
# wonderink.org cookies are session-only by policy and this profile's # 200 pixels wide in the corner of the screen: it starts
# browser process exits with the window; # maximised and so has no ordinary geometry to come back to, and
# * a Google sign-in performed here cannot be used to re-enter the # under KWin the fullscreen-to-maximised transition lands on
# previous teacher's Wonder Ink account from the ordinary browser, # that empty geometry. Turning off the fullscreen bit and leaving
# because the ordinary browser's Google session is in a different # the maximised bit alone keeps the window it already had.
# profile; self.setWindowState(
# * and the church YouTube login in the ordinary browser survives all of self.windowState() & ~Qt.WindowState.WindowFullScreen
# it, because nothing here touches that profile. )
# 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.
# #
# The machine-wide policy in /etc/opt/chrome/policies applies to every # Chrome refuses to begin audible playback that no click asked for,
# profile, so this window is governed by exactly the same rules as the # and Wonder Ink's Present view was measured failing on exactly that:
# browser. # 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
)
HOME_PAGE='https://app.wonderink.org/' 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()
# One number for the whole room. Chrome on Wayland was measured not to # The other half of the single instance: a second press of the panel
# follow the compositor's output scale on its own, so the 4K television # button connects here and the window comes forward instead of a
# got a browser at half the size of everything around it. Forcing the # second Wonder Ink starting on the same profile.
# same number the display is scaled by is a statement of the same fact, QLocalServer.removeServer(SOCKET)
# not a second scaling: Chrome replaces its detected scale with this server = QLocalServer(app)
# rather than multiplying by it. server.listen(SOCKET)
SCALE="$(/usr/local/bin/classroom-ui-scale 2>/dev/null || echo 1)"
PROFILE="${XDG_DATA_HOME:-$HOME/.local/share}/ekklesia-classroom/wonderink"
mkdir -p "$PROFILE" def come_forward():
connection = server.nextPendingConnection()
connection.disconnectFromServer()
exec google-chrome \ if window.isMinimized():
--user-data-dir="$PROFILE" \ window.showNormal()
--app="$HOME_PAGE" \
--ozone-platform=wayland \ window.raise_()
--force-device-scale-factor="$SCALE" \ window.activateWindow()
--start-maximized \
--no-first-run \ server.newConnection.connect(come_forward)
--no-default-browser-check \
--password-store=basic \ return app.exec()
--hide-crash-restore-bubble \
"$@"
if __name__ == "__main__":
sys.exit(main())
+74 -20
View File
@@ -32,49 +32,103 @@ def check(label, condition, detail=""):
WITH = '''tasks.writeConfig("launchers", [ WITH = '''tasks.writeConfig("launchers", [
"applications:ekklesia-wonderink.desktop", /* CLASSROOM-WONDERINK-LAUNCHER */ "applications:ekklesia-wonderink.desktop",
"applications:google-chrome.desktop", "applications:google-chrome.desktop",
/* CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop" /* CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop"
].join(","));''' ].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 */', WITHOUT = WITH.replace(' /* CLASSROOM-DVD-LAUNCHER */',
' // /* CLASSROOM-DVD-LAUNCHER */') ' // /* CLASSROOM-DVD-LAUNCHER */')
NO_INK = WITH.replace(' /* CLASSROOM-WONDERINK-LAUNCHER */',
' // /* CLASSROOM-WONDERINK-LAUNCHER */')
print("1. the DVD launcher follows the hardware") print("1. the DVD launcher follows the hardware")
check("a machine with a drive keeps it", check("a machine with a drive keeps it", dvd(WITH, True) == WITH)
module.rewrite(WITH, True) == WITH)
check("a machine without a drive loses it", check("a machine without a drive loses it",
module.rewrite(WITH, False) == WITHOUT, dvd(WITH, False) == WITHOUT, dvd(WITH, False))
module.rewrite(WITH, False)) check("and gets it back when a drive appears", dvd(WITHOUT, True) == WITH)
check("and gets it back when a drive appears",
module.rewrite(WITHOUT, True) == WITH)
check("removing twice changes nothing the second time", 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", check("adding twice changes nothing the second time",
module.rewrite(module.rewrite(WITHOUT, True), True) == WITH) dvd(dvd(WITHOUT, True), True) == WITH)
print() 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" other = WITH + "\npanel.addWidget(\"org.kde.plasma.digitalclock\");\n"
check("a line without the marker is left alone", 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", check("the other launchers survive",
"ekklesia-wonderink.desktop" in module.rewrite(WITH, False) "ekklesia-wonderink.desktop" in dvd(WITH, False)
and "google-chrome.desktop" in module.rewrite(WITH, False)) and "google-chrome.desktop" in dvd(WITH, False))
print() print()
print("3. the real layout carries the marker") print("5. the real layout carries both markers")
layout = (root / "config/plasma/lookandfeel/contents/layouts" layout = (root / "config/plasma/lookandfeel/contents/layouts"
/ "org.kde.plasma.desktop-layout.js").read_text() / "org.kde.plasma.desktop-layout.js").read_text()
check("the shipped layout has exactly one marker", check("exactly one DVD marker on a launcher line",
layout.count(module.MARKER) == 1) layout.count(module.DVD_MARKER + ' "applications:') == 1)
check("and it is on the DVD launcher", check("exactly one Wonder Ink marker on a launcher line",
'CLASSROOM-DVD-LAUNCHER */ "applications:ekklesia-dvd.desktop"' in layout) layout.count(module.WONDERINK_MARKER + ' "applications:') == 1)
check("the shipped layout is one a drive-less machine can be given", 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()
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: with tempfile.TemporaryDirectory() as work:
empty = Path(work) / "none.js" empty = Path(work) / "none.js"
check("a layout that does not exist exits cleanly", check("a layout that does not exist exits cleanly",
+13 -5
View File
@@ -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("no window rule claims the browser", "google-chrome" not in rules)
check("the browser asks for its own first window", "--start-maximized" in check("the browser asks for its own first window", "--start-maximized" in
(root / "scripts/classroom-browser").read_text()) (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()) (root / "scripts/classroom-wonderink").read_text())
check("the Chrome window arbiter is gone", not (root / "scripts/classroom-chrome-windows").exists()) 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) 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) "UI_SCALE_MAX = 3.0" in config_module)
check("the browser is told the same number", check("the browser is told the same number",
'--force-device-scale-factor="$SCALE"' in browser) '--force-device-scale-factor="$SCALE"' in browser)
check("so is Wonder Ink", check("and asks the one command for it",
'--force-device-scale-factor="$SCALE"' in wonderink) "classroom-ui-scale" in browser)
check("both ask the one command for it", # Wonder Ink is deliberately NOT told. Chrome on Wayland was measured not
"classroom-ui-scale" in browser and "classroom-ui-scale" in wonderink) # 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", check("the clock is sized by the bar, not by a number",
'autoFontAndSize", true' in layout and "fontSize" not in layout) 'autoFontAndSize", true' in layout and "fontSize" not in layout)
+47
View File
@@ -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')" "$(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)" 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" 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 installed" 1 "$(in_lab 'ls /usr/local/bin/classroom-parent-call 2>/dev/null | wc -l')"
check "parent call icon installed" 1 \ check "parent call icon installed" 1 \