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:
co-authored by
Claude Opus 5.5
parent
10433b850d
commit
48b3097c43
+9
-4
@@ -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.
|
||||
|
||||
+61
-5
@@ -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
|
||||
|
||||
|
||||
+265
-40
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')"
|
||||
|
||||
+9
-4
@@ -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
|
||||
|
||||
Executable
+158
@@ -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
|
||||
Reference in New Issue
Block a user