feat: a lesson in Wonder Ink is the size the browser shows it, and zooms

Teachers found lessons in Wonder Ink too small next to the ordinary
browser. Measured by asking each page for devicePixelRatio: Wonder Ink
follows the display scale exactly (dpr 1.5 at 150%), and Chrome is
scaled TWICE (dpr 2.25) — under Plasma it follows the compositor itself
and classroom-browser also forces UI_SCALE, a flag from the sway session.

The browser is left as it is. Wonder Ink's pages start at zoom =
UI_SCALE, the room's own number (1.0 at 100%), and measured afterwards
match the browser: dpr 2.25 and 853 px against 2.25 and 854. The window
is not zoomed; Qt scales it once, correctly.

Ctrl+Plus/=/keypad, Ctrl+Minus, Ctrl+0, Ctrl+wheel and the right-click
menu set one level for every tab, remembered as LEVEL= in its own file,
relative to the room. Two actions on one key are ambiguous to Qt and it
then does neither — Ctrl+Minus did nothing until each command became one
action with all its keys, which also fixes Ctrl+Shift+Tab.

The engine profile is built by one function, which the lab's checks now
use: a check with its own profile showed every worksheet blank.

The lab fences 10.10.0.0/16 as well: the development host reaches the
real Elmer over Tailscale, and the lab provisions a queue pointing at it.
Four lab checks written for older code are corrected; one had failed on
every run since 0.2.3.

