Measured before changing anything, and nothing about loading changed, because the evidence does not point at Wonder Ink. Same lab, same page, timed by each engine until the sign-in form exists: Wonder Ink 2.86-3.00 s cold and 2.20-2.49 s warm, Chrome 2.79-3.48 s and 1.96-2.15 s. The site is the weight — 119-160 requests and 3.2 MB for a sign-in form, and its console reports loading the Google Maps API twice. Memory about 610 MB for one tab, 115 MB per further tab; idle CPU about 1%. The lab draws in software for both engines, so it cannot show the one likely cause it has no hardware for: QtWebEngine failing to use a classroom's GPU where Chrome succeeds. classroom-wonderink --diagnose, run from the maintenance terminal in the teacher's session, prints the engine versions, the scale, the WebGL renderer (a driver, or software), and the sign-in page's time from a cold cache, in a throwaway profile beside any running Wonder Ink. Co-Authored-By: Claude Opus 5.5 <[email protected]>
24 KiB
Wonder Ink
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.
Worksheets
Some of Wonder Ink's lesson links are a download rather than a page: the server sends the PDF as an attachment, so there is nothing for a tab to show and the file simply lands on disk.
In this room that was the same as it not arriving at all. There is no file manager, no application launcher and no downloads bar, on purpose, and a teacher is not going to open a maintenance terminal to print a Christmas tree. 0.2.1 downloaded the worksheet, said so in a notification, and left it somewhere unreachable.
So a worksheet that finishes downloading is opened in a tab:
- a teacher clicks a worksheet link;
- Wonder Ink downloads it into
~/Downloads/Wonder Ink; - when the download completes, and the file really is a PDF, it opens as another tab, named after the file;
- the teacher reads it, scrolls it, prints it to the room's printer;
- closing that tab leaves the lesson exactly where it was.
Rendering is the engine's own PDF viewer — the same PDFium that Chrome
uses — so there is no second rendering library in the room and no
separate application. QtWebEngineSettings.PdfViewerEnabled was already
on for remote PDFs; what 0.2.2 adds is opening a local one, which was
verified against a real completed file rather than assumed from the
remote case.
What is refused, and why
A completed download is not the same as a usable worksheet, and each of these was a real case in the lab:
| an interrupted download | the notification says it did not finish and to try again. Nothing opens |
HTML that the server called .pdf |
refused on its first five bytes, not on its name. Without this the teacher gets a blank tab and no reason |
| an empty or unreadable file | the same |
| anything that is not a PDF | saved, and said so, exactly as before. Nothing is opened and nothing is executed |
The terminal state is read from stateChanged, not isFinishedChanged.
Measured: an interrupted download never emits isFinishedChanged at all,
so the first version of this said nothing whatsoever about a worksheet
that failed to arrive — which is the one case a teacher most needs told
about.
Which local files Wonder Ink will open
Exactly the ones it opened itself, and nothing else.
Opening a worksheet adds that one resolved path to a list. The page's
acceptNavigationRequest refuses every file: URL that is not on that
list, so a lesson cannot navigate to one, a link cannot point at one and a
redirect cannot reach one. Chromium already refuses to let a remote page
reach file: at all — verified in the lab, where a link, a
location.href and a link to a worksheet that had been downloaded but
not opened by the shell were all refused — so this is the second lock
rather than the only one. It is there because the first one is somebody
else's.
Alongside it, two Qt defaults are turned off rather than left implicit:
LocalContentCanAccessFileUrls (on by default, and would let a local
document read other local documents) and LocalContentCanAccessRemoteUrls.
Printing has no Print to File and no file chooser of any kind: the preview (below) offers the room's CUPS printers and nothing else.
What a teacher still cannot reach
Anything that is not a PDF. A lesson handing out a Word document, a spreadsheet, a zip of images or a PowerPoint still downloads, still says so, and still lands somewhere a teacher has no way to open. This release deliberately fixes only the worksheet case, because that is the one the room actually hit and because "open whatever arrives" is how an appliance stops being one.
The obvious small follow-ups, in the order they are worth doing:
- Presentations. Already planned separately, and the larger of the two jobs — a PowerPoint needs a renderer, not just a viewer.
- A worksheets list. Not a file manager: one Wonder Ink tab, showing
the PDFs already in
~/Downloads/Wonder Ink, each opening in a tab the same way a fresh download does. It reads one directory, offers no navigation out of it, and needs no new permission — the paths it offers are ones the shell would open anyway. That covers "a teacher closed the worksheet tab and wants it back", which is the next thing that will be asked for.
Neither is in this release.
Where Wonder Ink's downloads end, and Chrome's begin
They never meet, and not because anything inspects a filename.
The download handler is connected to Wonder Ink's own profile, so it only ever sees downloads that started in a Wonder Ink tab. Chrome is a separate application with a separate profile and its own download handling, and nothing here changes, watches or intercepts it. There is no directory watcher anywhere in the classroom: a PDF that Chrome downloads is Chrome's business and stays that way.
~/Downloads/Wonder Ink is a directory of its own inside the ordinary
Downloads folder. That is partly tidiness and mostly the security
argument: it is the only place a worksheet can come from.
Printing
The crash (0.2.2, Little Waders)
Wonder Ink closed completely every time a teacher confirmed a print job:
SIGSEGV in QPrinter::resolution(), twice, both through QtWebEngine's
print-completion path.
QWebEngineView.print(printer) is asynchronous. In Qt 6.8.2
(qwebengineview.cpp) it stores the raw QPrinter* and returns; when
Chromium has rendered the page, didPrintPage() calls
currentPrinter->resolution() and hands the printer to a worker thread.
0.2.2 made the printer a local variable in print_view(), so PyQt deleted
it when the function returned and Qt read freed memory. Reproduced in the
lab with the 0.2.2 code: QPrinter::resolution() ←
QWebEnginePagePrivate::didPrintPage ← WebContentsAdapter::didPrintPage,
the Little Waders backtrace, and no job in CUPS.
Keeping the printer alive until printFinished stops the crash — that was
built and measured first — and it is still not a working print path:
| Measured, QtWebEngine 6.8.2, lab queue built from Elmer's PPD | Qt view.print() |
printToPdf() + lp |
|---|---|---|
| A three-page lesson | 24.6 MB of 300 dpi pictures, no text, A4 on a test queue | 18 KB vector PDF with its text, on Letter, 0.43 s through Elmer's filters |
| A worksheet PDF tab | one blank page | the worksheet file itself |
| A PDF a site shows inline | — | printToPdf never answers (CreatePreviewDocument got 0 page count) — so it is downloaded and printed as a file |
| A queue refusing jobs | reported as printed | "Refusing did not take the job: Destination … is not accepting jobs." |
| Black and white on Elmer | — | IPP print-color-mode is ignored by the generic PCL 6 driver ("Unknown option", prints colour); ColorModel=Grayscale gives pxlmono |
Qt's path also mishandles three things its own source makes plain: a
second print() on a page that is still printing is refused with no
printFinished, the worker keeps the printer after the tab has gone, and
quitting destroys the printer under that worker.
So 0.2.6.1 does not use Qt printing at all. Print makes a PDF — a
worksheet already is one; a lesson page is rendered by the engine onto the
printer's own paper with Chrome-like 10 mm margins — shows it, and hands it
to CUPS with lp, which is how Chrome prints in the same rooms. Nothing
asynchronous ever holds a printer, so closing a tab or Wonder Ink cannot
break a job: once lp returns, the job is CUPS's.
The preview
One dialog, for lessons and worksheets alike:
- the document as it will print, a whole page at a time, ‹ › to turn;
- Printer — the CUPS destinations, the system default preselected (Elmer, in every room);
- Pages — all, or some:
2,1-3,1, 3, 5-6, checked against the document with the reason given if wrong; - Copies — 1 to 50;
- Colour — colour or black and white, only where the printer's driver has a grey mode, and asked for in that driver's own terms;
- Cancel sends nothing; Print returns to the lesson once CUPS has the job, and a notification says where it went. A refused job keeps the preview open with CUPS's reason.
Choosing a printer with different paper lays a lesson page out again. A
page that closes its own tab while the preview is open is reported, not
printed. Print pressed again while a preview is open does nothing, and so
does a page calling window.print() meanwhile. The PDF viewer's own print
button opens the same preview.
Temporary PDFs live in the teacher's $XDG_RUNTIME_DIR and are removed
when the preview closes.
tools/lab/wonderink-print-check runs all of this against the installed
command in the lab, and judges every scenario by what CUPS actually
received. tests/test-classroom-printing.sh covers the CUPS side at every
build. Neither is paper. The first print through the real Elmer is a
job for somebody standing next to it.
Any other error
An exception escaping any Qt handler makes PyQt6 abort the whole process — measured: a mistake in one slot and the window vanishes. Wonder Ink now logs it to the journal and carries on.
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
CookiesSessionOnlyForUrlsdid 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 linesetPersistentCookiesPolicyinclassroom-wonderink. - The old Chrome profile is left where it is, at
~/.local/share/ekklesia-classroom/wonderink. The new one iswonderink-shellbeside it, so rolling back to 0.2.0 finds the old one intact. - One scale for the room — see How large a lesson is, below.
How large a lesson is
Teachers found lessons in Wonder Ink too small next to the same site in
the ordinary browser. Measured in the lab (Plasma 6 on KWin, Debian 13) by
asking each page for window.devicePixelRatio and its width in CSS pixels,
with the display scale set the way a room sets it (kscreen-doctor and
UI_SCALE):
| Display scale | Wonder Ink 0.2.6 | Chrome, as classroom-browser starts it |
|---|---|---|
| 100% | dpr 1, 1920 px wide | dpr 1, 1920 px wide |
| 150% | dpr 1.5, 1280 px wide | dpr 2.25, 854 px wide |
| 200% | dpr 2, 960 px wide | dpr 4, 500 px wide |
Wonder Ink was right by the desktop's measure: it follows the display
scale exactly, like the panel and every dialog. Chrome is scaled twice.
Under Plasma it follows the compositor's scale itself, and
classroom-browser also forces --force-device-scale-factor=UI_SCALE — a
flag from the sway session, where Chrome was measured not following the
compositor. So in any room above 100%, every page in the browser is
UI_SCALE times larger than the desktop around it, and that is the size
teachers read lessons at.
0.2.6.1 does not change the browser — it is the size the rooms know, and
changing it everywhere is its own decision. Wonder Ink's pages start at
the browser's size instead: page zoom = UI_SCALE, the room's own
clamped number, which is exactly 1.0 in a room at 100%. Wonder Ink's
window, tabs and dialogs are not zoomed; Qt already scales them once,
correctly. Measured after the change: at 150% the lesson reports dpr 2.25
and 853 px, Chrome dpr 2.25 and 854 px.
The teacher's own size goes on top: Ctrl+Plus (and Ctrl+=, and the
keypad's), Ctrl+Minus, Ctrl+0 for normal, Ctrl+wheel, or the right-click
menu. Chrome's steps, 50% to 300% of the room's normal size, one level for
every tab including new ones. It is remembered in
~/.config/ekklesia-classroom/wonderink.conf as a single LEVEL= line —
its own file, nowhere near the engine profile, so remembering a size can
never mean remembering a sign-in — and relative to the room, so an
administrator changing UI_SCALE later still gets the right result.
Measured along the way:
- the engine keeps a tab's zoom across every navigation, same site or not, so it is set once when the tab is made and never re-applied;
- zoom does not change what prints: the same lesson is 3 pages at 100%, 200% and 400%;
- page zoom does not reach a worksheet tab. Chromium's PDF viewer follows the display scale like the window, fits the page to its width, and has its own − / + zoom in its toolbar, which is what a teacher uses there;
- two Qt actions on one key are ambiguous and Qt does neither — Ctrl+Minus did nothing while it was bound once by name and again as KDE's standard zoom-out key. One action per command now carries all its keys, which also fixed Ctrl+Shift+Tab (bound twice the same way since 0.2.1).
tools/lab/wonderink-zoom-check asks the installed command all of this.
The lab's injected keyboard does not reach its nested session, so the
check delivers keys through Qt's own key path to the focused page; a
physical Ctrl+Plus on a classroom keyboard is a hardware check.
Why lessons load slowly
Measured before anything was changed, and nothing about loading was changed, because the evidence does not point at Wonder Ink.
In the lab, on the same connection, the same page, timed by each engine's own Navigation Timing until the sign-in form exists (the site is a 3.7 KB shell that builds itself in JavaScript, so its load event is not the moment a teacher can use it):
| Until the sign-in page is usable | Wonder Ink | Chrome |
|---|---|---|
| cold cache, 3 runs | 2.86 – 3.00 s | 2.79 – 3.48 s |
| warm cache, 3 runs | 2.20 – 2.49 s | 1.96 – 2.15 s |
| window on screen after launch | 0.33 s | 0.42 s |
- The site is the weight: 119 – 160 requests and 3.2 MB for a sign-in form, about 430 KB warm. Its console says it loads the Google Maps JavaScript API twice, synchronously. That is Wonder Ink's to fix.
- Wonder Ink is about a quarter of a second slower warm: it revalidates the page shell (62 – 87 ms to first byte) where Chrome serves it from its cache. Not the "very long time" teachers describe.
- Memory: about 610 MB for one tab and 115 MB more for each further tab — eight tabs, 1.4 GB — one renderer each. Idle CPU about 1%: nothing spins in the background.
- The network: from the lab, 0.14 – 0.22 s to first byte; from the development host, 0.42 – 0.44 s. Cloudflare in front, uncached.
What the lab cannot show is the machines: it draws in software for both engines. A classroom whose graphics fail in QtWebEngine but work in Chrome would look exactly like the complaint. So there is a command for a technician, run from the maintenance terminal inside the teacher's session (Meta+Ctrl+Alt+Shift+K):
classroom-wonderink --diagnose
It prints the engine's versions, the scale, what WebGL reports as the
renderer — a real driver's name, or "none … drawing in software" — and
times the sign-in page from a cold cache in a throwaway profile, beside a
running Wonder Ink without touching it. Compare its time with the lab's
2.9 s, and its renderer with Chrome's on the same machine (open
chrome://gpu there).
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:
- Vimeo videos embedded in Wonder Ink play correctly in the normal lesson view.
- The same videos in Wonder Ink's Present view stop immediately when Play is pressed.
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 session. Nothing is missing from the machine.
No codecs or media packages were installed for this, and none should be. Installing packages to fix a problem that is not a missing package would leave the classroom carrying software it does not need, and would not fix Present view.
Nothing in the system was changed for this.
The decisive test
Run it once at the endpoint. This separates "Wonder Ink" from "the classroom's Chrome configuration" in one step:
sudo mv /etc/opt/chrome/policies /etc/opt/chrome/policies.off
google-chrome --user-data-dir=/tmp/plain-chrome
That is a stock Chrome with a clean profile and no classroom policy at all. Sign in, open the lesson, press Present, press Play.
- Still fails — it is Wonder Ink, and nothing here can fix it. Report it to them with the console output below.
- Works — something in the classroom configuration is responsible, and the next section is where to look.
Afterwards:
sudo mv /etc/opt/chrome/policies.off /etc/opt/chrome/policies
rm -rf /tmp/plain-chrome
If it turns out to be this end
Two candidates, in order of likelihood. Neither has been applied.
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 transition, the gesture from the Present click may not carry, and Chrome rejects the play. That would look exactly like this: fine when you click the player directly, dead when the view starts it for you.
This is stock Chrome behaviour, not a classroom setting — a plain Chrome on any machine would do the same, which is what the decisive test above checks.
Diagnostic: open DevTools (F12) in Present view and press Play. A rejected autoplay logs:
NotAllowedError: play() failed because the user didn't interact
with the document first.
If that appears, the fix is a scoped policy, not a global one:
"AutoplayAllowlist": ["https://app.wonderink.org"]
AutoplayAllowlist is recognised by Chrome 152 — verified against the
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.
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 expect, and a player that is resized or re-parented mid-start can stop.
Diagnostic, while Present view is open:
swaymsg -t get_tree | grep -c google-chrome
More than one Chrome window means this is worth pursuing.
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
and homepage behaviour only. None of it touches media, autoplay,
iframes, fullscreen or site permissions.