From 48b3097c432c854c5b554535ccff7ce9c6b729b2 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Wed, 23 Sep 2026 19:04:58 -0500 Subject: [PATCH] feat: a lesson in Wonder Ink is the size the browser shows it, and zooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ui-scale.md | 13 +- docs/wonder-ink.md | 66 ++++++- scripts/classroom-wonderink | 305 +++++++++++++++++++++++++++---- tests/test-deployment-support.sh | 12 +- tools/lab/converged-check | 22 ++- tools/lab/lab | 13 +- tools/lab/wonderink-zoom-check | 158 ++++++++++++++++ 7 files changed, 527 insertions(+), 62 deletions(-) create mode 100755 tools/lab/wonderink-zoom-check diff --git a/docs/ui-scale.md b/docs/ui-scale.md index 323a064..5e1f369 100644 --- a/docs/ui-scale.md +++ b/docs/ui-scale.md @@ -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 every dialog, because `classroom-scale` sets it as the display's scale through `kscreen-doctor` at every login. -* **Chrome and Wonder Ink** — `--force-device-scale-factor`, because - Chrome on Wayland was measured not to follow the compositor's output - scale on its own. It is the same number, not a second scaling: Chrome - replaces its detected scale with it. +* **Chrome** — `--force-device-scale-factor`, because Chrome on the sway + session was measured not to follow the compositor's output scale on its + own. **Under Plasma it does**, so this is now a second scaling: measured + 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 pixels the compositor is already scaling, so they follow without being told. diff --git a/docs/wonder-ink.md b/docs/wonder-ink.md index 00d810a..99e6752 100644 --- a/docs/wonder-ink.md +++ b/docs/wonder-ink.md @@ -276,11 +276,67 @@ logs it to the journal and carries on. `~/.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. +* **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. ## Turning it off diff --git a/scripts/classroom-wonderink b/scripts/classroom-wonderink index 59a00ca..f811494 100755 --- a/scripts/classroom-wonderink +++ b/scripts/classroom-wonderink @@ -62,7 +62,7 @@ sys.path.insert( 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/" @@ -72,6 +72,48 @@ DATA_HOME = Path( CACHE_HOME = Path( 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 # thing nobody can reason about later, and the Chrome profile is left @@ -123,6 +165,7 @@ if WIDEVINE.exists(): import classroom_printing # noqa: E402 from PyQt6.QtCore import ( # noqa: E402 QEvent, + QKeyCombination, QMarginsF, Qt, QTimer, @@ -185,6 +228,50 @@ def say(message): 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): """A page that answers "open this somewhere else" with a new tab. @@ -288,6 +375,17 @@ class LessonView(QWebEngineView): menu.addSeparator() 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() printing = QAction("Print…", menu) printing.triggered.connect(self._window.print_current) @@ -297,7 +395,9 @@ class LessonView(QWebEngineView): class WonderInk(QMainWindow): - def __init__(self, profile): + zoom_changed = pyqtSignal(float) + + def __init__(self, profile, room_zoom=1.0): super().__init__() self._profile = profile self._fullscreen_view = None @@ -316,6 +416,11 @@ class WonderInk(QMainWindow): self._preview = None self._print_when_downloaded = set() 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") @@ -358,6 +463,14 @@ class WonderInk(QMainWindow): view.page().loadingChanged.connect( 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, "…") @@ -436,24 +549,124 @@ class WonderInk(QMainWindow): # ---- the keys a teacher might already know -------------------------- 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.setShortcut(QKeySequence(sequence)) + action.setShortcuts(unique) action.setShortcutContext(Qt.ShortcutContext.WindowShortcut) action.triggered.connect(handler) self.addAction(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 # 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) + 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) + # Chrome's zoom keys belong to the browser around the page, so the + # engine never sees them; in this room the browser around the page + # 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): 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) -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") +def build_profile(app, name="wonderink"): + """The engine profile every Wonder Ink window uses, settings and all. + 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) CACHE.mkdir(parents=True, exist_ok=True) - profile = QWebEngineProfile("wonderink", app) + profile = QWebEngineProfile(name, app) profile.setPersistentStoragePath(str(PROFILE)) profile.setCachePath(str(CACHE)) # The behaviour the Chrome policy's CookiesSessionOnlyForUrls gave @@ -1301,9 +1494,41 @@ def main(): 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) - window = WonderInk(profile) + window = WonderInk(profile, room_zoom=ui_scale(config)) # Connected to the window rather than to a loose function: a finished # worksheet becomes a tab, and only a window can make one. profile.downloadRequested.connect(window.on_download) diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh index 10858c9..fcaff2c 100755 --- a/tests/test-deployment-support.sh +++ b/tests/test-deployment-support.sh @@ -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 # client on that session reports devicePixelRatio 2.0 without being told # 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", - "force-device-scale-factor" not in wonderink - and "QT_SCALE_FACTOR" not in wonderink) + "force-device-scale-factor" not in wonderink_code + 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", 'autoFontAndSize", true' in layout and "fontSize" not in layout) diff --git a/tools/lab/converged-check b/tools/lab/converged-check index 1e67ddc..1ed689d 100755 --- a/tools/lab/converged-check +++ b/tools/lab/converged-check @@ -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 # deliberately absent, and a check that cannot tell that from a binding # 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 \ - "$(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 # exists: a teacher's Wonder Ink sign-in and the room's shared YouTube # 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" 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 \ "$(in_lab 'grep -c "def open_worksheet" /usr/local/bin/classroom-wonderink || true')" 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')" check "a local document cannot read other local documents" 1 \ "$(in_lab 'grep -c "LocalContentCanAccessFileUrls, False" /usr/local/bin/classroom-wonderink || true')" -check "no file chooser behind the print dialog" 1 \ - "$(in_lab 'grep -c "PrintDialogOption.PrintToFile, False" /usr/local/bin/classroom-wonderink || true')" +# Qt's print dialog, and its Print to File chooser, are gone altogether: +# 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 \ "$(in_lab 'grep -cE "QDesktopServices|xdg-open" /usr/local/bin/classroom-wonderink || true')" 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')" check "and it defaults to on" true \ "$(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 \ - "$(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')" + "$(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" 1 \ + "$(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" check "parent call installed" 1 "$(in_lab 'ls /usr/local/bin/classroom-parent-call 2>/dev/null | wc -l')" diff --git a/tools/lab/lab b/tools/lab/lab index ddc5c42..7490170 100755 --- a/tools/lab/lab +++ b/tools/lab/lab @@ -90,11 +90,16 @@ up) # Container-local: this netns belongs to the container alone and # nothing on the host or the church network is changed by it. Debian's # 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 # iptables, and the rig's guard had been quietly doing nothing because # of it. Routes need no netfilter modules and cannot silently fail. 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 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 # the check passed on a lab that could reach the broker perfectly # 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 echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2 exit 1 @@ -140,11 +145,11 @@ reboot) # named after container ids on the real broker, from sessions that # had been fenced when they started and were not after a reboot. 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 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 echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2 exit 1 diff --git a/tools/lab/wonderink-zoom-check b/tools/lab/wonderink-zoom-check new file mode 100755 index 0000000..f512596 --- /dev/null +++ b/tools/lab/wonderink-zoom-check @@ -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,lesson

Noah built an ark.

" +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