tools/lab/wonderink-zoom-check: 18 checks. Lab: converged 120, print 39,
worksheets 24, all passing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-23 19:04:58 -05:00
co-authored by Claude Opus 5.5
parent 10433b850d
commit 48b3097c43
7 changed files with 527 additions and 62 deletions
+9 -4
View File
@@ -55,10 +55,15 @@ the display scale for itself asks that command rather than the file:
* **Plasma** — the panel, the clock, notifications, the volume display and * **Plasma** — the panel, the clock, notifications, the volume display and
every dialog, because `classroom-scale` sets it as the display's scale every dialog, because `classroom-scale` sets it as the display's scale
through `kscreen-doctor` at every login. through `kscreen-doctor` at every login.
* **Chrome and Wonder Ink** — `--force-device-scale-factor`, because * **Chrome** — `--force-device-scale-factor`, because Chrome on the sway
Chrome on Wayland was measured not to follow the compositor's output session was measured not to follow the compositor's output scale on its
scale on its own. It is the same number, not a second scaling: Chrome own. **Under Plasma it does**, so this is now a second scaling: measured
replaces its detected scale with it. at 150%, Chrome's pages report `devicePixelRatio` 2.25, not 1.5. Left as
it is in 0.2.6.1 because it is the size teachers know; see
[wonder-ink.md](wonder-ink.md#how-large-a-lesson-is).
* **Wonder Ink** — its window follows the compositor as every Qt
application does, and its *pages* start at a zoom of `UI_SCALE`, so a
lesson is the size the browser shows it.
* **The disc player's own surfaces** — they are measured in the logical * **The disc player's own surfaces** — they are measured in the logical
pixels the compositor is already scaling, so they follow without being pixels the compositor is already scaling, so they follow without being
told. told.
+61 -5
View File
@@ -276,11 +276,67 @@ logs it to the journal and carries on.
`~/.local/share/ekklesia-classroom/wonderink`. The new one is `~/.local/share/ekklesia-classroom/wonderink`. The new one is
`wonderink-shell` beside it, so rolling back to 0.2.0 finds the old one `wonderink-shell` beside it, so rolling back to 0.2.0 finds the old one
intact. intact.
* **One scale for the room.** Chrome has to be handed `UI_SCALE` because * **One scale for the room** — see *How large a lesson is*, below.
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 ## How large a lesson is
reports `devicePixelRatio 2.0` without being told anything — so Wonder
Ink is deliberately *not* told, or the scale would be applied twice. 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.
## Turning it off ## Turning it off
+265 -40
View File
@@ -62,7 +62,7 @@ sys.path.insert(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"), os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
) )
from classroom_config import feature, read_device_config # noqa: E402 from classroom_config import feature, read_device_config, ui_scale # noqa: E402
HOME_PAGE = "https://app.wonderink.org/" HOME_PAGE = "https://app.wonderink.org/"
@@ -72,6 +72,48 @@ DATA_HOME = Path(
CACHE_HOME = Path( CACHE_HOME = Path(
os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache" os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
) )
CONFIG_HOME = Path(
os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
)
# The one thing Wonder Ink remembers between sessions: how large the
# teacher likes the lesson. Its own small file, deliberately nowhere near
# the engine's profile, so remembering a zoom level can never become
# remembering a sign-in.
SETTINGS = CONFIG_HOME / "ekklesia-classroom" / "wonderink.conf"
# HOW LARGE A LESSON IS.
#
# Measured in the lab on Plasma 6 / KWin, Debian 13, by asking each page
# for window.devicePixelRatio and its width in CSS pixels:
#
# display scale Wonder Ink Chrome (classroom-browser)
# 100% dpr 1, 1920 wide dpr 1, 1920 wide
# 150% dpr 1.5, 1280 wide dpr 2.25, 854 wide
# 200% dpr 2, 960 wide dpr 4, 500 wide
#
# Wonder Ink follows the display scale exactly, as the rest of the
# desktop does. Chrome is scaled TWICE: on Plasma it follows the
# compositor's scale by itself, and classroom-browser forces
# --force-device-scale-factor=UI_SCALE on top — a flag from the sway
# session, where Chrome was measured not following it. So in any room
# above 100% every page in the browser is UI_SCALE times larger than
# the desktop around it, and the same lesson in Wonder Ink looked small.
#
# Teachers read lessons in the browser at that size, so that is the size
# Wonder Ink starts at: page zoom = UI_SCALE. It is the room's own
# number, not a guess — 1.0 in a room at 100%, where nothing changes —
# and Wonder Ink's window itself is untouched, because Qt already scales
# that once, correctly.
#
# On top of that, the teacher's own level: Chrome's zoom steps, so
# Ctrl+Plus goes where somebody who knows Chrome expects, shown as a
# percentage of the room's normal size.
ZOOM_STEPS = (0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0)
LEVEL_NORMAL = 1.0
# The engine's own limits on a zoom factor.
ENGINE_ZOOM = (0.25, 5.0)
# Not the old Chrome directory. Two engines' state in one directory is a # 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 # thing nobody can reason about later, and the Chrome profile is left
@@ -123,6 +165,7 @@ if WIDEVINE.exists():
import classroom_printing # noqa: E402 import classroom_printing # noqa: E402
from PyQt6.QtCore import ( # noqa: E402 from PyQt6.QtCore import ( # noqa: E402
QEvent, QEvent,
QKeyCombination,
QMarginsF, QMarginsF,
Qt, Qt,
QTimer, QTimer,
@@ -185,6 +228,50 @@ def say(message):
print(f"classroom-wonderink: {message}", file=sys.stderr) print(f"classroom-wonderink: {message}", file=sys.stderr)
def load_level():
"""The teacher's zoom level, relative to the room's normal size.
Relative, so that an administrator changing UI_SCALE later is not
undone by a number saved at the old one. Never raises.
"""
try:
for line in SETTINGS.read_text(encoding="utf-8").splitlines():
key, _, value = line.partition("=")
if key.strip() == "LEVEL":
level = float(value)
if ZOOM_STEPS[0] <= level <= ZOOM_STEPS[-1]:
return level
except (OSError, ValueError):
pass
return LEVEL_NORMAL
def save_level(level):
"""Write it atomically; a failure costs the setting, not the lesson."""
try:
SETTINGS.parent.mkdir(parents=True, exist_ok=True)
temporary = SETTINGS.with_suffix(".tmp")
temporary.write_text(f"LEVEL={level:g}\n", encoding="utf-8")
temporary.replace(SETTINGS)
except OSError as error:
say(f"could not remember the zoom level: {error}")
def level_step(level, direction):
"""The next of ZOOM_STEPS above (1) or below (-1) LEVEL."""
if direction > 0:
return next((z for z in ZOOM_STEPS if z > level + 0.001), ZOOM_STEPS[-1])
return next((z for z in reversed(ZOOM_STEPS) if z < level - 0.001), ZOOM_STEPS[0])
def nearest_level(level):
return min(ZOOM_STEPS, key=lambda step: abs(step - level))
class LessonPage(QWebEnginePage): class LessonPage(QWebEnginePage):
"""A page that answers "open this somewhere else" with a new tab. """A page that answers "open this somewhere else" with a new tab.
@@ -288,6 +375,17 @@ class LessonView(QWebEngineView):
menu.addSeparator() menu.addSeparator()
menu.addAction(self.page().action(QWebEnginePage.WebAction.Copy)) menu.addAction(self.page().action(QWebEnginePage.WebAction.Copy))
menu.addSeparator()
bigger = QAction("Make the lesson bigger", menu)
bigger.triggered.connect(self._window.zoom_in)
smaller = QAction("Make the lesson smaller", menu)
smaller.triggered.connect(self._window.zoom_out)
normal = QAction("Normal size", menu)
normal.triggered.connect(self._window.zoom_reset)
normal.setEnabled(abs(self._window.level() - LEVEL_NORMAL) > 0.001)
for action in (bigger, smaller, normal):
menu.addAction(action)
menu.addSeparator() menu.addSeparator()
printing = QAction("Print…", menu) printing = QAction("Print…", menu)
printing.triggered.connect(self._window.print_current) printing.triggered.connect(self._window.print_current)
@@ -297,7 +395,9 @@ class LessonView(QWebEngineView):
class WonderInk(QMainWindow): class WonderInk(QMainWindow):
def __init__(self, profile): zoom_changed = pyqtSignal(float)
def __init__(self, profile, room_zoom=1.0):
super().__init__() super().__init__()
self._profile = profile self._profile = profile
self._fullscreen_view = None self._fullscreen_view = None
@@ -316,6 +416,11 @@ class WonderInk(QMainWindow):
self._preview = None self._preview = None
self._print_when_downloaded = set() self._print_when_downloaded = set()
self._mime = {} self._mime = {}
# One zoom level for the whole window: every tab, every new tab,
# and the next time Wonder Ink is opened. The room's normal size
# times the teacher's own level; see ZOOM_STEPS.
self._room_zoom = room_zoom
self._level = load_level()
self.setWindowTitle("Wonder Ink") self.setWindowTitle("Wonder Ink")
@@ -358,6 +463,14 @@ class WonderInk(QMainWindow):
view.page().loadingChanged.connect( view.page().loadingChanged.connect(
lambda info, v=view: self._loading_changed(v, info) lambda info, v=view: self._loading_changed(v, info)
) )
# Set once, here. Measured: the engine keeps a tab's zoom across
# every navigation, same site or not, so it is never re-applied.
view.setZoomFactor(self.zoom_factor())
# Ctrl+wheel is handled inside the engine and changes one tab.
# It is taken as the teacher's choice for all of them.
view.page().zoomFactorChanged.connect(
lambda zoom, v=view: self._zoomed_by_page(v, zoom)
)
index = self._tabs.addTab(view, "…") index = self._tabs.addTab(view, "…")
@@ -436,24 +549,124 @@ class WonderInk(QMainWindow):
# ---- the keys a teacher might already know -------------------------- # ---- the keys a teacher might already know --------------------------
def _shortcuts(self): def _shortcuts(self):
def bind(sequence, handler): def bind(sequences, handler):
# ONE action per command, carrying every key for it.
#
# Two actions answering the same key are ambiguous to Qt, and
# Qt's answer to ambiguity is to do neither — measured: with
# Ctrl+- bound once by name and again as KDE's standard
# zoom-out key, Ctrl+Minus did nothing at all.
keys = []
for sequence in sequences:
if isinstance(sequence, QKeySequence.StandardKey):
keys.extend(QKeySequence.keyBindings(sequence))
else:
keys.append(QKeySequence(sequence))
unique = []
for key in keys:
if not key.isEmpty() and key not in unique:
unique.append(key)
action = QAction(self) action = QAction(self)
action.setShortcut(QKeySequence(sequence)) action.setShortcuts(unique)
action.setShortcutContext(Qt.ShortcutContext.WindowShortcut) action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
action.triggered.connect(handler) action.triggered.connect(handler)
self.addAction(action) self.addAction(action)
return action return action
def keypad(key):
return QKeySequence(QKeyCombination(
Qt.KeyboardModifier.ControlModifier
| Qt.KeyboardModifier.KeypadModifier,
key,
))
# Deliberately absent: Ctrl+T, Ctrl+N, Ctrl+L. There is no new # Deliberately absent: Ctrl+T, Ctrl+N, Ctrl+L. There is no new
# tab, no new window and no address bar to focus. # tab, no new window and no address bar to focus.
bind(QKeySequence.StandardKey.Close, self._close_current) bind([QKeySequence.StandardKey.Close], self._close_current)
bind(QKeySequence.StandardKey.Back, self._back) bind([QKeySequence.StandardKey.Back], self._back)
bind(QKeySequence.StandardKey.Forward, self._forward) bind([QKeySequence.StandardKey.Forward], self._forward)
bind(QKeySequence.StandardKey.Refresh, self._reload) bind([QKeySequence.StandardKey.Refresh], self._reload)
bind(QKeySequence.StandardKey.Print, self.print_current) bind([QKeySequence.StandardKey.Print], self.print_current)
bind(QKeySequence.StandardKey.NextChild, self._next_tab) # Chrome's zoom keys belong to the browser around the page, so the
bind(QKeySequence.StandardKey.PreviousChild, self._previous_tab) # engine never sees them; in this room the browser around the page
bind("Ctrl+Shift+Tab", self._previous_tab) # is us. Ctrl+Plus arrives as Ctrl+Shift+Plus on the keyboards the
# rooms have, and Chrome answers Ctrl+= as well; the keypad's keys
# carry a modifier of their own.
bind(
[
"Ctrl+=", "Ctrl++", "Ctrl+Shift+=", "Ctrl+Shift++",
keypad(Qt.Key.Key_Plus),
QKeySequence.StandardKey.ZoomIn,
],
self.zoom_in,
)
bind(
[
"Ctrl+-", "Ctrl+Shift+_",
keypad(Qt.Key.Key_Minus),
QKeySequence.StandardKey.ZoomOut,
],
self.zoom_out,
)
bind(
[
"Ctrl+0",
keypad(Qt.Key.Key_0),
],
self.zoom_reset,
)
bind([QKeySequence.StandardKey.NextChild], self._next_tab)
bind(
[QKeySequence.StandardKey.PreviousChild, "Ctrl+Shift+Tab"],
self._previous_tab,
)
# ---- how large the lesson is -----------------------------------------
def level(self):
"""The teacher's level: 1.0 is the room's normal size."""
return self._level
def zoom_factor(self):
"""What the engine is told: the room's size times the teacher's."""
low, high = ENGINE_ZOOM
return max(low, min(high, self._room_zoom * self._level))
def set_level(self, level):
level = nearest_level(level)
changed = abs(level - self._level) > 0.001
self._level = level
zoom = self.zoom_factor()
for index in range(self._tabs.count()):
view = self._tabs.widget(index)
if abs(view.zoomFactor() - zoom) > 0.001:
view.setZoomFactor(zoom)
if changed:
save_level(level)
self.zoom_changed.emit(level)
def zoom_in(self):
self.set_level(level_step(self._level, 1))
def zoom_out(self):
self.set_level(level_step(self._level, -1))
def zoom_reset(self):
self.set_level(LEVEL_NORMAL)
def _zoomed_by_page(self, view, zoom):
# Ctrl+wheel, inside the engine. Taken as the teacher's level for
# every tab, on the nearest of the same steps the keys use.
if abs(zoom - self.zoom_factor()) > 0.001:
self.set_level(zoom / self._room_zoom)
def _close_current(self): def _close_current(self):
self.close_tab(self._tabs.currentIndex()) self.close_tab(self._tabs.currentIndex())
@@ -1201,37 +1414,17 @@ def keep_going(kind, value, trace):
traceback.print_exception(kind, value, trace, file=sys.stderr) traceback.print_exception(kind, value, trace, file=sys.stderr)
def main(): def build_profile(app, name="wonderink"):
config = read_device_config() """The engine profile every Wonder Ink window uses, settings and all.
# 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.
# feature(), not enabled(..., default=True). The default belongs in
# classroom_config.FEATURES with every other application's, so that
# the panel and the command can never disagree about whether a device
# offers this — which is the whole reason that table exists.
if not feature(config, "WONDERINK_ENABLED"):
say("WONDERINK_ENABLED is false on this device; not starting")
return 0
if raise_running_instance():
return 0
sys.excepthook = keep_going
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")
Its own function so that the lab's checks build exactly this rather
than a profile of their own: a check that forgot PluginsEnabled once
showed every worksheet as a blank page, and blamed Wonder Ink.
"""
PROFILE.mkdir(parents=True, exist_ok=True) PROFILE.mkdir(parents=True, exist_ok=True)
CACHE.mkdir(parents=True, exist_ok=True) CACHE.mkdir(parents=True, exist_ok=True)
profile = QWebEngineProfile("wonderink", app) profile = QWebEngineProfile(name, app)
profile.setPersistentStoragePath(str(PROFILE)) profile.setPersistentStoragePath(str(PROFILE))
profile.setCachePath(str(CACHE)) profile.setCachePath(str(CACHE))
# The behaviour the Chrome policy's CookiesSessionOnlyForUrls gave # The behaviour the Chrome policy's CookiesSessionOnlyForUrls gave
@@ -1301,9 +1494,41 @@ def main():
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, False QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, False
) )
return profile
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.
# feature(), not enabled(..., default=True). The default belongs in
# classroom_config.FEATURES with every other application's, so that
# the panel and the command can never disagree about whether a device
# offers this — which is the whole reason that table exists.
if not feature(config, "WONDERINK_ENABLED"):
say("WONDERINK_ENABLED is false on this device; not starting")
return 0
if raise_running_instance():
return 0
sys.excepthook = keep_going
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 = build_profile(app)
DOWNLOADS.mkdir(parents=True, exist_ok=True) DOWNLOADS.mkdir(parents=True, exist_ok=True)
window = WonderInk(profile) window = WonderInk(profile, room_zoom=ui_scale(config))
# Connected to the window rather than to a loose function: a finished # Connected to the window rather than to a loose function: a finished
# worksheet becomes a tab, and only a window can make one. # worksheet becomes a tab, and only a window can make one.
profile.downloadRequested.connect(window.on_download) profile.downloadRequested.connect(window.on_download)
+10 -2
View File
@@ -305,9 +305,17 @@ check("and asks the one command for it",
# UI_SCALE=2.0: kscreen-doctor reports the output at scale 2, and a Qt # 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 # client on that session reports devicePixelRatio 2.0 without being told
# anything. Forcing it as well would apply the scale twice. # anything. Forcing it as well would apply the scale twice.
wonderink_code = "\n".join(line for line in wonderink.splitlines()
if not line.lstrip().startswith("#"))
check("Wonder Ink is not told twice", check("Wonder Ink is not told twice",
"force-device-scale-factor" not in wonderink "force-device-scale-factor" not in wonderink_code
and "QT_SCALE_FACTOR" not in wonderink) and "QT_SCALE_FACTOR" not in wonderink_code)
# Its PAGES are, because Chrome under Plasma turned out to be scaled twice
# (dpr 2.25 at 150%, measured) and a lesson has to be the size teachers
# read it at in the browser. The window itself is left to Qt.
check("its pages start at the room's scale, which is the browser's size",
"room_zoom=ui_scale(config)" in wonderink_code
and "self._room_zoom * self._level" in wonderink_code)
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)
+15 -7
View File
@@ -302,8 +302,11 @@ check "the launcher names the shell's app id" ekklesia-wonderink \
# Comments stripped first. The shell has a comment saying Ctrl+T is # Comments stripped first. The shell has a comment saying Ctrl+T is
# deliberately absent, and a check that cannot tell that from a binding # deliberately absent, and a check that cannot tell that from a binding
# is a check that fails for being right. # is a check that fails for being right.
# One text box exists, in the print preview, for the page range. The
# check reads the code outside that class; anywhere else a text box in
# this window would be an address bar.
check "no code offers a new tab button or an address bar" 0 \ 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")" "$(in_lab "sed '/^class PrintPreview/,/^def notify/d' /usr/local/bin/classroom-wonderink | grep -vE '^[[:space:]]*#' | grep -cE 'QLineEdit\\(|setCornerWidget|addressBar|bind\\(\"Ctrl\\+T' || true")"
# Separate from the ordinary browser, which is the whole reason the split # 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 # exists: a teacher's Wonder Ink sign-in and the room's shared YouTube
# account must never be in one profile. # account must never be in one profile.
@@ -329,7 +332,7 @@ check "and the browser is untouched by any of this" 0 \
section "a lesson's worksheets come back as tabs" section "a lesson's worksheets come back as tabs"
check "worksheets have a directory of Wonder Ink's own" 1 \ check "worksheets have a directory of Wonder Ink's own" 1 \
"$(in_lab 'grep -c "\"Wonder Ink\"$" /usr/local/bin/classroom-wonderink || true')" "$(in_lab 'grep -c ") / \"Wonder Ink\"$" /usr/local/bin/classroom-wonderink || true')"
check "a finished PDF becomes a tab" 1 \ check "a finished PDF becomes a tab" 1 \
"$(in_lab 'grep -c "def open_worksheet" /usr/local/bin/classroom-wonderink || true')" "$(in_lab 'grep -c "def open_worksheet" /usr/local/bin/classroom-wonderink || true')"
check "checked on its contents, not its name" 1 \ check "checked on its contents, not its name" 1 \
@@ -344,8 +347,10 @@ check "and a guard consults it on every navigation" 1 \
"$(in_lab 'grep -c "def acceptNavigationRequest" /usr/local/bin/classroom-wonderink || true')" "$(in_lab 'grep -c "def acceptNavigationRequest" /usr/local/bin/classroom-wonderink || true')"
check "a local document cannot read other local documents" 1 \ check "a local document cannot read other local documents" 1 \
"$(in_lab 'grep -c "LocalContentCanAccessFileUrls, False" /usr/local/bin/classroom-wonderink || true')" "$(in_lab 'grep -c "LocalContentCanAccessFileUrls, False" /usr/local/bin/classroom-wonderink || true')"
check "no file chooser behind the print dialog" 1 \ # Qt's print dialog, and its Print to File chooser, are gone altogether:
"$(in_lab 'grep -c "PrintDialogOption.PrintToFile, False" /usr/local/bin/classroom-wonderink || true')" # the preview offers CUPS printers and nothing else.
check "no file chooser behind printing" 0 \
"$(in_lab 'grep -cE "QFileDialog|QPrintDialog" /usr/local/bin/classroom-wonderink || true')"
check "nothing downloaded is handed to the desktop to run" 0 \ check "nothing downloaded is handed to the desktop to run" 0 \
"$(in_lab 'grep -cE "QDesktopServices|xdg-open" /usr/local/bin/classroom-wonderink || true')" "$(in_lab 'grep -cE "QDesktopServices|xdg-open" /usr/local/bin/classroom-wonderink || true')"
check "and no file browser was added" 0 \ check "and no file browser was added" 0 \
@@ -365,10 +370,13 @@ check "device.conf carries the setting" 1 \
"$(in_lab 'grep -c "^WONDERINK_ENABLED=" /etc/ekklesia-classroom/device.conf || true')" "$(in_lab 'grep -c "^WONDERINK_ENABLED=" /etc/ekklesia-classroom/device.conf || true')"
check "and it defaults to on" true \ check "and it defaults to on" true \
"$(in_lab 'sed -n "s/^WONDERINK_ENABLED=//p" /etc/ekklesia-classroom/device.conf')" "$(in_lab 'sed -n "s/^WONDERINK_ENABLED=//p" /etc/ekklesia-classroom/device.conf')"
# One line the setting switches, since 0.2.3's one-setting-per-application
# layout: var WONDERINK = true|false. This asked for the older marker
# until 0.2.6.1 and so failed on every lab run in between.
check "the layout carries a marker the setting can switch" 1 \ 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')" "$(in_lab 'grep -cE "^var WONDERINK = (true|false);" /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 \ check "and it is switched on for this machine" 1 \
"$(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')" "$(in_lab 'grep -c "^var WONDERINK = true;" /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')"
+9 -4
View File
@@ -90,11 +90,16 @@ up)
# Container-local: this netns belongs to the container alone and # Container-local: this netns belongs to the container alone and
# nothing on the host or the church network is changed by it. Debian's # nothing on the host or the church network is changed by it. Debian's
# own mirrors are untouched, so installs still work. # own mirrors are untouched, so installs still work.
# 10.10 is the printers. The package provisions Elmer at
# socket://10.10.0.155:9100, and the development host reaches that
# address over Tailscale, so an unfenced lab that printed would have
# put paper in the church's real printer.
#
# A blackhole route rather than a firewall rule: the lab image has no # A blackhole route rather than a firewall rule: the lab image has no
# iptables, and the rig's guard had been quietly doing nothing because # iptables, and the rig's guard had been quietly doing nothing because
# of it. Routes need no netfilter modules and cannot silently fail. # of it. Routes need no netfilter modules and cannot silently fail.
docker exec "$NAME" sh -c ' docker exec "$NAME" sh -c '
for net in 10.30.0.0/16 10.40.0.0/16; do for net in 10.10.0.0/16 10.30.0.0/16 10.40.0.0/16; do
ip route replace blackhole "$net" || exit 1 ip route replace blackhole "$net" || exit 1
done' || echo " WARNING: could not fence the church networks" done' || echo " WARNING: could not fence the church networks"
@@ -102,7 +107,7 @@ up)
# bash feature: under dash it fails whatever the network is doing, so # bash feature: under dash it fails whatever the network is doing, so
# the check passed on a lab that could reach the broker perfectly # the check passed on a lab that could reach the broker perfectly
# well. Measured. # well. Measured.
for target in 10.40.0.2/1883 10.30.0.2/1025; do for target in 10.40.0.2/1883 10.30.0.2/1025 10.10.0.155/9100; do
if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2 echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2
exit 1 exit 1
@@ -140,11 +145,11 @@ reboot)
# named after container ids on the real broker, from sessions that # named after container ids on the real broker, from sessions that
# had been fenced when they started and were not after a reboot. # had been fenced when they started and were not after a reboot.
docker exec "$NAME" sh -c ' docker exec "$NAME" sh -c '
for net in 10.30.0.0/16 10.40.0.0/16; do for net in 10.10.0.0/16 10.30.0.0/16 10.40.0.0/16; do
ip route replace blackhole "$net" || exit 1 ip route replace blackhole "$net" || exit 1
done' || echo " WARNING: could not fence the church networks" done' || echo " WARNING: could not fence the church networks"
for target in 10.40.0.2/1883 10.30.0.2/1025; do for target in 10.40.0.2/1883 10.30.0.2/1025 10.10.0.155/9100; do
if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2 echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2
exit 1 exit 1
+158
View File
@@ -0,0 +1,158 @@
#!/bin/bash
# How large is a lesson in Wonder Ink, and do the keys change it?
#
# Runs the INSTALLED classroom-wonderink under a real QtWebEngine
# (offscreen), builds its window the way main() does, and asks the page
# itself what it is rendering at. Keys are delivered through Qt's own key
# path to the focused page — the same shortcut map a keyboard reaches —
# because the lab's injected keyboard does not reach the nested session.
# A physical Ctrl+Plus is a hardware check.
#
# tools/lab/wonderink-zoom-check
set -uo pipefail
NAME="${LAB_NAME:-classroom-v2-lab}"
COMMAND="${WONDERINK:-/usr/local/bin/classroom-wonderink}"
docker exec -i \
-e QT_QPA_PLATFORM=offscreen \
-e QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu" \
-e COMMAND="$COMMAND" \
"$NAME" bash -c 'export XDG_RUNTIME_DIR=$(mktemp -d) HOME=$(mktemp -d); python3 -' <<'PY'
import importlib.util, os, sys
from importlib.machinery import SourceFileLoader
loader = SourceFileLoader("wonderink", os.environ["COMMAND"])
spec = importlib.util.spec_from_loader("wonderink", loader)
ink = importlib.util.module_from_spec(spec)
loader.exec_module(ink)
from PyQt6.QtCore import QEventLoop, Qt, QTimer
from PyQt6.QtTest import QTest
from PyQt6.QtWebEngineCore import QWebEngineProfile
from PyQt6.QtWidgets import QApplication
app = QApplication(sys.argv[:1])
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" \033[32mPASS\033[0m {label}")
passed += 1
else:
print(f" \033[31mFAIL\033[0m {label}" + (f" [{detail}]" if detail else ""))
failed += 1
def wait(ms):
loop = QEventLoop()
QTimer.singleShot(ms, loop.quit)
loop.exec()
def page_dpr(view):
answer = []
view.page().runJavaScript("window.devicePixelRatio", answer.append)
for _ in range(50):
if answer:
return answer[0]
wait(100)
return None
PAGE = "data:text/html,<title>lesson</title><p>Noah built an ark.</p>"
KEY = {
"Ctrl+=": (Qt.Key.Key_Equal, Qt.KeyboardModifier.ControlModifier),
"Ctrl+Plus": (Qt.Key.Key_Plus, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier),
"keypad Ctrl+Plus": (Qt.Key.Key_Plus, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.KeypadModifier),
"Ctrl+Minus": (Qt.Key.Key_Minus, Qt.KeyboardModifier.ControlModifier),
"keypad Ctrl+Minus": (Qt.Key.Key_Minus, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.KeypadModifier),
"Ctrl+0": (Qt.Key.Key_0, Qt.KeyboardModifier.ControlModifier),
"Ctrl+Shift+Tab": (Qt.Key.Key_Backtab, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier),
}
def press(window, name):
target = QApplication.focusWidget() or window.current()
QTest.keyClick(target, *KEY[name])
wait(400)
def open_window(room_zoom):
profile = QWebEngineProfile(f"zoomcheck{room_zoom}", app)
window = ink.WonderInk(profile, room_zoom=room_zoom)
window.resize(1280, 800)
window.show()
for _ in range(3):
window.open_tab(PAGE)
wait(2500)
window.current().setFocus()
wait(300)
return window
def factors(window):
return [round(window._tabs.widget(i).zoomFactor(), 3) for i in range(window._tabs.count())]
print("1. a room's normal size is the browser's")
for room in (1.0, 1.5, 2.0):
ink.SETTINGS.unlink(missing_ok=True)
window = open_window(room)
check(f"UI_SCALE {room}: every tab starts at {room}", factors(window) == [room] * 3, factors(window))
window.close()
wait(300)
print()
print("2. the keys a teacher knows, at UI_SCALE 1.5")
ink.SETTINGS.unlink(missing_ok=True)
window = open_window(1.5)
for name, level in (("Ctrl+=", 1.1), ("Ctrl+Plus", 1.25), ("keypad Ctrl+Plus", 1.5),
("Ctrl+Minus", 1.25), ("keypad Ctrl+Minus", 1.1), ("Ctrl+0", 1.0)):
press(window, name)
check(f"{name} makes it {round(level * 100)}% in every tab",
window.level() == level and factors(window) == [round(1.5 * level, 3)] * 3,
f"level={window.level()} factors={factors(window)}")
press(window, "Ctrl+Plus")
dpr = page_dpr(window.current())
check("and the page itself renders at it", dpr is not None and abs(dpr - 1.65) < 0.01, dpr)
print()
print("3. new tabs, and the next time Wonder Ink opens")
view = window.open_tab(PAGE)
wait(1500)
check("a new tab starts at the teacher's size", round(view.zoomFactor(), 3) == 1.65, view.zoomFactor())
check("the level is saved on its own", ink.SETTINGS.read_text().strip() == "LEVEL=1.1",
ink.SETTINGS.read_text())
check("in its own file, not the web profile",
"wonderink-shell" not in str(ink.SETTINGS) and ink.SETTINGS.name == "wonderink.conf")
index = window._tabs.currentIndex()
press(window, "Ctrl+Shift+Tab")
check("Ctrl+Shift+Tab still goes back a tab", window._tabs.currentIndex() == index - 1,
f"{index} -> {window._tabs.currentIndex()}")
window.close()
wait(300)
window = open_window(2.0)
check("reopened in a room at 2.0, the teacher's 110% is kept",
window.level() == 1.1 and factors(window) == [2.2] * 3, factors(window))
window.close()
ink.SETTINGS.write_text("LEVEL=banana\n")
check("a damaged setting is the normal size", ink.load_level() == 1.0)
ink.SETTINGS.write_text("LEVEL=40\n")
check("an absurd one too", ink.load_level() == 1.0)
window = open_window(3.0)
press(window, "keypad Ctrl+Plus"); press(window, "keypad Ctrl+Plus"); press(window, "keypad Ctrl+Plus")
check("a large room zoomed further never passes the engine's limit",
max(factors(window)) <= 5.0, factors(window))
window.close()
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY