Compare commits

16 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 23194a085c release: 0.2.6.4, for the canary machine only
Temporary workspace restore: Wonder Ink brings back the teacher's recent
tabs for two hours after they last used it — never the sign-in, which
WonderInk keeps as a Basic-auth password and which is not stored. The
admin-only link bubble is documented, not overridden.

Published to canary: ekklesia-classroom_0.2.6.4_amd64.deb, sha256
80388e7ac3d9a1a266ebc3454f64808b6a0e2cd75b06fa934caf9f2a25026253, gated
on that file as an upgrade from 0.2.6.3 and from 0.2.2. 0.2.6.3 and
0.2.6.2 are on the server as they were; latest.deb and the manifest read
back over HTTPS as 0.2.6.4. Stable still answers 0.2.2; nothing here
wrote to it. No desktop is affected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 21:30:15 -05:00
ekklesiaandClaude Opus 5.5 fd4330a649 docs: temporary workspace restore, the admin link bubble, and Sunday's checks
wonder-ink.md: where WonderInk keeps its sign-in (Basic auth in a cookie)
and why only tabs are restored; what comes back and what never does; what
counts as use; when it is not restored; the test-only override. And the
link bubble teachers saw: WonderInk's CKEditor 5 for Admin accounts,
where a plain click on a link is an editing click by design; Viewers get
one-click links, measured on the real Saul lesson, and Wonder Ink does
not override the editor.

The manual says closing still signs you out and recent tabs come back
for two hours. testing.md has the Little Waders checklist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 21:12:17 -05:00
ekklesiaandClaude Opus 5.5 78a844968c test: the lab restores real tabs across real launches, and follows real links
tools/lab/wonderink-workspace-check launches the installed command again
and again against a stand-in for WonderInk's start page whose sign-in is
Basic auth, as WonderInk's is: restored after 5 and 90 minutes; fresh
after two hours and a minute, a day and a week; reopened untouched until
it lapses; a click after a restore counts and the restore does not; a
busy page and an untouched print preview do not; Print does; closed,
reordered, worksheet and missing-worksheet tabs; killed outright; a
restore that crashes twice; corrupt, future-format and half-written
files; the file holding no credential; restored tabs printing. 59 checks.

The print check follows every kind of link on a signed-in page with one
click — new tab in front for links and window.open, nothing for buttons
and anchors, same-tab for no target, Back, closing — and prints a PDF and
a picture reached by a link, and a page after several tabs. 0.2.6.3's
scenarios are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 21:12:17 -05:00
ekklesiaandClaude Opus 5.5 b5d9535467 feat: Wonder Ink brings back recent tabs for two hours, never the sign-in
A teacher who closes Wonder Ink by accident, or whose computer crashes or
restarts, gets their recent tabs back if they open it again within two
hours of last using it. Later than that — overnight, next week — Wonder
Ink starts fresh and the file is removed.

It is a temporary workspace restore, not a sign-in. Measured on the real
site: WonderInk's rrf-auth cookie is "Basic " and the base64 of the
teacher's email and password, and its start-up authenticates again on
every load; keeping a sign-in would mean keeping a password. Nothing of
it is kept — no cookie, header or storage — and closing Wonder Ink still
signs the teacher out. What is kept is each tab's kind, address or
worksheet file, and title; the order; the tab in front; the time of last
use. An address with a credential in it is not kept at all.

Every WonderInk page has one address, so its tabs come back as one, at
the sign-in page. Its resource files are public and come back as they
were. A worksheet comes back only from Downloads/Wonder Ink, and only if
it is still there and still a PDF. The tab in front loads at once, the
rest when first shown: measured, a PDF loaded in a hidden tab showed at
10%.

Using it is the teacher's input — click, key, wheel, touch — never a
page's own activity, a print preview left open, or the restore itself,
so reopening cannot keep a workspace alive. A restore that crashes twice
is abandoned. Written atomically when tabs change and at most every 30 s
for the time of use; unreadable, future-format and clock-gone-back files
start fresh. The keeper stops filtering events at aboutToQuit: left in
place it crashed Wonder Ink on exit one run in several; 30 runs, none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 21:12:17 -05:00
ekklesiaandClaude Opus 5.5 5fef4e02a9 release: 0.2.6.3, for the canary machine only
Black and white is shown in the preview as it prints, and CUPS is given
the file shown; right-click on a picture in a lesson offers Print image,
which prints that picture with the picture layout, and Print page.

Published to canary: ekklesia-classroom_0.2.6.3_amd64.deb, sha256
2b8f26409b5374f08e8d461d8c711307d5180c3b120654df0150a587b7d64710, gated
on that file as an upgrade from 0.2.6.2 and from 0.2.2. 0.2.6.2 is still
on the server as it was. Stable still answers 0.2.2; nothing here wrote
to it. No desktop is affected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:47:56 -05:00
ekklesiaandClaude Opus 5.5 f9a0ee08f9 docs: 0.2.6.3 — what was measured, the manual, and Sunday's checks
Why black and white is made with Ghostscript and how close it is to what
Elmer prints; why Print image goes through the engine's copy image, with
the table of what each other way failed on; what it cannot reach (a CSS
background picture) and what it costs (the clipboard lives in Wonder Ink
afterwards). The manual says Print page and Print image, and that the
preview turns grey. Sunday's paper checks for both, in testing.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:34:38 -05:00
ekklesiaandClaude Opus 5.5 cb12e59dea test: the lab prints from a signed-in lesson, and judges black and white on the job
A lesson site for the lab (session cookie, one-time addresses, a second
origin, a request log) and real right-clicks on its words and on a JPEG, a
PNG, a transparent PNG, a one-time picture, another site's, a blob:, a
WebP, a canvas, a linked picture, a broken one, and Cancel. Each is judged
on the job CUPS kept — where and how large, no other ink — and on the site
log: nothing was fetched again. The clipboard is the teacher's afterwards.

Black and white: a colourful lesson turned back and forth five times on
page 2, a worksheet, a picture at 50% top left, a page full of pictures,
back to Color, and Cancel. Every grey preview is grey pixel for pixel and
the job is byte-for-byte the file shown, through pxlmono. The three 0.2.6.2
black-and-white scenarios now wait for the grey file before Print, as a
teacher has to. 343 checks, none failing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:34:38 -05:00
ekklesiaandClaude Opus 5.5 594fbd810e fix: black and white is shown as it prints, and right-click prints the picture
Printing through Elmer found two things. Black and white came out grey
while the preview stayed in colour: 0.2.6.2 only told the driver. And
right-clicking a picture in a lesson and choosing Print printed the whole
page around it, where some pictures cannot be opened on their own at all.

Black and white is now made before the preview. Ghostscript makes a grey
copy, the preview shows it, and CUPS is given that same file, still with
ColorModel=Grayscale so Elmer's PCL 6 driver prints with pxlmono. Measured
first: Ghostscript's own grey of the colour original and of the copy give
the same greys (Elmer's driver is Ghostscript); poppler's do not. It takes
up to 4.4 s for a lesson page of pictures, so it runs in the background
with a minute to finish, is made once and kept, and a failure is said so
and never printed in colour instead. Ghostscript is now a named dependency;
cups-filters already brought it.

Right-click asks the engine's hit test: on a picture or a canvas it offers
Print image and Print page, elsewhere only Print page. The picture comes
from the engine's own copy image — the only one of five ways measured that
worked for a signed-in picture, a hotlink-protected one, a one-time
address, blob:, data:, another site's, a canvas and WebP — and it fetches
nothing, so the teacher's sign-in is not needed again. It goes through the
clipboard, which is kept and put back; measured under KWin with another
program owning it. A picture that never arrives is said so; the page is
not printed in its place. It then gets the 0.2.6.2 picture layout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:34:38 -05:00
ekklesiaandClaude Opus 5.5 550c0bf126 release: 0.2.6.2, for the canary machine only
Pictures print as the picture — no black bars — at the size and place a
teacher chooses; a presentation that runs out of memory says so and can
be tried again, and the import service survives it; and a notification's
Configure button no longer opens KDE's settings under the teacher account.

Published to canary: ekklesia-classroom_0.2.6.2_amd64.deb, sha256
a7ca958ac8352bb194a2685e4339fe8cafceb5fee871e4c023402c7320945618, gated
on that file as an upgrade from 0.2.6.1 and from 0.2.2. 0.2.6.1 is
still on the server as it was. Stable still answers 0.2.2; nothing here
wrote to it. The laptop is offline and will take this when it is next
on; no desktop is affected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 07:35:24 -05:00
ekklesiaandClaude Opus 5.5 2325a5993b test: the worksheet check tears its pages down before their profile
Intermittently it segfaulted after every check had passed, leaving
teardown order to Python; a profile released under a live page does
that. Eight runs clean afterwards. The real Wonder Ink, closed six times
by its window's close button in the lab, exited 0 each time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 07:19:45 -05:00
ekklesiaandClaude Opus 5.5 2d1cc016c5 test: the lab installs a package the way a classroom does
from-package used dpkg -i and then apt-get -f install. With 0.2.6.2's
Conflicts: khelpcenter, dpkg refused and -f resolved it by dropping the
classroom package, so the lab ran every check against nothing. The
updater runs apt-get install on the file, which removes the conflict and
installs; measured, and now the lab does the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 07:07:54 -05:00
ekklesiaandClaude Opus 5.5 59cb51c68b docs: Sunday's checks for 0.2.6.2 — pictures, a large deck, the settings route
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 07:01:39 -05:00
ekklesiaandClaude Opus 5.5 0bbe2a1ee0 docs: the 0.3.0 backlog, from testing 0.2.6.1
Wonder Ink's next interface, the PDF viewer's Download and second Print,
readable worksheet names, a tab's sound indicator, the popup that Chrome
does not show, session retention, and the site's weight; Presentations
as a maximisable tile grid with click-to-advance and a hidden pointer;
the launcher's subtitles and Parent Call's flash. Recorded, not built.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 07:01:10 -05:00
ekklesiaandClaude Opus 5.5 cd798a33a1 fix: a notification's Configure button no longer opens system settings
The kernel memory killer's "Memory Shortage Avoided" notice carried a
Configure button that ran kcmshell6 and opened KDE's whole Notifications
settings under the teacher's account. Reproduced in the lab.

Plasma draws that button without consulting the Kiosk policy, but
kcmshell6 and System Settings do: every settings module Debian 13's
Plasma 6.3 installs (90) is now refused in the classroom's immutable
kdeglobals, so the same click shows only "disabled by the system
administrator". The clock's Configure Digital Clock goes with
plasma/allow_configure_when_locked=false; Configure Audio Devices
disappears on its own; the disabled window's Help button opened KDE Help
Center, which the package now conflicts with (plasma-desktop only
recommends it).

The maintenance terminal runs outside the classroom's configuration and
still opens every module, verified. converged-check fails if any module
on a machine is ever left unrestricted: 126 passed in the lab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 06:59:53 -05:00
ekklesiaandClaude Opus 5.5 3855989029 fix: a presentation that runs out of memory says so, and the service survives it
On a 4 GB VM the kernel killed the import service mid-conversion. Measured:
LibreOffice needs 0.2 GB for a text deck and 1.4-2.1 GB for decks of many
different photographs, for about twenty seconds — a spike, not a leak.
What made it a problem was the rest: OOMPolicy=stop took the watcher down
with LibreOffice, it restarted and resumed the same conversion with no
limit, and the library said "Getting this ready" throughout.

The service is capped at 40% of memory, is first in line when memory runs
short everywhere, and survives a killed LibreOffice (OOMPolicy=continue).
A killed conversion is a failed import of kind "memory"; an import
interrupted twice is not tried a third time; the card says the computer
needed more memory, to close other programs and choose Try again; an
import nobody has worked on for 20 minutes shows the same card; and an
exclusive lock keeps it to one import at a time.

Lab, the real service and LibreOffice: at a 4 GB-machine ceiling the
2.1 GB deck still imported; with no swap and a 300 MB ceiling LibreOffice
was killed, the watcher kept its PID and the card offered Try again,
which worked; the whole service killed during two attempts gave up after
the second; three decks at once never ran two LibreOffices together.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 22:16:50 -05:00
ekklesiaandClaude Opus 5.5 940f63e0e5 fix: a picture prints as the picture, where and as large as the teacher asks
The black bars above and below a printed picture were neither in the
picture nor in the layout. A PNG or JPEG in a tab of its own is shown by
the engine on a viewer page whose background is rgb(14, 14, 14), and Qt
prints page backgrounds by default; 0.2.6.1 printed that page.

A picture is now taken as itself — drawn onto white, capped at 300 dpi
across Letter — and laid out on the printer's paper by the preview, with
Size (as large as fits down to 25%), Position (nine squares), Paper
(automatic, upright, sideways) and Fill the whole page, which says it
cuts the edges off and is never the default. The preview is the PDF that
prints, laid out again after each change. Worksheet PDFs and lesson pages
get no picture controls and are unchanged.

Page backgrounds are no longer printed for lesson pages either, as in
Chrome; Blink darkens light text when it does that, so a white-on-black
page prints readable grey on white. "Colour" is "Color" in the print
interface and the manual.

Lab: 135 print checks, now including eight picture layouts measured on
the job file CUPS kept — byte-identical to the preview, within 1 point of
where asked, no ink anywhere else. Printed from the real session with the
pointer: 50% top left, Elmer-66, the same. Not yet on paper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 21:41:15 -05:00
44 changed files with 4435 additions and 174 deletions
+1 -1
View File
@@ -1 +1 @@
0.2.6.1
0.2.6.4
+115
View File
@@ -26,6 +26,12 @@ editable_desktop_icons=false
editable_bookmarks=false
movable_toolbars=false
plasma/plasmashell/unlockedDesktop=false
# A locked panel still offered "Configure Digital Clock…" on the clock's
# right-click menu — measured on Plasma 6.3. This is Plasma's own key for
# "no widget settings while the desktop is locked", and with it that entry
# is gone. (The clock's "Adjust Date and Time…" stays, and opens a System
# Settings with nothing in it: every module is restricted below.)
plasma/allow_configure_when_locked=false
plasma-desktop/scripting_console=false
# Nothing in the classroom should offer to file a bug or open a manual.
action/help_report_bug=false
@@ -47,6 +53,115 @@ action/help_contents=false
# is not shown doors that will not open.
[KDE Control Module Restrictions][$i]
kcm_networkmanagement=false
#
# AND EVERY OTHER SETTINGS MODULE. A notification's Configure button —
# measured on Plasma 6.3, from the "Memory Shortage Avoided" notice the
# kernel's memory killer raised — runs `kcmshell6 notifications`, which
# opened KDE's whole notification settings under the teacher's account:
# every application and system service, with Apply. The notification
# applet draws that button without asking this group; kcmshell6 and
# System Settings do ask it, and with the module listed here the same
# click opens only "The module kcm_notifications is disabled ... by the
# system administrator". Measured.
#
# KDE has no wildcard for this, so the list is every module installed by
# Debian 13's Plasma 6.3, as found on the lab machine. A teacher has a use
# for none of them. tools/lab/converged-check fails if a module is ever
# installed that is not listed here, so a Plasma update cannot quietly
# reopen a door.
#
# The maintenance terminal is untouched: it runs with
# XDG_CONFIG_DIRS=/etc/xdg, outside this file, so System Settings started
# from it is the whole of System Settings.
kcm_about-distro=false
kcm_access=false
kcm_activities=false
kcm_audio_information=false
kcm_autostart=false
kcm_baloofile=false
kcm_block_devices=false
kcm_bluetooth=false
kcm_bolt=false
kcm_clock=false
kcm_colors=false
kcm_componentchooser=false
kcm_cpu=false
kcm_cursortheme=false
kcm_desktoppaths=false
kcm_desktoptheme=false
kcm_device_automounter=false
kcm_disks=false
kcm_edid=false
kcm_egl=false
kcm_energyinfo=false
kcm_feedback=false
kcm_filetypes=false
kcm_firewall=false
kcm_firmware_security=false
kcm_fontinst=false
kcm_fonts=false
kcm_gamecontroller=false
kcm_glx=false
kcm_icons=false
kcm_interrupts=false
kcm_kded=false
kcm_keyboard=false
kcm_keys=false
kcm_kgamma=false
kcm_krunner_kill=false
kcm_krunnersettings=false
kcm_kscreen=false
kcm_kwallet5=false
kcm_kwin_effects=false
kcm_kwin_scripts=false
kcm_kwin_virtualdesktops=false
kcm_kwindecoration=false
kcm_kwinoptions=false
kcm_kwinrules=false
kcm_kwinscreenedges=false
kcm_kwinsupportinfo=false
kcm_kwintabbox=false
kcm_kwintouchscreen=false
kcm_kwinxwayland=false
kcm_landingpage=false
kcm_lookandfeel=false
kcm_memory=false
kcm_mouse=false
kcm_netpref=false
kcm_network=false
kcm_nightlight=false
kcm_notifications=false
kcm_opencl=false
kcm_pci=false
kcm_plasmasearch=false
kcm_powerdevilprofilesconfig=false
kcm_proxy=false
kcm_pulseaudio=false
kcm_qtquicksettings=false
kcm_recentFiles=false
kcm_regionandlang=false
kcm_samba=false
kcm_screenlocker=false
kcm_sddm=false
kcm_smserver=false
kcm_solid_actions=false
kcm_soundtheme=false
kcm_splashscreen=false
kcm_style=false
kcm_tablet=false
kcm_touchpad=false
kcm_touchscreen=false
kcm_usb=false
kcm_users=false
kcm_virtualkeyboard=false
kcm_vulkan=false
kcm_wallpaper=false
kcm_wayland=false
kcm_webshortcuts=false
kcm_workspace=false
kcm_xserver=false
kcmspellchecking=false
kwincompositing=false
# Not immutable. Marking this group [$i] froze the colour scheme so hard
# that `classroom-theme light` changed the running session and then lost
+97
View File
@@ -0,0 +1,97 @@
# Backlog — 0.3.0 and later
Recorded after hands-on testing of 0.2.6.1 in September 2026. **None of
this is in 0.2.6.2 to 0.2.6.4**: 0.2.6.2 is limited to picture printing,
the Presentations memory failure and the settings lockdown, 0.2.6.3 to
the black-and-white preview and Print image, and 0.2.6.4 to temporary
workspace restore (the two items that were here about links and sign-in
retention are answered in wonder-ink.md). Each item says what was
observed, so whoever picks it up starts from the evidence.
## Wonder Ink
- **An interface like Chrome's, visibly the classroom's.** Familiar tabs
and back/forward/reload, without an address bar, new-tab button or
anything that takes a teacher somewhere the lesson did not. The 0.2.6.1
tab strip is a step towards it, not the design.
- **The PDF viewer's own Download button.** It saves a copy, which the
download handler then opens as another worksheet tab. Remove it, or
intercept the download from a worksheet tab so it does nothing visible.
(0.2.2 already skips re-opening a save from a worksheet tab — see
`from_worksheet` in `on_download` — so check first which route it now
takes.)
- **One Print, not two.** A worksheet tab has the viewer's printer icon
and Wonder Ink's Print; both now open the same preview, but a teacher
sees two buttons. One teacher-facing workflow.
- **Readable worksheet names.** Tabs show the downloaded file's name,
which for many worksheets is a long generated prefix. Strip it, or take
the name from the PDF's own title.
- **Print image and CSS background pictures** (from 0.2.6.3). The engine's
hit test does not call a CSS background a picture, so right-click there
offers only Print page. Only worth doing if Sunday's testing finds
Wonder Ink's site draws artwork that way; the lab has no account to
tell. See wonder-ink.md, *Print image*.
- **The clipboard after Print image** (from 0.2.6.3). It is put back, but
owned by Wonder Ink afterwards, so text copied elsewhere before is gone
once Wonder Ink closes — there is no clipboard manager in the session.
Measured, and judged smaller than leaving the picture there.
- **A speaker on the tab that is playing sound** (`QWebEnginePage.recentlyAudible`).
- **A picture in its own tab, in a room above 100%** (seen testing
0.2.6.4). Page zoom is the room's `UI_SCALE`, and the engine's picture
viewer takes it too, so at 200% a slide opened from a lesson is shown
twice its size with scrollbars rather than fitted to the window, as
Chrome shows it. Printing is unaffected.
- **Staying signed in, if WonderInk ever issues a real session token.**
Today its sign-in is the teacher's email and password in a Basic-auth
cookie (measured, 0.2.6.4), so keeping it would mean keeping a password;
Wonder Ink does not. Worth asking WonderInk for a server-issued token
that can be revoked; until then, closing Wonder Ink signs the teacher
out, and only the recent tabs come back.
- **Workspace time as a room setting**, if two hours turns out wrong for
some room: `IDLE_LIMIT_SECONDS` in `classroom_wonderink_workspace.py` is
the one place, and a device.conf key would be a small change.
- **The site's sluggishness, where it is ours.** Measured in 0.2.6.1:
Wonder Ink and Chrome take the same time (about 2.9 s cold) and the
site is the weight — 119 – 160 requests, 3.2 MB, the Google Maps API
loaded twice. Ours to look at: whether a classroom's QtWebEngine has its
GPU (`classroom-wonderink --diagnose` on the machines), and the page
shell being revalidated on every warm load (62 – 87 ms).
- **The browser is scaled twice** under Plasma (dpr 2.25 at 150%):
`classroom-browser` forces `UI_SCALE` on a Chrome that already follows
the compositor. Deliberately left in 0.2.6.x because teachers know that
size, and Wonder Ink was matched to it. Decide once, for both.
## Presentations
- **A normal, maximisable application** instead of the small floating
library window.
- **Consistently sized tiles in a responsive grid.**
- **No unnecessary internal scrollbars or stretched layouts.** 0.2.6.2
had to give a row extra height by hand when a card carries the
memory-failure message; the layout should size to its cards.
- **Keep the window's size and position** when importing a presentation
or changing a cover.
- **Click the slide to advance.**
- **Hide the pointer after a moment of stillness while presenting**, and
bring it back on movement.
- **Keep the keyboard controls**, and never advance when a playback
control itself is clicked.
- **Remote control of a presentation** — later than 0.3.0.
## The launcher and Parent Call
- **Remove the subtitles under DVD and Presentations** on the launcher.
- **Parent Call appears among the ordinary applications for a moment and
then disappears**, which looks like a crash. Keep it reachable through
its background or notification route without it flashing up as a
window.
## The settings lockdown
- A notification's **Configure** button still shows, and opens a small
"module disabled" window (0.2.6.2). Hiding it needs a change to Plasma's
notification applet, not configuration — revisit if Plasma gains a
Kiosk key for it.
- The settings-module list is per Plasma release. `tools/lab/converged-check`
fails when a new one appears; the list in `config/plasma/xdg/kdeglobals`
then needs the new name.
+42
View File
@@ -116,3 +116,45 @@ and nothing needs reinstalling.
To put the session screen back, remove the D-Bus shadow and the
`kglobalshortcutsrc` entries; both are package files, so an update will
restore them, which is the intended direction.
## Settings the teacher must not reach (0.2.6.2)
During testing the kernel's memory killer raised Plasma's *Memory Shortage
Avoided* notice, and its **Configure** button opened KDE's Notifications
settings under the teacher's account — every application and system
service, with Apply. Reproduced in the lab: the button runs
`kcmshell6 notifications`.
Plasma draws that button on any notification whose application has KDE
notification settings, without asking the Kiosk policy. What does ask is
the thing it launches: `kcmshell6` and System Settings refuse a module
listed under `[KDE Control Module Restrictions]`. KDE has no wildcard for
that group, so **every settings module Debian 13's Plasma 6.3 installs —
90, found on the lab machine — is listed**, immutable, in the classroom's
`kdeglobals`. Measured afterwards:
| Entry point | Before | After |
| --- | --- | --- |
| A notification's Configure button | Notifications settings, editable | *The module kcm_notifications is disabled … by the system administrator* |
| That window's **Help** button | KDE Help Center: menus, man pages, file dialogs | not installed (`Conflicts: khelpcenter`; plasma-desktop only recommends it) |
| Volume: right-click | *Configure Audio Devices…* | entry gone — the applet asks the policy |
| Clock: right-click | *Adjust Date and Time…*, *Configure Digital Clock…* | *Configure* gone (`plasma/allow_configure_when_locked=false`); *Adjust* opens a System Settings with nothing in it |
| Bell, empty panel: right-click | no menu | no menu |
| Network | already refused (0.2.4) | unchanged |
The button itself still shows on such a notification, and still opens the
small "disabled" window: hiding it would mean replacing Plasma's
notification applet, which is exactly what this appliance does not do.
Notifications themselves are untouched — Parent Call, the room's
messages, Wonder Ink's "Sent to the printer" all still appear.
**The maintenance route is unaffected.** `classroom-admin-terminal` starts
Konsole with `XDG_CONFIG_DIRS=/etc/xdg`, outside this file, so anything a
technician starts from it — `systemsettings`, `kcmshell6 kcm_notifications`
— is the whole of it. Verified in the lab: the same module refused to the
teacher opened fully, editable, from the maintenance terminal.
`tools/lab/converged-check` fails if any settings module on the machine is
not refused in the classroom's cascade, so a Plasma update that adds one
cannot quietly reopen this; and it checks the maintenance cascade still
refuses nothing.
+36 -5
View File
@@ -32,11 +32,28 @@ The size applies to every tab and is remembered next time.
## Printing
Press **Ctrl** and **P**, click **Print** at the top right, or right-click
and choose **Print…**. You will see the pages exactly as they will print.
and choose **Print page…**. You will see the pages exactly as they will
print.
* Choose **Only some pages** to print, for example, `2` or `1-3`.
* Set **Copies** for the number of children.
* Choose **Black and white** to save colour ink.
* Choose **Black and white** to save color ink. The preview turns grey,
so you see what will come out; choose **Color** to go back.
**Printing a picture from the lesson.** Right-click the picture and
choose **Print image…**. Only that picture prints, not the page around
it. This works for pictures you cannot open by themselves.
In the preview for a picture, also under **Picture**: choose its
**Size** (for example 50%), click one of the nine **Position** squares to
put it in a corner or the middle, and choose **Paper** upright or
sideways. The preview changes as you choose, and shows exactly what will
print. **Fill the whole page** makes it cover the paper but cuts off the
edges. A picture that is open in a tab by itself prints the same way.
If Wonder Ink says a picture *could not be taken from the page*, it had
not finished loading: close the preview and try again, or choose **Print
page…** to print the whole page instead.
Click **Print**. Wonder Ink says when the printer has it and goes back to
the lesson. **Cancel** prints nothing.
@@ -44,14 +61,28 @@ the lesson. **Cancel** prints nothing.
## Worksheets and colouring pages
Click the worksheet link in the lesson. It opens in a new tab, ready to
read or print. A worksheet's own **−** and **+** at the top of the page
make it bigger or smaller.
read or print. Any other link in a lesson — a slide, a picture, a video —
also opens in a new tab with one click, and Wonder Ink shows it straight
away; the lesson stays open in its own tab. A worksheet's own **−** and
**+** at the top of the page make it bigger or smaller.
Worksheets you download are kept separately from everything else on the
computer, so they never get mixed up with anything.
## If Wonder Ink closes by accident
Open it again. If you used it in the last two hours, **your recent tabs
come back** — the slides, pictures and worksheets you had open, in the
same order. Wonder Ink's own lesson tab comes back at the sign-in page:
sign in again and open your lesson.
Your password is never kept. After two hours that nobody has used Wonder
Ink, it forgets the tabs and the next person starts with a fresh Wonder
Ink.
## When you have finished
**Close Wonder Ink at the end of your session.** Closing it signs you
out, so the next teacher in this room starts with their own account and
not yours.
not yours. Your recent tabs are kept for two hours in case you need them
again, but never your sign-in.
+11 -5
View File
@@ -43,7 +43,7 @@ figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
<body>
<div class="sheet">
<h1>Classroom manual</h1>
<p class="revision">River of Glory &middot; Revision 23 September 2026</p>
<p class="revision">River of Glory &middot; Revision 26 September 2026</p>
<nav id='contents'></nav>
<section id="00-welcome">
<h2>Your classroom computer</h2>
@@ -135,14 +135,20 @@ figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
<p>Press <strong>Ctrl</strong> and <strong>+</strong> to make the lesson bigger, <strong>Ctrl</strong> and <strong>−</strong> to make it smaller, and <strong>Ctrl</strong> and <strong>0</strong> to go back to normal. You can also right-click the lesson and choose <strong>Make the lesson bigger</strong>. When several tabs are open, the <strong>− 100% +</strong> buttons at the top right do the same.</p>
<p>The size applies to every tab and is remembered next time.</p>
<h3>Printing</h3>
<p>Press <strong>Ctrl</strong> and <strong>P</strong>, click <strong>Print</strong> at the top right, or right-click and choose <strong>Print…</strong>. You will see the pages exactly as they will print.</p>
<ul><li>Choose <strong>Only some pages</strong> to print, for example, `2` or `1-3`.</li><li>Set <strong>Copies</strong> for the number of children.</li><li>Choose <strong>Black and white</strong> to save colour ink.</li></ul>
<p>Press <strong>Ctrl</strong> and <strong>P</strong>, click <strong>Print</strong> at the top right, or right-click and choose <strong>Print page…</strong>. You will see the pages exactly as they will print.</p>
<ul><li>Choose <strong>Only some pages</strong> to print, for example, `2` or `1-3`.</li><li>Set <strong>Copies</strong> for the number of children.</li><li>Choose <strong>Black and white</strong> to save color ink. The preview turns grey, so you see what will come out; choose <strong>Color</strong> to go back.</li></ul>
<p><strong>Printing a picture from the lesson.</strong> Right-click the picture and choose <strong>Print image…</strong>. Only that picture prints, not the page around it. This works for pictures you cannot open by themselves.</p>
<p>In the preview for a picture, also under <strong>Picture</strong>: choose its <strong>Size</strong> (for example 50%), click one of the nine <strong>Position</strong> squares to put it in a corner or the middle, and choose <strong>Paper</strong> upright or sideways. The preview changes as you choose, and shows exactly what will print. <strong>Fill the whole page</strong> makes it cover the paper but cuts off the edges. A picture that is open in a tab by itself prints the same way.</p>
<p>If Wonder Ink says a picture *could not be taken from the page*, it had not finished loading: close the preview and try again, or choose <strong>Print page…</strong> to print the whole page instead.</p>
<p>Click <strong>Print</strong>. Wonder Ink says when the printer has it and goes back to the lesson. <strong>Cancel</strong> prints nothing.</p>
<h3>Worksheets and colouring pages</h3>
<p>Click the worksheet link in the lesson. It opens in a new tab, ready to read or print. A worksheet&#x27;s own <strong>−</strong> and <strong>+</strong> at the top of the page make it bigger or smaller.</p>
<p>Click the worksheet link in the lesson. It opens in a new tab, ready to read or print. Any other link in a lesson — a slide, a picture, a video — also opens in a new tab with one click, and Wonder Ink shows it straight away; the lesson stays open in its own tab. A worksheet&#x27;s own <strong>−</strong> and <strong>+</strong> at the top of the page make it bigger or smaller.</p>
<p>Worksheets you download are kept separately from everything else on the computer, so they never get mixed up with anything.</p>
<h3>If Wonder Ink closes by accident</h3>
<p>Open it again. If you used it in the last two hours, <strong>your recent tabs come back</strong> — the slides, pictures and worksheets you had open, in the same order. Wonder Ink&#x27;s own lesson tab comes back at the sign-in page: sign in again and open your lesson.</p>
<p>Your password is never kept. After two hours that nobody has used Wonder Ink, it forgets the tabs and the next person starts with a fresh Wonder Ink.</p>
<h3>When you have finished</h3>
<p><strong>Close Wonder Ink at the end of your session.</strong> Closing it signs you out, so the next teacher in this room starts with their own account and not yours.</p>
<p><strong>Close Wonder Ink at the end of your session.</strong> Closing it signs you out, so the next teacher in this room starts with their own account and not yours. Your recent tabs are kept for two hours in case you need them again, but never your sign-in.</p>
<p><a class="top" href="#contents">Back to the top</a></p>
</section>
<section id="60-parent-call" data-feature="PARENT_CALL_ENABLED">
+95 -66
View File
@@ -3,47 +3,50 @@
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R] /Count 11 >>
<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /Count 12 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 14 0 R >>
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 15 0 R >>
endobj
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 15 0 R >>
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 16 0 R >>
endobj
5 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 16 0 R >>
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 17 0 R >>
endobj
6 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 17 0 R >>
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 18 0 R >>
endobj
7 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 18 0 R >>
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 19 0 R >>
endobj
8 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 19 0 R >>
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 20 0 R >>
endobj
9 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 20 0 R >>
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 21 0 R >>
endobj
10 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 21 0 R >>
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 22 0 R >>
endobj
11 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 22 0 R >>
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 23 0 R >>
endobj
12 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 23 0 R >>
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 24 0 R >>
endobj
13 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 24 0 R >>
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 25 0 R >>
endobj
14 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 26 0 R >>
endobj
15 0 obj
<< /Length 765 >>
stream
BT /F2 26 Tf 1 0 0 1 62.0 720.0 Tm (Classroom manual) Tj ET
BT /F1 13 Tf 1 0 0 1 62.0 677.1 Tm (River of Glory) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 656.6 Tm (Revision 23 September 2026) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 656.6 Tm (Revision 26 September 2026) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 623.7 Tm (This manual covers every classroom computer in the church. Not every room has every button, so) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 608.8 Tm (some pages begin by saying when they apply. If your computer does not have what a page describes,) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 593.9 Tm (that page is not for your room.) Tj ET
@@ -51,7 +54,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 569.0 Tm (Keep this copy in the room. If the date ab
BT /F1 10.5 Tf 1 0 0 1 62.0 554.1 Tm (the office for a new one.) Tj ET
endstream
endobj
15 0 obj
16 0 obj
<< /Length 1250 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Your classroom computer) Tj ET
@@ -68,7 +71,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 580.7 Tm (The right-hand end shows the network, the
BT /F1 10.5 Tf 1 0 0 1 62.0 560.8 Tm (To open something, click its button once. To come back to it later, click the same button again.) Tj ET
endstream
endobj
16 0 obj
17 0 obj
<< /Length 1408 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (The Browser) Tj ET
@@ -89,7 +92,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 518.2 Tm (Anything you sign in to yourself - a perso
BT /F1 10.5 Tf 1 0 0 1 62.0 503.3 Tm (The computer is shared.) Tj ET
endstream
endobj
17 0 obj
18 0 obj
<< /Length 1694 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Sound and volume) Tj ET
@@ -113,7 +116,7 @@ BT /F1 10.5 Tf 1 0 0 1 94.0 512.3 Tm (across.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 492.4 Tm (If none of that works, tell the office. Do not unplug anything.) Tj ET
endstream
endobj
18 0 obj
19 0 obj
<< /Length 4084 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Presentations) Tj ET
@@ -164,7 +167,7 @@ BT /F1 10.5 Tf 1 0 0 1 94.0 113.3 Tm (Click Use this slide as the picture.) Tj E
BT /F1 10.5 Tf 1 0 0 1 62.0 93.4 Tm (The card changes straight away, and stays that way - after you close the library, and after the) Tj ET
endstream
endobj
19 0 obj
20 0 obj
<< /Length 3089 >>
stream
BT /F1 10.5 Tf 1 0 0 1 62.0 780.0 Tm (computer is switched off and on again.) Tj ET
@@ -205,8 +208,8 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 223.3 Tm (Its card says so, and offers only Remove.
BT /F1 10.5 Tf 1 0 0 1 62.0 203.4 Tm (Download it again. Your original download is still there either way.) Tj ET
endstream
endobj
20 0 obj
<< /Length 3431 >>
21 0 obj
<< /Length 4396 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Wonder Ink) Tj ET
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
@@ -227,27 +230,51 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 516.2 Tm (back to normal. You can also right-click t
BT /F1 10.5 Tf 1 0 0 1 62.0 501.3 Tm (several tabs are open, the ? 100% + buttons at the top right do the same.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 481.4 Tm (The size applies to every tab and is remembered next time.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 453.4 Tm (Printing) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 432.7 Tm (Press Ctrl and P, click Print at the top right, or right-click and choose Print?. You will see) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 417.8 Tm (the pages exactly as they will print.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 432.7 Tm (Press Ctrl and P, click Print at the top right, or right-click and choose Print page?. You will) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 417.8 Tm (see the pages exactly as they will print.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 397.9 Tm (-) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 397.9 Tm (Choose Only some pages to print, for example, `2` or `1-3`.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 383.0 Tm (-) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 383.0 Tm (Set Copies for the number of children.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 368.1 Tm (-) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 368.1 Tm (Choose Black and white to save colour ink.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 348.1 Tm (Click Print. Wonder Ink says when the printer has it and goes back to the lesson. Cancel prints) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 333.2 Tm (nothing.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 305.3 Tm (Worksheets and colouring pages) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 284.6 Tm (Click the worksheet link in the lesson. It opens in a new tab, ready to read or print. A) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 269.7 Tm (worksheet's own ? and + at the top of the page make it bigger or smaller.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 249.8 Tm (Worksheets you download are kept separately from everything else on the computer, so they never) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 234.8 Tm (get mixed up with anything.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 206.9 Tm (When you have finished) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 186.2 Tm (Close Wonder Ink at the end of your session. Closing it signs you out, so the next teacher in) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 171.3 Tm (this room starts with their own account and not yours.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 368.1 Tm (Choose Black and white to save color ink. The preview turns grey, so you see what will come) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 353.1 Tm (out; choose Color to go back.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 333.2 Tm (Printing a picture from the lesson. Right-click the picture and choose Print image?. Only that) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 318.3 Tm (picture prints, not the page around it. This works for pictures you cannot open by themselves.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 298.4 Tm (In the preview for a picture, also under Picture: choose its Size \(for example 50%\), click one of) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 283.5 Tm (the nine Position squares to put it in a corner or the middle, and choose Paper upright or) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 268.6 Tm (sideways. The preview changes as you choose, and shows exactly what will print. Fill the whole) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 253.7 Tm (page makes it cover the paper but cuts off the edges. A picture that is open in a tab by itself) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 238.8 Tm (prints the same way.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 218.9 Tm (If Wonder Ink says a picture *could not be taken from the page*, it had not finished loading:) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 203.9 Tm (close the preview and try again, or choose Print page? to print the whole page instead.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 184.0 Tm (Click Print. Wonder Ink says when the printer has it and goes back to the lesson. Cancel prints) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 169.1 Tm (nothing.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 141.2 Tm (Worksheets and colouring pages) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 120.5 Tm (Click the worksheet link in the lesson. It opens in a new tab, ready to read or print. Any other) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 105.6 Tm (link in a lesson - a slide, a picture, a video - also opens in a new tab with one click, and Wonder) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 90.7 Tm (Ink shows it straight away; the lesson stays open in its own tab. A worksheet's own ? and + at the) Tj ET
endstream
endobj
21 0 obj
22 0 obj
<< /Length 1446 >>
stream
BT /F1 10.5 Tf 1 0 0 1 62.0 780.0 Tm (top of the page make it bigger or smaller.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 760.1 Tm (Worksheets you download are kept separately from everything else on the computer, so they never) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 745.2 Tm (get mixed up with anything.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 717.3 Tm (If Wonder Ink closes by accident) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 696.5 Tm (Open it again. If you used it in the last two hours, your recent tabs come back - the slides,) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 681.6 Tm (pictures and worksheets you had open, in the same order. Wonder Ink's own lesson tab comes back at) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 666.7 Tm (the sign-in page: sign in again and open your lesson.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 646.8 Tm (Your password is never kept. After two hours that nobody has used Wonder Ink, it forgets the) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 631.9 Tm (tabs and the next person starts with a fresh Wonder Ink.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 604.0 Tm (When you have finished) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 583.2 Tm (Close Wonder Ink at the end of your session. Closing it signs you out, so the next teacher in) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 568.3 Tm (this room starts with their own account and not yours. Your recent tabs are kept for two hours in) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 553.4 Tm (case you need them again, but never your sign-in.) Tj ET
endstream
endobj
23 0 obj
<< /Length 1475 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Parent Call) Tj ET
@@ -270,7 +297,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 516.2 Tm (when it did not.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 496.3 Tm (If that happens, send somebody.) Tj ET
endstream
endobj
22 0 obj
24 0 obj
<< /Length 1500 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Playing a DVD) Tj ET
@@ -294,7 +321,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 487.4 Tm (Click X on that row to stop, or eject to s
BT /F1 10.5 Tf 1 0 0 1 62.0 467.5 Tm (Take the disc with you.) Tj ET
endstream
endobj
23 0 obj
25 0 obj
<< /Length 1166 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Printing) Tj ET
@@ -313,7 +340,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 591.8 Tm (in the queue and prints when the printer i
BT /F1 10.5 Tf 1 0 0 1 62.0 571.9 Tm (Sending it again two or three times is how ten copies of the same worksheet appear later.) Tj ET
endstream
endobj
24 0 obj
26 0 obj
<< /Length 1766 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (If something goes wrong) Tj ET
@@ -337,43 +364,45 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 411.0 Tm (Tell the office, and say which room and wh
BT /F1 10.5 Tf 1 0 0 1 62.0 396.1 Tm (the one that saves everybody time.) Tj ET
endstream
endobj
25 0 obj
27 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
endobj
26 0 obj
28 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
endobj
xref
0 27
0 29
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000180 00000 n
0000000319 00000 n
0000000458 00000 n
0000000597 00000 n
0000000736 00000 n
0000000875 00000 n
0000001014 00000 n
0000001153 00000 n
0000001293 00000 n
0000001433 00000 n
0000001573 00000 n
0000001713 00000 n
0000002530 00000 n
0000003833 00000 n
0000005294 00000 n
0000007041 00000 n
0000011178 00000 n
0000014320 00000 n
0000017804 00000 n
0000019332 00000 n
0000020885 00000 n
0000022104 00000 n
0000023923 00000 n
0000024021 00000 n
0000000187 00000 n
0000000326 00000 n
0000000465 00000 n
0000000604 00000 n
0000000743 00000 n
0000000882 00000 n
0000001021 00000 n
0000001160 00000 n
0000001300 00000 n
0000001440 00000 n
0000001580 00000 n
0000001720 00000 n
0000001860 00000 n
0000002677 00000 n
0000003980 00000 n
0000005441 00000 n
0000007188 00000 n
0000011325 00000 n
0000014467 00000 n
0000018916 00000 n
0000020415 00000 n
0000021943 00000 n
0000023496 00000 n
0000024715 00000 n
0000026534 00000 n
0000026632 00000 n
trailer
<< /Size 27 /Root 1 0 R >>
<< /Size 29 /Root 1 0 R >>
startxref
24124
26735
%%EOF
+58
View File
@@ -128,6 +128,60 @@ A file that cannot be converted gets a card saying so, offering only
**Remove**. Its managed original remains recoverable even when PDF or page
generation fails.
## Memory (0.2.6.2)
On a 4 GB Plasma VM the kernel killed `ekklesia-classroom-presentations.service`
mid-import and Plasma said *Memory Shortage Avoided*. Measured afterwards,
LibreOffice converting one deck with the importer's own code:
| Deck | PPTX | LibreOffice peak | Rendering peak |
| --- | --- | --- | --- |
| 10 slides of text | 20 KB | 203 MB, 0.8 s | 38 MB |
| 30 slides, 12 photos shared | 23.5 MB | 738 MB, 16 s | 70 MB |
| 60 slides, the same 12 photos | 23.6 MB | 754 MB, 28 s | 70 MB |
| 30 slides, 30 different 12 MP photos | 58.7 MB | **1.43 GB**, 22 s | 69 MB |
| 20 slides, 20 different 27 MP photos | 143 MB | **2.08 GB**, 24 s | 70 MB |
It grows with the photographs, not the slides, and it is a spike rather
than a leak. PDF export with images reduced to 200 dpi saved only 5%,
because LibreOffice holds the full images regardless. So the failure was a
photo-heavy deck meeting a 4 GB machine with the room's other programs
open — and what made it a problem was what happened next. systemd's
default `OOMPolicy=stop` took the whole service down with the killed
LibreOffice; it restarted, `sweep()` resumed the same conversion with no
limit, and the library said *Getting this ready…* throughout.
Now:
* **`MemoryMax=40%`** — 1.6 GB on a 4 GB machine, 3.2 GB on 8 GB. Measured
in the lab at 1.6 GB, the 2.1 GB deck still imported in 33 s: much of
that peak is file-backed and reclaimable, and on a machine with swap the
rest is swapped rather than killed (1.4 GB of it at a 200 MB ceiling).
* **`OOMScoreAdjust=800`** — under pressure everywhere, LibreOffice goes
before the lesson on the screen, the browser or the panel.
* **`OOMPolicy=continue`** — a killed LibreOffice is a failed import, not a
dead service. Measured with no swap and a 300 MB ceiling: the kernel
killed LibreOffice, the watcher kept its PID with no restart, and the
import was recorded as `failed`, kind `memory`.
* **Two attempts, then stop.** Each attempt is counted before it starts.
Measured by killing the whole service during attempt 1 and again during
attempt 2: it resumed once, then said "giving up … the teacher can try
again" and did not start a third.
* **The card says so**, and offers **Try again**: *This needed more memory
than the computer had free. Close other programs, then choose Try
again.* Try again was pressed in the lab and the deck became ready.
A file that simply will not convert still says *This file could not be
opened* and offers no Try again, because trying again will not help.
* **Never stuck.** An import still "getting ready" 20 minutes after it
began — longer than any attempt can take — is shown as the same Try
again card, because nothing is working on it.
* **One import at a time**, by an exclusive lock as well as the one loop;
measured, three decks downloaded together converted one after another
with never more than one LibreOffice running.
What the lab cannot give is a real 4 GB machine's mix of programs; the
ceilings were emulated per service.
## The three things kept apart
This is the part most worth not breaking.
@@ -226,3 +280,7 @@ classroom-manual --features which manual pages this device shows
`--status` prints titles, slide counts, the chosen thumbnail slide, both
timers and the expiry date. It is the fastest way to answer "why has this
gone" and "why has this not appeared".
## Later
The library's next round — a maximisable window, a tile grid, click to advance, a hidden pointer — is in [backlog.md](backlog.md).
+12
View File
@@ -149,6 +149,18 @@ nothing else about the room's locks moves.
| `notif-13pt`, `notif-15pt` | the two font sizes that were weighed for it |
| `pc-typed-layer` | the card gone: the lab's keyboard tool closes it, which is why typing is still a bench test |
## Wonder Ink printing
Under the lab's real Plasma session, driven with the pointer. See
[wonder-ink.md](../wonder-ink.md), *Printing*.
| | |
| --- | --- |
| `pic-preview-default`, `pic-preview-half-topleft` | a picture in a tab of its own, laid out on Letter: as large as fits, then 50% top left (0.2.6.2) |
| `print-image-menu` | right-click on a picture inside a lesson: **Print image…** and **Print page…**; on the words there is only Print page (0.2.6.3) |
| `print-image-bw-half-topleft` | Print image on a signed-in picture, 50% top left, Black and white — the preview grey, as the job CUPS kept was (0.2.6.3) |
| `workspace-restored` | Wonder Ink reopened after an accidental close: the lesson's WonderInk tab at its sign-in page, its slide in front, its PDF behind (0.2.6.4, the real site) |
## Failures, kept
| | |
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

+112 -11
View File
@@ -575,14 +575,14 @@ ifupdown stanza is already gone. **Do not run `--adopt` on it.**
still reports `yes`
- [ ] The laptop still reconnects after a reboot
## Wonder Ink — 0.2.6.1, before it goes to stable
## Wonder Ink — 0.2.6.1 to 0.2.6.4, before any goes to stable
Everything below was done in the lab except what a lab cannot do: paper,
a classroom's own graphics, a real keyboard, its network and its screen.
Those are these checks. Stable stays on 0.2.2 until they pass.
Before starting, on each machine: `dpkg-query -W ekklesia-classroom`
must say `0.2.6.1`. The desktops only get it once stable is moved, so on
must say `0.2.6.4`, which carries everything 0.2.6.1 to 0.2.6.3 did. The desktops only get it once stable is moved, so on
a desktop install it by hand for the test:
```
@@ -600,20 +600,70 @@ after testing, either promote or reinstall 0.2.2 the same way.
- [ ] Open Wonder Ink, sign in, open a lesson with a worksheet
- [ ] Click the worksheet; it opens as a tab named after the file, with a
document icon
- [ ] Ctrl+P (or right-click → Print…): the preview shows the worksheet's
- [ ] Ctrl+P (or right-click → Print page…): the preview shows the worksheet's
own pages, page count right, ‹ › turn them
- [ ] Printer shows **Elmer**, preselected
- [ ] Print → a notification "Sent to the printer — … — Elmer", Wonder Ink
stays open on the lesson
- [ ] **The worksheet comes out of Elmer**, every page, right size, in colour
- [ ] Pages `2`, Copies `2`, Black and white → exactly two grey copies of
page 2 come out
- [ ] Pages `2`, Copies `2`, Black and white → the preview turns grey,
and exactly two grey copies of page 2 come out
- [ ] Print a normal lesson page the same way; it comes out, Letter
- [ ] Open the preview and press Cancel → nothing prints
- [ ] Print three times in a row → three printouts, no crash
- [ ] `lpstat -W completed -o` shows each job; `journalctl --user -b | grep
"printed '"` shows Wonder Ink's line for each
### Pictures (0.2.6.2)
- [ ] Open a colouring page that is a picture on its own (a PNG or JPEG
tab, not a PDF) and press Print: the preview shows it on white — **no
black bars** — with a **Picture** section under Color
- [ ] Size 50%, the top-left square: the preview shows it there, the
rest of the page blank. Print it: **the paper matches the preview**
- [ ] Paper *Sideways*, then *Upright*: the preview turns; print one
- [ ] *Fill the whole page*: the preview shows the edges cut off; Cancel
prints nothing
- [ ] A worksheet PDF still prints with no Picture section
- [ ] The print dialog says **Color**, not Colour
### Black and white, and Print image (0.2.6.3)
The lab measured all of this on the files CUPS kept, and under the real
Plasma session for the clipboard. What it cannot show is Wonder Ink's own
site (no account), real paper, and a classroom's own keyboard and mouse.
- [ ] A lesson page with colour on it: Ctrl+P, then **Black and white** →
the preview says *Preparing black and white…* for a moment, then the
page is **grey**. **Color** → colour again. Back and forth three
times: it is instant after the first, and stays on the same page
- [ ] Print it in black and white: **the paper matches the grey preview**
- [ ] The same with a worksheet PDF (a few pages), in black and white
- [ ] Choose Black and white, then Color, then Print → it comes out **in
colour**
- [ ] In a lesson, **right-click on a picture** (not the words): the menu
has **Print image…** and **Print page…**. Right-click on the words:
only **Print page…**
- [ ] **Print image…** on a picture you cannot open in a tab by itself:
the preview shows *that picture* on white, with the **Picture**
section — not the lesson page
- [ ] Size **50%**, the **top-left** square, Black and white → the preview
shows a grey picture in the top-left quarter. Print it: **the paper
matches the preview**
- [ ] Print image on a picture with a transparent background (a clipart
animal, a logo) → white around it on paper, not black
- [ ] Print image, then **Cancel** → nothing prints
- [ ] Copy some words in the browser, then use Print image in Wonder
Ink, then paste in the browser → the words paste, not the picture
- [ ] If any picture says *could not be taken from the page*: note which
lesson and which picture. Right-click the same picture again once
the page has finished loading. If it still says so, that picture is
drawn in a way the engine does not treat as a picture — tell us
where it is
- [ ] If right-clicking an obvious picture offers **only** Print page, the
site draws it as a background. Note which lesson — it is the one
case Print image cannot reach
### Size and zoom
- [ ] A lesson in Wonder Ink is the same size as the same page in the
@@ -642,12 +692,63 @@ classroom-wonderink --diagnose
with a driver named above points at the network
- [ ] `free -m`: Wonder Ink needs about 610 MB plus 115 MB per extra tab
### Signing out
### Temporary workspace restore (0.2.6.4) — Little Waders
- [ ] Sign in, close Wonder Ink, open it again → **signed out**. (Cookies
are session-only and measured so; the site's Local Storage is not
cleared, which is the one way this could fail and has never been
tested with a real account)
Use a **Viewer** account. An Admin sees lessons in WonderInk's editor,
where a click on a link shows its Edit/Unlink bubble — WonderInk's design,
not Wonder Ink's; Ctrl+click opens it. See wonder-ink.md, *Links in a
lesson*.
1. [ ] Sign in to the real WonderInk site
2. [ ] Open three lesson/resource tabs: the lesson, and two of its links —
for example *Saul Welcome Question Slide* and a video or worksheet
3. [ ] Click the **middle** tab
4. [ ] Close Wonder Ink (the × at the top right)
5. [ ] Open it again straight away from the panel
6. [ ] The WonderInk tab asks you to **sign in** — sign in normally (your
password is never kept, so it always will)
7. [ ] The **recent tabs are back**: the slide and the other resource
8. [ ] **In the same order** as before
9. [ ] With the **middle tab in front**
10. [ ] Open a worksheet from a lesson, then close and reopen Wonder Ink:
the worksheet tab comes back, named after the file
11. [ ] Print from a restored tab (the slide or the worksheet) through
Elmer: **the paper matches the preview**
12. [ ] Links still open with one click in a new tab, as before (Viewer)
13. [ ] With the tabs open, **reboot** the computer; within two hours of
using Wonder Ink, open it again
14. [ ] **The recent tabs are back** (after signing in on the WonderInk tab)
15. [ ] **Expiry, without waiting two hours.** Close Wonder Ink. Open the
maintenance terminal (Meta+Ctrl+Alt+Shift+K) and run
`CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS=120 classroom-wonderink`.
This is **test-only**: it lasts as long as that one Wonder Ink,
can only shorten the time, and nothing in the classroom sets it
16. [ ] Open a couple of tabs in it, click in one, then touch nothing for
**three minutes** and close it
17. [ ] Open Wonder Ink from the panel: it **starts fresh**, one tab on the
WonderInk start page, none of the old tabs
### Presentations under memory pressure (0.2.6.2, the laptop)
- [ ] Download a large, photo-heavy deck with Wonder Ink and Chrome open:
it either becomes ready, or its card says *This needed more memory …
choose Try again* — never *Getting this ready…* for more than a few
minutes
- [ ] Close the other programs and choose **Try again**: it becomes ready
- [ ] `systemctl --user status ekklesia-classroom-presentations` is
active, and `journalctl --user -u ekklesia-classroom-presentations`
shows no restart loop
### Settings the teacher must not reach (0.2.6.2)
- [ ] When any notification shows the sliders (Configure) button, clicking
it opens only *The module … is disabled*; its Help button does
nothing
- [ ] Right-click the clock: no *Configure Digital Clock…*; *Adjust Date
and Time…* opens an empty System Settings
- [ ] Right-click the volume: no *Configure Audio Devices…*
- [ ] The maintenance chord (Meta+Ctrl+Alt+Shift+K) still opens a
terminal, and `systemsettings` started from it is complete
### After a reboot, and the other rooms
@@ -660,4 +761,4 @@ classroom-wonderink --diagnose
says `false`)
If anything here fails, leave the desktops on stable 0.2.2 and fix it in
0.2.6.2 on canary.
the next maintenance release on canary.
+369 -10
View File
@@ -71,7 +71,51 @@ wrong a teacher is standing in front of a class.
The right-click menu is built rather than filtered, so a future Qt cannot
grow view-source or save-page back into it without somebody noticing in
the code: back, forward, reload, open in a new tab when there is a link,
cut/copy/paste in a text field, print.
cut/copy/paste in a text field, print image when there is a picture under
the pointer, print page.
### Links in a lesson (0.2.6.4)
A link in a lesson opens with one click in a new tab, and Wonder Ink shows
that tab; the lesson stays in its own. That is what the site asks for —
every link in a lesson's content carries `target="_blank"` — and what the
engine does with it (`createWindow`, answered with a tab that takes the
focus). Measured on the real site, on the real *Rise and Fall of Saul*
lesson: "Saul Welcome Question Slide" is
`<a target="_blank" href="…/SaulWelcomeQuestionSlide.png">`, and one click
opened the slide in a new tab, at 100%, at 200% and in Full Screen.
**The URL bubble teachers saw is WonderInk's editor, shown to admins.**
Testing 0.2.6.3 found that clicking such a link showed a small bubble with
the address and Edit / Remove instead of following it. Found by reading
the site's code and confirmed with the account roles:
* the site shows lesson text in **CKEditor 5** (35.2.1, its
`RTE_USE_CKEDITOR_PRO` component). For a **Viewer** the content is
read-only and a link is a link; for an **Admin** it is editable, in
place;
* in an editable CKEditor 5, a plain click on a link is an editing click
by design (`LinkEditing._enableLinkOpen` opens a link only with Ctrl, ⌘
on a Mac); the editor answers it with its link balloon — the address,
*Edit link*, *Unlink*. It is the same code in Chrome;
* moving the teacher's account to Viewer made the bubble go away.
So Wonder Ink does not override it. Doing so would mean catching clicks
inside WonderInk's editor, which is how admins edit lessons, to fix
something the account's role already decides. An admin who wants to
follow a link straight away: **Ctrl+click** it.
What each kind of link does, as `tools/lab/wonderink-print-check links:*`
clicks them on a signed-in lesson page:
| | |
| --- | --- |
| a text link, a linked picture, another site's link (`target="_blank"`) | a new tab, shown |
| a worksheet PDF | a new tab in the PDF viewer; Print fetches it and prints it as a worksheet |
| a picture | a new tab; Print lays it out as a picture, and right-click offers Print image |
| a lesson button that opens a window | a new tab, shown |
| a lesson button, an in-page anchor (`#…`) | what the page does; no tab |
| a link with no target | followed in the same tab, as in a browser; Back returns |
Keys: Ctrl+W closes a tab, Alt+Left and Alt+Right navigate, Ctrl+P prints,
Ctrl+Tab switches. Ctrl+T, Ctrl+N and Ctrl+L are deliberately unbound.
@@ -232,8 +276,9 @@ One dialog, for lessons and worksheets alike:
* **Pages** — all, or some: `2`, `1-3`, `1, 3, 5-6`, checked against the
document with the reason given if wrong;
* **Copies** — 1 to 50;
* **Colour** — colour or black and white, only where the printer's driver
has a grey mode, and asked for in that driver's own terms;
* **Color** — colour or black and white, only where the printer's driver
has a grey mode, and asked for in that driver's own terms. Since 0.2.6.3
the preview turns grey too (below);
* **Cancel** sends nothing; **Print** returns to the lesson once CUPS has
the job, and a notification says where it went. A refused job keeps the
preview open with CUPS's reason.
@@ -244,9 +289,176 @@ printed. Print pressed again while a preview is open does nothing, and so
does a page calling `window.print()` meanwhile. The PDF viewer's own print
button opens the same preview.
### Pictures (0.2.6.2)
A picture a lesson opens in a tab of its own — a colouring page as a PNG
or JPEG — printed in 0.2.6.1 with **black bars** above and below it.
Measured: the bars were neither in the picture nor in the page layout.
The engine shows a lone picture on a viewer page whose background is
rgb(14, 14, 14), scaled to the window, and Qt prints page backgrounds by
default; the dark page went onto the paper around the artwork.
So a picture is no longer printed as that page. The preview takes the
picture itself — drawn onto white, which is what transparent parts are on
paper, at up to 3300 pixels (300 dpi across Letter) — and lays it out on
the printer's own paper, and the teacher chooses:
* **Size** — as large as fits, 75%, 50%, 33% or 25% of that;
* **Position** — nine squares, like the page itself;
* **Paper** — automatic (landscape for a wide picture), upright or
sideways;
* **Fill the whole page** — covers the printable area and says it cuts the
edges off. Nothing is cropped unless this is ticked.
The preview is the PDF that will print, laid out again after every change.
`tools/lab/wonderink-print-check` measures it on the file CUPS actually
kept (`PreserveJobFiles`), which is byte-for-byte the preview's: the
picture within 1 point of where it was asked for, the paper turned as
asked, and no ink anywhere else on the page. Worksheet PDFs and lesson
pages have no picture controls; nothing about them changed.
Page backgrounds are also no longer printed for lesson pages, as in
Chrome, where "Background graphics" is off unless asked for. A white-on-
black page prints dark grey on white: Blink darkens light text when the
background is left off, measured.
Temporary PDFs live in the teacher's `$XDG_RUNTIME_DIR` and are removed
when the preview closes.
### Black and white is shown as it prints (0.2.6.3)
Printing through Elmer found that **Black and white** came out grey on
paper while the preview stayed in colour. 0.2.6.2 only told the driver:
the preview was the colour PDF, CUPS was given the colour PDF, and
`ColorModel=Grayscale` turned it grey inside Elmer's filters.
So black and white is now made before the preview, not after it. When it
is chosen, the preview makes a grey copy of the document with
Ghostscript, shows that copy, and Print hands CUPS that same file — still
with `ColorModel=Grayscale`, so Elmer's generic PCL 6 driver prints it
with `pxlmono` as before rather than as a colour job that happens to be
grey. Choosing **Color** shows and prints the colour document again.
Measured before building it (Ghostscript 10.05.1, in the lab):
| | |
| --- | --- |
| a lesson page, a picture page, a leader guide, a fillable form | grey and only grey; same pages, page sizes and words; text still text; a filled-in form field and a drawn mark kept, in grey |
| against what Elmer would have printed | Ghostscript's grey rendering of the colour original and of the grey copy give the **same greys**: within 1 level in 255 on every filled area and picture. Small text can land a fraction of a pixel over — on a real lesson page, 12 of 32,754 8×8 blocks, all on one line of body text, in pairs of opposite sign. Elmer's driver *is* Ghostscript. (poppler's own grey differs on 7% of a coloured page, which is why the copy is not made with poppler) |
| how long | 0.05 s for a worksheet, 0.2 s for a text lesson page, 1.3 s for a full-page photograph, 2 s for 42 pages — and **4.4 s** for one lesson page with eleven full-size pictures, under the real Plasma session. `-dUseFastColor` saves 40% and changes the greys, so it is not used |
| a PDF Ghostscript cannot read | exits 1 and **still leaves a file behind** |
From those:
* the copy is made in the background, the preview says *Preparing black
and white…* and Print waits for it — four seconds is long enough to
look like a frozen window. It has a minute before it is given up on,
not the 20 s a page render has: a lesson of photographs on a slower
laptop is slow, not stuck. Cancel stops it at once;
* it is made once per document and kept, and each drawn page is kept, so
turning Color and Black and white back and forth is instant after the
first time, and stays on the page the teacher was looking at;
* a conversion is judged by its exit status and its page count, never by
whether a file appeared. If it fails the preview says so and offers
Color. It **never prints the colour file in its place**: the teacher
asked for black and white;
* pages are never turned (pdfwrite otherwise rotates a page to match its
text), and annotations are drawn into the page as they print, so no
later filter can draw them again in colour;
* a printer whose driver has no grey mode is not offered black and white,
and choosing one returns the preview to colour.
`tools/lab/wonderink-print-check` shows a colourful lesson in colour,
turns it to black and white and back five times on page 2, and prints
page 2 twice: every grey preview is grey pixel for pixel, every colour
one is not, the grey copy is made once, and the job CUPS kept is
**byte-for-byte the file the preview showed**, grey on every page, sent
with `ColorModel=Grayscale` through `pxlmono`. The same for a worksheet,
a 50% top-left picture, a return to Color (which prints in colour through
`pxlcolor`), and Cancel.
### Print image: a picture from inside a lesson (0.2.6.3)
Right-clicking a picture in a lesson and choosing Print printed the whole
page around it. Some of Wonder Ink's pictures cannot be opened on their
own through the site at all, so a teacher had no way to give that
picture the picture layout 0.2.6.2 introduced.
The right-click menu now says what it will print. It asks the engine's
own hit test what is under the pointer:
| Under the pointer | Measured `mediaType` | The menu offers |
| --- | --- | --- |
| a picture (`<img>`, also inside a link, a frame, from `blob:` or `data:`) | Image | **Print image…** and **Print page…** |
| a `<canvas>` | Canvas | **Print image…** and **Print page…** |
| words, empty space, a CSS background picture | None | **Print page…** |
Print page is always there. Ctrl+P and the Print button print the page,
as before.
**How the picture is taken** was measured before anything was built, on
a lab site with each kind of picture a lesson might have, a real
right-click on each, and the site logging every request:
| | signed-in picture | hotlink-protected | one-time address | `blob:` / `data:` | another site's | canvas | WebP |
| --- | --- | --- | --- | --- | --- | --- | --- |
| engine's "download image" | ✓ | ✓ | **404** — fetched again | ✓ | ✓ | **nothing** | a file Qt cannot read |
| `page.download(url)` | ✓ | **403** — no Referer | **404** | – | ✓ | – | the same |
| `fetch()` from the page | ✓ | ✓ | ✓ | – | **refused** (CORS) | – | the same |
| draw it on a canvas | ✓ | ✓ | ✓ | ✓ | **refused** (tainted) | – | ✓ |
| **engine's "copy image"** | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Only the engine's own **copy image** hands over the picture in every
case, and it is what Wonder Ink uses. It is the bitmap the page has
already decoded — full size, transparency kept, any format Chromium
reads (Qt itself cannot read WebP here) — and **it fetches nothing**: the
site saw no request at all. So a picture behind the teacher's sign-in
needs no sign-in again, and a picture the site hands out once is still
there. 0.01 to 0.05 s in the lab.
It goes through the clipboard, which is the teacher's. So what is on it
is kept first and put back as soon as the picture arrives — whenever it
arrives, even after Cancel. Measured under the real Plasma session
(KWin, Wayland) with another program owning the clipboard: saved in
0.002 s, the picture in 0.016 s, and that other program then pasted its
own text again.
The picture then goes into exactly the 0.2.6.2 picture preview — drawn on
white, capped at 300 dpi across Letter, Size, Position, Paper, Fill the
whole page, Color — named *Picture from* the lesson's title.
**When it cannot be taken.** A picture that never loaded is still a
picture to the hit test, and the engine then copies nothing. After three
seconds the preview says *This picture could not be taken from the page*
and that nothing was printed, with Print off. It does **not** fall back
to printing the page; Print page is one right-click away and says what
it does.
What it cannot do, and says nothing about because it cannot know:
* **a CSS background picture** is not a picture to the engine's hit
test — Chrome's own menu has no "Save image" for one either. The menu
offers only Print page. If Wonder Ink's site draws its artwork this
way, that is the next thing to look at; nothing in the lab says it does;
* **a video** frame is not offered;
* **the teacher's clipboard is put back into Wonder Ink.** Under Wayland
with no clipboard manager — this session has none — the clipboard
belongs to whichever program last set it. After a Print image that is
Wonder Ink, so text copied in another program before it can still be
pasted while Wonder Ink is open, and is gone once Wonder Ink closes.
Measured in the lab session. Leaving the picture there instead would
lose it at once.
`tools/lab/wonderink-print-check` runs a signed-in lesson site
(`tools/lab/wonderink-lesson-site.py`: session cookie, one-time
addresses, a second origin) and right-clicks for real: the words (Print
page only, and the page prints), a JPEG, a PNG, a transparent PNG, a
one-time picture, another site's, a `blob:`, a WebP, a canvas, a picture
inside a link, a JPEG at 50% top left in black and white, a picture that
never loaded, and Cancel. Each is judged on the job CUPS kept: where and
how large, no ink elsewhere, the corner mark there — and **no request to
the site** while it printed, and the teacher's clipboard theirs again.
`tools/lab/wonderink-print-check` runs all of this against the installed
command in the lab, and judges every scenario by what CUPS actually
received. `tests/test-classroom-printing.sh` covers the CUPS side at every
@@ -265,19 +477,162 @@ logs it to the journal and carries on.
sign-in and the room's shared YouTube account are different sessions in
different storage that cannot reach each other. The browser is still
Chrome and nothing in this change touches it.
* **Cookies live only as long as the window.** This is what the Chrome
policy's `CookiesSessionOnlyForUrls` did for wonderink.org, and it is
the reason closing Wonder Ink ends the teacher's session rather than
leaving the next person signed in as the last one. The whole profile
rather than one domain now, because this profile only ever goes where a
lesson sends it. If a room ever wants sign-ins to survive, it is the one
line `setPersistentCookiesPolicy` in `classroom-wonderink`.
* **Cookies live only as long as the window**, as Chrome's
`CookiesSessionOnlyForUrls` did for wonderink.org, so closing Wonder Ink
signs the teacher out and the next person is not signed in as the last
one. WonderInk's sign-in is the teacher's password in a cookie
(measured, 0.2.6.4), which is one more reason it never reaches the
disk. What does survive a close, for two hours, is the list of tabs —
see *Temporary workspace restore*, below.
* **The old Chrome profile is left where it is**, at
`~/.local/share/ekklesia-classroom/wonderink`. The new one is
`wonderink-shell` beside it, so rolling back to 0.2.0 finds the old one
intact.
* **One scale for the room** — see *How large a lesson is*, below.
## Temporary workspace restore (0.2.6.4)
A teacher who closes Wonder Ink by accident, or whose computer crashes or
restarts, gets their recent tabs back if they open it again within **two
hours of last using it**. After that, Wonder Ink starts fresh and the tabs
are forgotten: the next teacher — overnight, next week — never meets the
last one's. It is not a sign-in and not a memory of tabs.
10:00 using Wonder Ink
10:45 closed by accident 10:50 opened again: the tabs are back
11:00 finished, closed next Sunday: a fresh Wonder Ink
### Why it is tabs and not the sign-in
The first design kept the sign-in. It was measured on the real site
before it was built, and stopped:
| | |
| --- | --- |
| `rrf-auth` | a cookie the site's JavaScript writes: **`Basic ` and the base64 of the teacher's email and password**, sent as a header on every request. Not a token the server issued |
| `rrf-session-id`, `rrf_tsec_tok`, `auth` | session cookies alongside it; none survives the window, which is why closing Wonder Ink signs the teacher out |
| Local Storage `mddui_local_storage`, Session Storage | screen variables and a screen cache |
| the site's start-up | asks the server to authenticate again every time it loads; a reload in the lab ended signed out even with the cookie present |
Keeping the sign-in would have meant keeping the teacher's password, in a
form anyone at the maintenance terminal could reverse, on a shared
computer. It is not done. **Closing Wonder Ink still signs the teacher
out**, exactly as before; the engine writes no cookie to disk for any site.
### What comes back
* **Resource tabs** — a slide, a picture, a worksheet or video the lesson
linked to — exactly as they were. WonderInk's files are public (the
lesson's slide loads with no sign-in, measured), so they need none.
* **WonderInk's own tabs as one.** Every WonderInk page has the same
address (`https://app.wonderink.org/?`) whichever lesson it shows — the
site draws each screen itself — so a restored WonderInk tab opens the
site's start page and asks the teacher to sign in, as it would anyway.
Several of them become one, where the first was, so a teacher meets one
sign-in page, not five.
* **Worksheet tabs**, named and where they were — if the file is still in
`~/Downloads/Wonder Ink` and still a PDF. A worksheet that has gone is
left out, silently. Nothing outside that folder is ever opened from a
kept workspace, whatever the file says.
* **The order, and the tab that was in front.** The tab in front loads at
once; the others load the first time they are clicked. Measured: a PDF
loaded in a tab nobody was looking at was fitted to a window of no size,
and showed at 10% when clicked. Until then a tab is kept by the address
it will load, so closing Wonder Ink again loses nothing.
Measured on the real site, with a real Viewer account, under the lab's
Plasma session: the *Rise and Fall of Saul* lesson, its welcome slide and
its WONDER@HOME PDF, the slide in front; closed and reopened — the three
tabs in that order, the slide in front, the WonderInk tab at its sign-in
page; and again after rebooting the machine with Wonder Ink open. The kept
file held the three addresses and titles, and none of the password, the
Basic-auth value or the email address.
Never: the sign-in, a cookie, a header, a page's storage, a form, the
clipboard, a print preview or any of its temporary files.
### What is kept, and when
`~/.local/share/ekklesia-classroom/wonderink-workspace.json`, readable only
by the classroom account:
format, last_activity, active, attempts,
tabs: [{kind: "web" | "worksheet", where: address or file, title}]
An address is kept only if it is `http` or `https` and carries no
credential — no user and password in it, no query parameter named like a
token, key, password, session or signature. `tests/` writes a workspace
with such addresses and a Basic-auth value and checks the file holds none
of them; the lab signs in on a stand-in whose sign-in is Basic auth, as
WonderInk's is, and checks the same.
It is written a second after the tabs change — opened, closed, dragged
into another order, brought to the front, navigated — and the time of
last use at most every 30 seconds, whole, to a temporary file renamed
into place. A crash or a power cut loses at most half a minute of use and
never leaves half a file.
### What counts as using it
The teacher's input reaching Wonder Ink: a click, a key, the wheel, a
touch — so following a link, scrolling, switching, opening and closing
tabs, opening a worksheet, printing and zooming all count. Nothing a page
does by itself does: timers, animation, background requests, analytics,
advertising, video buffering, service workers — measured with a page
making a request every second and animating for 20 s: not activity. A
print preview left open untouched is not activity either.
**Opening Wonder Ink and having the tabs restored is not using it.** The
time kept is the teacher's last use, carried over, so closing and
reopening cannot keep a workspace alive by itself; the teacher clicking
in the restored tabs is using it, and the two hours start again.
### When it is not restored
At start, and only then: more than two hours since the last use, a file
that cannot be read or is from another version, a clock gone backwards by
more than a minute — Wonder Ink starts fresh, removes the file and says
why in the journal. No question is asked.
A restore that crashes Wonder Ink does not make it unusable. Starting from
a workspace is counted in the file before any tab loads, and cleared once
Wonder Ink has run 20 seconds. After two starts that did not get that
far, the workspace is abandoned and Wonder Ink starts fresh, with a line
in the journal.
Two hours is `IDLE_LIMIT_SECONDS` in
`lib/classroom_wonderink_workspace.py`, the one place it is decided.
**Testing without waiting two hours.** From the maintenance terminal in
the session (Meta+Ctrl+Alt+Shift+K), with Wonder Ink closed:
CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS=120 classroom-wonderink
That one Wonder Ink keeps the workspace for two minutes after the last
use, and says `TEST ONLY` in the journal. The variable can only shorten
the time, nothing in the classroom sets it (a test checks), and it lasts
as long as that process.
### What it cannot do
* **Reopen a lesson where it was.** WonderInk's screens have no address of
their own, so its tab comes back at its start page; after signing in
the teacher opens the lesson again. The slides, pictures and worksheets
they had open alongside it are already there.
* **Know who is at the keyboard.** Two hours is two hours of nobody using
Wonder Ink. A teacher who leaves and whose room is used within two hours
leaves their tabs — not their sign-in — to the next person.
`tools/lab/wonderink-workspace-check` runs each of these against the
installed command: restored after 5 and 90 minutes; fresh after two hours
and a minute, the next day and the next week; reopened twice untouched;
activity after a restore; a busy page; an untouched print preview;
pressing Print; a closed tab; tabs reordered; a worksheet, and one whose
file has gone; killed outright; a restore that crashes twice; a corrupt
file, a later format, a half-written file; the privacy of the file; and
restored tabs printing. `tests/test-classroom-wonderink-workspace.sh`
covers the rules at every build.
## How large a lesson is
Teachers found lessons in Wonder Ink too small next to the same site in
@@ -538,3 +893,7 @@ For completeness, so nobody re-audits them: the managed policy in
`/etc/opt/chrome/policies/managed/` sets first-run, sign-in, password
and homepage behaviour only. None of it touches media, autoplay,
iframes, fullscreen or site permissions.
## Later
What testing of 0.2.6.1 asked for next is in [backlog.md](backlog.md).
+77
View File
@@ -288,6 +288,44 @@ class Presentation:
def failed(self):
return self.state == "failed"
@property
def failure(self):
"""Why it failed, as one word a teacher's card can be chosen by.
"memory": the conversion was killed for want of memory, or was
interrupted too often to keep trying — worth trying again once
other programs are closed. "unreadable": the file itself would not
convert, which trying again will not change.
"""
if self.stalled:
return "memory"
if not self.failed:
return None
return self.data.get("error_kind") or "unreadable"
@property
def stalled(self):
"""Still "getting ready" long after any import could have finished.
The importer's own limits end every attempt within
CONVERT_TIMEOUT + RENDER_TIMEOUT. Past that, with no answer, the
importer is not running at all — and a card that says "Getting
this ready" for ever is the thing a teacher must never be left
looking at.
"""
if self.state not in ("importing", "retry"):
return False
started = parse_stamp(self.data.get("attempt_started_at")
or self.data.get("imported_at"))
if started is None:
return False
return (now() - started).total_seconds() > STALLED_SECONDS
@property
def error(self):
return self.data.get("error") or ""
@@ -458,6 +496,24 @@ def library(root=None):
return found
def request_retry(presentation):
"""A teacher's Try again: the importer takes it up on its next pass.
Only a presentation that failed for want of memory, or stalled; a file
that will not convert is not going to convert on the second try.
"""
if presentation.failure != "memory":
return False
presentation.data["state"] = "retry"
presentation.data["error"] = None
presentation.data["error_kind"] = None
presentation.data["attempts"] = 0
presentation.data["attempt_started_at"] = stamp()
presentation.save()
return True
def remove(presentation):
"""Delete exactly one managed entry, unless it is on screen now."""
try:
@@ -642,6 +698,21 @@ PDFTOTEXT = "pdftotext"
CONVERT_TIMEOUT = 300
RENDER_TIMEOUT = 600
# Past this, a presentation still "getting ready" has no importer working
# on it; see Presentation.stalled.
STALLED_SECONDS = CONVERT_TIMEOUT + RENDER_TIMEOUT + 300
# What convert_to_pdf and render_pages say when the kernel killed the
# program doing the work. Measured: a deck of 30 different photographs
# takes LibreOffice 1.4 GB for about twenty seconds, 20 larger ones 2.1 GB,
# and the importer's service is capped at 40% of the machine's memory.
KILLED = "killed for want of memory"
def killed(returncode):
"""Did the kernel end this process? SIGKILL, directly or via a shell."""
return returncode in (-9, 137)
def _run(command, timeout, **kwargs):
import subprocess
@@ -704,6 +775,9 @@ def convert_to_pdf(source, destination):
shutil.rmtree(profile, ignore_errors=True)
shutil.rmtree(workspace, ignore_errors=True)
if killed(result.returncode):
return False, KILLED
return False, (result.stderr or result.stdout or
"the file could not be opened").strip()
@@ -775,6 +849,9 @@ def render_pages(pdf, directory, width=SLIDE_WIDTH, progress=None):
time.sleep(0.4)
if process.returncode != 0:
if killed(process.returncode):
return 0, KILLED
return 0, (process.stderr.read() if process.stderr else
"the slides could not be rendered")
+110
View File
@@ -197,6 +197,42 @@ def parse_pages(text, count):
return ",".join(parts)
def grayscale_command(source, target):
"""The command that writes SOURCE, in black and white, to TARGET.
Black and white is made here, before the preview, so the preview shows
it and CUPS is given the same file — 0.2.6.2 only told the driver, and
the preview stayed in colour while the paper came out grey. Measured in
the lab (Ghostscript 10.05.1) on a lesson page, a picture page, a
worksheet and a fillable form:
* the result is grey and nothing else, with the same pages, page
sizes and words, and the text still text;
* Ghostscript's own grey rendering of the colour original and of
this file give the same greys — within 1 level in 255 on every
filled area and picture; small text can land a fraction of a
pixel over. Elmer's driver is Ghostscript, so this is what Elmer
would have made of the colour file anyway — poppler's grey is
not, and differs on 7% of a coloured page;
* 0.05 s for a worksheet, 1.3 s for a full-page photograph, 2 s for
42 pages, and 4.4 s for one lesson page with eleven full-size
pictures, which is why the preview does it in the background.
Pages are never turned (pdfwrite otherwise rotates a page by its text)
and annotations — a form field a teacher filled in, a mark on a
worksheet — are drawn into the page as they print, so nothing is left
for a later filter to draw again in colour.
"""
return [
"gs", "-q", "-dSAFER", "-dBATCH", "-dNOPAUSE",
"-sDEVICE=pdfwrite",
"-sColorConversionStrategy=Gray", "-dProcessColorModel=/DeviceGray",
"-dAutoRotatePages=/None",
"-dPreserveAnnots=false", "-dPrinted",
f"-sOutputFile={target}", "--", str(source),
]
def submit(pdf, printer, copies=1, pages=None, gray=None, title="Wonder Ink"):
"""Hand a PDF to CUPS. (True, job id) or (False, what went wrong).
@@ -225,3 +261,77 @@ def submit(pdf, printer, copies=1, pages=None, gray=None, title="Wonder Ink"):
# without the program's name in front of it.
message = re.sub(r"^lp:\s*", "", message) or "The printer did not accept the job."
return False, message
# ---------------------------------------------------------------- pictures
#
# A picture a lesson opens in a tab of its own is not a page: the engine
# shows it on a near-black viewer page (body background rgb(14, 14, 14),
# measured) scaled to the window, and printing that page printed the black
# around the picture — the bars teachers saw above and below the artwork.
# So a picture is laid out on the paper here instead, from the picture
# itself, and the teacher chooses how large and where.
# How large, as a share of the largest size that fits the page.
PICTURE_SIZES = (100, 75, 50, 33, 25)
# Where, as (column, row): 0 left or top, 1 centre, 2 right or bottom.
PICTURE_POSITIONS = {
"top left": (0, 0), "top": (1, 0), "top right": (2, 0),
"left": (0, 1), "centre": (1, 1), "right": (2, 1),
"bottom left": (0, 2), "bottom": (1, 2), "bottom right": (2, 2),
}
# Portrait or landscape: "auto" follows the picture's own shape.
ORIENTATIONS = ("auto", "portrait", "landscape")
def picture_landscape(orientation, width, height):
"""Should the page be landscape for this picture?"""
if orientation == "landscape":
return True
if orientation == "portrait":
return False
return width > height
def picture_rect(page, margin, picture, size=100, position="centre", fill=False):
"""Where a picture goes on a page, as (x, y, width, height).
PAGE is (width, height) and MARGIN the blank edge, both in the same
units; PICTURE is the picture's (width, height) in pixels. Never
stretched: the picture keeps its own proportions.
Fit (the default) is the largest size that shows ALL of the picture
inside the margins, times SIZE percent, placed at POSITION. Fill covers
the whole printable area and so cuts off whatever overhangs — which a
teacher asks for by name, never by default; SIZE and POSITION do not
apply to it.
"""
page_width, page_height = page
width, height = picture
if width <= 0 or height <= 0:
raise ValueError("the picture has no size")
area_x, area_y = margin, margin
area_width = max(1.0, page_width - 2 * margin)
area_height = max(1.0, page_height - 2 * margin)
if fill:
scale = max(area_width / width, area_height / height)
drawn_width, drawn_height = width * scale, height * scale
return (area_x + (area_width - drawn_width) / 2,
area_y + (area_height - drawn_height) / 2,
drawn_width, drawn_height)
size = max(1, min(100, int(size)))
scale = min(area_width / width, area_height / height) * size / 100
drawn_width, drawn_height = width * scale, height * scale
column, row = PICTURE_POSITIONS.get(position, (1, 1))
return (area_x + (area_width - drawn_width) * column / 2,
area_y + (area_height - drawn_height) * row / 2,
drawn_width, drawn_height)
+306
View File
@@ -0,0 +1,306 @@
"""Wonder Ink's recent tabs, kept for two hours: temporary workspace restore.
A teacher who closes Wonder Ink by accident, or whose computer restarts,
gets their tabs back if they open it again soon. A room nobody has used
for two hours starts fresh: the next teacher never sees the last one's
tabs.
It is not a sign-in and not a memory of tabs. Measured on the real site
(docs/wonder-ink.md, *Temporary workspace restore*): WonderInk's sign-in
is the teacher's email and password, Basic-auth encoded in a cookie, so
keeping a sign-in would mean keeping a password. Nothing of the sign-in is
kept here — no cookie, no header, no storage — and a restored tab that
needs the teacher signed in gets WonderInk's own sign-in page.
WHAT IS KEPT, AND ONLY THIS.
format the version of this file
last_activity wall-clock time the teacher last used Wonder Ink
active which tab was in front
tabs for each, in order: its kind ("web" or "worksheet"),
its address or the worksheet's file, and its title
Addresses are http or https and nothing else, and one that carries a
credential — a user and password in it, or a query parameter named like
a token, key, password or signature — is not kept at all.
WHEN IT IS USED.
Only when Wonder Ink starts, and only within IDLE_LIMIT_SECONDS of the
last time the teacher used it. The time is the teacher's last use, not
the last start: opening Wonder Ink and having its tabs restored is not
using it, so closing and reopening cannot keep a workspace alive by
itself. Anything that cannot be read, is from another version, is in the
future by more than a minute (a clock gone backwards), or has failed to
restore twice running is discarded, and Wonder Ink starts fresh.
"""
import json
import os
import time
from pathlib import Path
from urllib.parse import parse_qsl, urlsplit
# Two hours after the teacher last used Wonder Ink. The one place this is
# decided.
IDLE_LIMIT_SECONDS = 2 * 60 * 60
# FOR TESTING ONLY. Checking expiry on a real machine should not take two
# hours, so this variable, in the environment of a Wonder Ink started by
# hand from the maintenance terminal, shortens it. Nothing in the classroom
# sets it; it lasts as long as that one process, and it can only shorten.
TEST_OVERRIDE = "CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS"
TEST_SHORTEST = 10
# How far the clock may seem to have gone backwards before a workspace is
# discarded for it.
CLOCK_TOLERANCE_SECONDS = 60
# A workspace that has been started from this many times without Wonder
# Ink then running long enough to say it worked is abandoned: one bad tab
# must not make Wonder Ink unusable.
RESTORE_ATTEMPTS = 2
FORMAT = 1
KINDS = ("web", "worksheet")
# WonderInk's own site. Every page of it has the same address — it is one
# page that draws each screen itself — so however many of its tabs a
# teacher had, one is restored, and that one asks them to sign in.
SITE_DOMAIN = "wonderink.org"
# Query parameters that are, or may be, a credential. A tab whose address
# has one is not kept.
CREDENTIAL_WORDS = (
"password", "passwd", "pwd", "token", "auth", "secret", "key",
"signature", "sig", "credential", "session", "jwt", "x-amz-",
)
TITLE_LENGTH = 120
def idle_limit(environ=None):
"""Seconds after the teacher's last use that a workspace is kept."""
environ = os.environ if environ is None else environ
value = environ.get(TEST_OVERRIDE, "").strip()
if value:
try:
seconds = int(value)
except ValueError:
return IDLE_LIMIT_SECONDS
return max(TEST_SHORTEST, min(seconds, IDLE_LIMIT_SECONDS))
return IDLE_LIMIT_SECONDS
def is_test_limit(environ=None):
return idle_limit(environ) != IDLE_LIMIT_SECONDS
def is_site(host, site=SITE_DOMAIN):
host = (host or "").lstrip(".").lower()
return host == site or host.endswith("." + site)
def keepable_url(url):
"""Is this an address that may be written down? http(s), no credential."""
try:
parts = urlsplit(url)
except ValueError:
return False
if parts.scheme not in ("http", "https") or not parts.hostname:
return False
if parts.username or parts.password:
return False
for name, _value in parse_qsl(parts.query, keep_blank_values=True):
lowered = name.lower()
if any(word in lowered for word in CREDENTIAL_WORDS):
return False
return True
def is_site_start(url, site=SITE_DOMAIN):
"""Is this one of WonderInk's own pages, which all share one address?"""
parts = urlsplit(url)
return is_site(parts.hostname, site) and parts.path in ("", "/")
def tab(kind, where, title=""):
return {"kind": kind, "where": str(where), "title": str(title or "")[:TITLE_LENGTH]}
def plan(tabs, active, downloads, site=SITE_DOMAIN):
"""Which of the saved TABS to open, in order, and which to put in front.
Returns (tabs, active) with every tab one that may be opened:
* a web tab only if its address may be kept (see keepable_url);
* WonderInk's own tabs as one, where the first of them was;
* a worksheet only if it is a file directly inside DOWNLOADS that
still exists and still begins as a PDF — a state file cannot send
Wonder Ink to any other file on the computer;
* the tab in front is the one that was, or the nearest kept before it.
"""
downloads = Path(downloads).resolve()
kept, index_of, site_seen = [], {}, None
for number, item in enumerate(tabs):
if not isinstance(item, dict) or item.get("kind") not in KINDS:
continue
where, title = str(item.get("where", "")), str(item.get("title", ""))[:TITLE_LENGTH]
if item["kind"] == "web":
if not keepable_url(where):
continue
if is_site_start(where, site):
if site_seen is not None:
index_of[number] = site_seen
continue
site_seen = len(kept)
else:
if not openable_worksheet(where, downloads):
continue
index_of[number] = len(kept)
kept.append(tab(item["kind"], where, title))
if not kept:
return [], 0
try:
active = int(active)
except (TypeError, ValueError):
active = 0
for candidate in range(active, -1, -1):
if candidate in index_of:
return kept, index_of[candidate]
return kept, 0
def openable_worksheet(path, downloads):
try:
resolved = Path(path).resolve()
except (OSError, RuntimeError, ValueError):
return False
if resolved.parent != Path(downloads).resolve() or not resolved.is_file():
return False
try:
with open(resolved, "rb") as handle:
return handle.read(5) == b"%PDF-"
except OSError:
return False
def read(path, now=None, limit=None):
"""The workspace to restore, or why there is none.
Returns (data, None) — with "tabs", "active", "last_activity" and
"attempts" — or (None, reason). Anything that is not a workspace to
restore is removed.
"""
now = time.time() if now is None else now
limit = idle_limit() if limit is None else limit
path = Path(path)
try:
data = json.loads(path.read_text())
except FileNotFoundError:
return None, "none kept"
except (OSError, ValueError):
return _discard(path, "unreadable")
try:
if data["format"] != FORMAT:
return _discard(path, "from another version")
last = float(data["last_activity"])
tabs = list(data["tabs"])
active = int(data.get("active", 0))
attempts = int(data.get("attempts", 0))
except (KeyError, TypeError, ValueError):
return _discard(path, "incomplete")
if last > now + CLOCK_TOLERANCE_SECONDS:
return _discard(path, "the clock is behind the last use")
if now - last > limit:
return _discard(path, "expired")
if attempts >= RESTORE_ATTEMPTS:
return _discard(path, f"it failed to restore {attempts} times")
if not tabs:
return _discard(path, "no tabs")
return {"tabs": tabs, "active": active, "last_activity": last, "attempts": attempts}, None
def write(path, tabs, active, last_activity, attempts=0):
"""Keep TABS, or remove the file if there is nothing to keep.
Only what plan() would open again is written. Written whole to a
temporary file, flushed to disk, and renamed over the old one, readable
only by the classroom account: a crash or a power cut leaves the old
file or the new one, never half of either.
"""
path = Path(path)
keepable, front = [], 0
for number, item in enumerate(tabs):
if item.get("kind") == "worksheet" or (
item.get("kind") == "web" and keepable_url(item.get("where", ""))
):
if number <= active:
front = len(keepable)
keepable.append(tab(item["kind"], item["where"], item.get("title", "")))
if not keepable or last_activity is None:
forget(path)
return False
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + ".new")
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(descriptor, "w") as handle:
json.dump({
"format": FORMAT,
"last_activity": float(last_activity),
"active": front,
"attempts": int(attempts),
"tabs": keepable,
}, handle)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
return True
def forget(path):
"""Remove a kept workspace. Never fails: Wonder Ink must start regardless."""
path = Path(path)
for target in (path, path.with_name(path.name + ".new")):
try:
target.unlink(missing_ok=True)
except OSError:
pass
def _discard(path, reason):
forget(path)
return None, reason
+15 -2
View File
@@ -4,9 +4,9 @@ Section: misc
Priority: optional
Architecture: amd64
Maintainer: Ekklesia
Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6, libreoffice-impress, libreoffice-core, poppler-utils, fonts-liberation, fonts-liberation-sans-narrow, fonts-croscore, fonts-crosextra-carlito, fonts-crosextra-caladea, fonts-urw-base35, fonts-roboto, fonts-open-sans, fonts-lato, fonts-montserrat, fonts-noto-extra
Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6, libreoffice-impress, libreoffice-core, poppler-utils, ghostscript, fonts-liberation, fonts-liberation-sans-narrow, fonts-croscore, fonts-crosextra-carlito, fonts-crosextra-caladea, fonts-urw-base35, fonts-roboto, fonts-open-sans, fonts-lato, fonts-montserrat, fonts-noto-extra
Recommends: vlc
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup, khelpcenter
Description: Ekklesia managed classroom environment
A classroom appliance built on Debian's own Plasma desktop. The room's
panel, window behaviour, notifications, palette and lockdown are
@@ -48,6 +48,13 @@ Description: Ekklesia managed classroom environment
import costs 0.34s for a ten-slide deck and nothing at all afterwards.
libreoffice-gtk3 is deliberately NOT here: nothing interactive runs.
.
ghostscript is Wonder Ink's black and white. It was already on every
machine, because cups-filters needs it; it is named here because Wonder
Ink now runs it itself, to make the grey copy of a page that the print
preview shows and CUPS is given. Measured: it is the same engine Elmer's
driver prints with, and its grey differs from what Elmer would have
made of the colour page by at most 1 level in 255.
.
The fonts are what a Google Slides export asks for. The metric
compatible families cover the Microsoft names — Liberation for Arial,
Times New Roman and Courier New, Carlito for Calibri, Caladea for
@@ -61,3 +68,9 @@ Description: Ekklesia managed classroom environment
The Conflicts line is deliberate. A welcome tour, a software centre and
a phone pairing daemon are all things plasma-desktop recommends and a
room a teacher walks into should not have.
.
khelpcenter is there for the same reason, found the hard way: a
settings window's Help button opens it directly, whatever the Kiosk
restrictions say, and it is a browser of the machine's manual pages with
file dialogs of its own. plasma-desktop only recommends it, and nothing
else installed needs it.
+108 -12
View File
@@ -158,11 +158,29 @@ def forget_presentation(presentation_id, root):
library.write_json(library.ledger_path(root), data)
class NeedsMemory(RuntimeError):
"""The kernel killed the converter or renderer for want of memory."""
# An import the service was killed in the middle of is resumed at the next
# start — once. Measured in the lab: a deck that needs more memory than
# the machine has free is killed again every time, and with no limit the
# service converted, died and restarted in a loop while the library said
# "Getting this ready…" for ever.
MAX_ATTEMPTS = 2
def prepare(presentation):
"""Build the derived PDF, pages and cover from a managed original."""
data = presentation.data
managed = presentation.managed_copy
# Counted and written BEFORE the work, so that a kill in the middle of
# it is still counted when the service comes back.
data["attempts"] = int(data.get("attempts") or 0) + 1
data["attempt_started_at"] = library.stamp()
presentation.save()
if managed is None or not managed.is_file():
raise RuntimeError("the managed original is missing")
@@ -189,12 +207,18 @@ def prepare(presentation):
managed, presentation.slides)
if not converted:
if error == library.KILLED:
raise NeedsMemory(error)
raise RuntimeError(error or "the file could not be converted")
pages, error = library.render_pages(
presentation.slides, presentation.pages_directory)
if not pages:
if error == library.KILLED:
raise NeedsMemory(error)
raise RuntimeError(error or "the slides could not be rendered")
data["pages"] = pages
@@ -211,6 +235,7 @@ def prepare(presentation):
data["state"] = "ready"
data["error"] = None
data["error_kind"] = None
presentation.save()
say(f"imported {data['title']!r} ({pages} slides)")
@@ -278,15 +303,55 @@ def import_one(source, root=None):
# teacher can remove it, and a technician can recover or inspect it;
# no successfully downloaded file is lost because a derived asset
# could not be made.
data["state"] = "failed"
data["error"] = str(error)[:300]
presentation.save()
fail(presentation, error)
say(f"could not import {source.name}: {error}")
return presentation
def fail(presentation, error, kind=None):
"""Record a failure a teacher's card can say something useful about."""
presentation.data["state"] = "failed"
presentation.data["error"] = str(error)[:300]
presentation.data["error_kind"] = kind or (
"memory" if isinstance(error, NeedsMemory) else "unreadable")
presentation.save()
def attempt(presentation):
"""prepare(), with any failure recorded rather than raised."""
try:
prepare(presentation)
except Exception as error: # noqa: BLE001
fail(presentation, error)
say(f"could not import {presentation.title!r}: {error}")
def retry_requested(root=None):
"""Presentations a teacher pressed Try again on, taken up now."""
root = root if root is not None else library.library_root()
retried = 0
if not root.is_dir():
return 0
for presentation in library.library(root):
if presentation.state != "retry":
continue
managed = presentation.managed_copy
if managed is None or not managed.is_file():
fail(presentation, RuntimeError("the managed original is missing"), "unreadable")
continue
say(f"trying again: {presentation.title!r}")
attempt(presentation)
retried += 1
return retried
def sweep(root=None):
"""Recover imports interrupted during a move, conversion or rendering.
@@ -313,14 +378,17 @@ def sweep(root=None):
if (managed is not None and managed.is_file()
and (source is None or not source.exists())):
say(f"resuming an interrupted import: {presentation.title!r}")
try:
prepare(presentation)
except Exception as error: # noqa: BLE001
presentation.data["state"] = "failed"
presentation.data["error"] = str(error)[:300]
presentation.save()
say(f"could not import {presentation.title!r}: {error}")
attempts = int(presentation.data.get("attempts") or 0)
if attempts >= MAX_ATTEMPTS:
# Killed in the middle of it every time it was tried.
say(f"giving up on {presentation.title!r} after {attempts} "
"interrupted attempts; the teacher can try again")
fail(presentation, NeedsMemory("interrupted every attempt"))
else:
say(f"resuming an interrupted import: {presentation.title!r}")
attempt(presentation)
stale += 1
continue
@@ -414,6 +482,23 @@ def status(root=None):
return 0
def hold_lock(root=None):
"""The importer's lock, held for as long as this process lives."""
import fcntl
root = root if root is not None else library.library_root()
root.mkdir(parents=True, exist_ok=True)
handle = open(root / ".importer.lock", "a")
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return None
return handle
def main():
parser = argparse.ArgumentParser(
description="Bring finished downloads into the Presentations library.")
@@ -439,7 +524,17 @@ def main():
say(f"removed {removed} expired presentation(s)")
return 0
# One importer at a time, whoever started it. Conversions are the
# memory the machine runs short of, so two at once — the service and a
# technician's --once, say — must not happen.
held = hold_lock()
if held is None:
say("another importer is already running; not starting a second")
return 0
sweep()
retry_requested()
if arguments.once:
imported = scan()
@@ -453,6 +548,7 @@ def main():
while True:
try:
retry_requested()
scan()
if time.monotonic() - last_cleanup > CLEANUP_SECONDS:
+35 -6
View File
@@ -682,11 +682,11 @@ class Library(Gtk.Window):
would make the buttons unclickable.
"""
return tuple(
(p.id, p.state, p.pages, p.thumbnail_page, p.title)
(p.id, p.state, p.pages, p.thumbnail_page, p.title, p.failure)
for p in presentations
)
def _fit_window(self, count):
def _fit_window(self, count, tall=False):
"""Size to the cards while staying inside the panel's work area."""
workarea = monitor_workarea(self)
maximum_width = max(420, int(workarea.width * 0.92))
@@ -705,7 +705,10 @@ class Library(Gtk.Window):
columns = min(count, available, 4)
rows = (count + columns - 1) // columns
width = min(maximum_width, max(420, 40 + columns * card_with_gap))
height = min(maximum_height, 130 + rows * 340)
# A card explaining a memory failure carries three lines of
# words above its buttons, not one; measured, at 340 its Try
# again was cut off below the window's edge.
height = min(maximum_height, 130 + rows * (400 if tall else 340))
self.resize(width, height)
@@ -735,14 +738,19 @@ class Library(Gtk.Window):
# something is "being prepared" when it never will be is how
# somebody ends up waiting through a Bible study for it.
ready = sum(1 for p in presentations if p.ready)
broken = sum(1 for p in presentations if p.failed)
working = len(presentations) - ready - broken
again = sum(1 for p in presentations if p.failure == "memory")
broken = sum(1 for p in presentations
if p.failed and p.failure != "memory")
working = len(presentations) - ready - broken - again
parts = [f"{ready} ready to present"]
if working:
parts.append(f"{working} still being prepared")
if again:
parts.append(f"{again} to try again")
if broken:
parts.append(f"{broken} could not be opened")
@@ -752,7 +760,8 @@ class Library(Gtk.Window):
self.grid.add(self.card(presentation))
self.grid.show_all()
self._fit_window(len(presentations))
self._fit_window(len(presentations),
tall=any(p.failure == "memory" for p in presentations))
def card(self, presentation):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
@@ -802,6 +811,20 @@ class Library(Gtk.Window):
look = Gtk.Button(label="Quick Look")
look.connect("clicked", lambda *_: self.quick_look(presentation))
buttons.pack_start(look, True, True, 0)
elif presentation.failure == "memory":
# The computer ran short of memory getting it ready, or the
# importer stopped part way. Worth another go with less open;
# never a converter's message, a path or a terminal.
note.set_text("This needed more memory than the computer had "
"free. Close other programs, then choose "
"Try again.")
note.set_line_wrap(True)
note.set_max_width_chars(34)
again = Gtk.Button(label="Try again")
again.get_style_context().add_class("primary")
again.connect("clicked", lambda *_: self.try_again(presentation))
buttons.pack_start(again, True, True, 0)
elif presentation.failed:
# SHORT, AND IN WORDS A TEACHER CAN ACT ON.
#
@@ -878,6 +901,12 @@ class Library(Gtk.Window):
self._signature = None
self.refresh()
def try_again(self, presentation):
# The importer takes it up within a few seconds; the card says
# "Getting this ready" again at the next refresh.
if library.request_retry(presentation):
self.refresh()
def remove(self, presentation):
dialog = Gtk.MessageDialog(
transient_for=self,
File diff suppressed because it is too large Load Diff
@@ -24,5 +24,26 @@ Slice=session.slice
Nice=10
IOSchedulingClass=idle
# MEMORY. Measured in the lab, LibreOffice converting one deck: 203 MB
# for ten slides of text, 738 MB for thirty slides sharing twelve photos,
# 1.4 GB for thirty different photos and 2.1 GB for twenty very large
# ones, for twenty seconds or so. A 4 GB classroom running Plasma and
# Wonder Ink does not have 2 GB spare, and in testing the kernel killed
# the whole service mid-conversion — which then restarted and tried the
# same deck again, while the library said "Getting this ready" for ever.
#
# At most 40% of this machine's memory: 1.6 GB on 4 GB, 3.2 GB on 8 GB.
# A deck that needs more is stopped HERE, inside this service, rather
# than pushing the teacher's own windows out of memory.
MemoryMax=40%
# If memory runs short everywhere, this is what goes first: never the
# lesson on the screen, the browser or the panel.
OOMScoreAdjust=800
# And a killed LibreOffice is a failed import, not a dead service. The
# watcher records it — "needed more memory", with Try again — and carries
# on. systemd's default stops the whole unit when any one process in it
# is killed for memory.
OOMPolicy=continue
[Install]
WantedBy=plasma-workspace.target
+99
View File
@@ -422,6 +422,105 @@ check("normal title bars contain only Close",
check("the unfinished Help launcher is absent from the panel",
"ekklesia-manual.desktop" not in layout)
print("17. running out of memory is a state a teacher can act on, never a loop")
library.render_pages = fake_render_pages
library.render_thumbnail = fake_render_thumbnail
library.first_text = lambda _pdf, pages=2: ""
# The kernel killing LibreOffice: SIGKILL directly, or 137 through the
# soffice shell wrapper.
check("a SIGKILL is recognised either way",
library.killed(-9) and library.killed(137)
and not library.killed(1) and not library.killed(0))
library.convert_to_pdf = lambda _source, _destination: (False, library.KILLED)
heavy = download("Heavy deck.pptx", contents="photographs")
starved = importer.import_one(heavy, shelf)
check("a conversion killed for memory is a failed import",
starved is not None and starved.failed, starved.data if starved else None)
check("of the kind a teacher can try again",
starved.failure == "memory" and starved.data["error_kind"] == "memory")
check("and the downloaded original is kept",
starved.managed_copy.read_text() == "photographs")
check("the attempt was counted before the work began",
starved.data.get("attempts") == 1 and starved.data.get("attempt_started_at"))
check("a file that simply will not convert is a different failure",
failed_import.failure == "unreadable")
check("and cannot be tried again", not library.request_retry(failed_import))
# A service killed in the middle of an import, found by the next start.
library.convert_to_pdf = original_convert
def interrupted(name, attempts):
directory = shelf / name
(directory / "original").mkdir(parents=True)
(directory / "original" / f"{name}.pdf").write_text("%PDF-1.4 " + name)
library.Presentation(directory, {
"id": name, "state": "importing", "title": name,
"original_name": f"{name}.pdf", "source_path": str(downloads / f"{name}.pdf"),
"managed_copy": f"original/{name}.pdf", "format": "pdf",
"imported_at": library.stamp(), "last_presented_at": None,
"times_presented": 0, "thumbnail_page": 1, "pages": 0, "error": None,
"attempts": attempts,
}).save()
return directory
once = interrupted("once", 1)
twice = interrupted("twice", 2)
importer.sweep(shelf)
check("an import interrupted once is resumed",
library.load(once).ready)
gave_up = library.load(twice)
check("an import interrupted twice is not tried a third time",
gave_up.failed and gave_up.failure == "memory", gave_up.data)
check("and its original is still there to try again from",
(twice / "original" / "twice.pdf").exists())
check("Try again puts it back in the importer's hands",
library.request_retry(gave_up) and library.load(twice).state == "retry")
check("which counts afresh", library.load(twice).data.get("attempts") == 0)
check("and the importer takes it up on its next pass",
importer.retry_requested(shelf) == 1 and library.load(twice).ready)
# The importer not running at all: nothing will ever finish this one.
stuck = interrupted("stuck", 0)
entry_data = library.load(stuck).data
long_ago = library.now() - timedelta(seconds=library.STALLED_SECONDS + 60)
entry_data["attempt_started_at"] = library.stamp(long_ago)
library.Presentation(stuck, entry_data).save()
stuck_card = library.load(stuck)
check("an import nobody is working on stops saying Getting this ready",
stuck_card.stalled and stuck_card.failure == "memory")
check("and offers Try again", library.request_retry(stuck_card))
check("a fresh import is not mistaken for a stalled one",
not library.load(once).stalled and library.load(once).failure is None)
held = importer.hold_lock(shelf)
check("one importer holds the lock", held is not None)
check("a second importer is refused rather than run beside it",
importer.hold_lock(shelf) is None)
held.close()
check("and it is free again once the first has gone",
importer.hold_lock(shelf) is not None)
check("the lock file is not a presentation",
all(p.id != ".importer.lock" for p in library.library(shelf)))
unit = (root / "systemd" / "user" / "ekklesia-classroom-presentations.service").read_text()
check("the service may use at most 40% of the machine's memory",
"MemoryMax=40%" in unit)
check("a killed converter does not take the watcher with it",
"OOMPolicy=continue" in unit)
check("and under pressure it goes before the teacher's windows",
"OOMScoreAdjust=800" in unit)
check("the card says what to do, in a teacher's words",
"Close other programs, then choose" in ui and 'label="Try again"' in ui)
library.render_pages = original_render_pages
library.render_thumbnail = original_render_thumbnail
library.first_text = original_first_text
library.convert_to_pdf = original_convert
shutil.rmtree(work, ignore_errors=True)
print()
+116 -1
View File
@@ -177,6 +177,77 @@ ok, answer = printing.submit(pdf, "Elmer")
check("a refused job is a failure, in CUPS's own words",
not ok and answer == 'Destination "Elmer" is not accepting jobs.', answer)
print()
print("4b. where a picture goes on the paper")
LETTER, MARGIN = (612, 792), 28.35
area = (MARGIN, MARGIN, 612 - 2 * MARGIN, 792 - 2 * MARGIN)
close = lambda a, b: all(abs(x - y) < 0.01 for x, y in zip(a, b))
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000))
check("a wide picture fits the width and keeps its shape",
abs(w - area[2]) < 0.01 and abs(w / h - 1.6) < 0.001, (x, y, w, h))
check("and is centred on the page", abs(y + h / 2 - 396) < 0.01)
check("nothing of it falls outside the margins",
x >= MARGIN - 0.01 and y >= MARGIN - 0.01
and x + w <= 612 - MARGIN + 0.01 and y + h <= 792 - MARGIN + 0.01)
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "top left")
check("50% in the top left is half the fitted size, at the margin",
close((x, y, w), (MARGIN, MARGIN, area[2] / 2)), (x, y, w, h))
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), 25, "bottom right")
check("25% in the bottom right ends at the margin",
close((x + w, y + h), (612 - MARGIN, 792 - MARGIN)), (x, y, w, h))
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (900, 1400), 100, "right")
check("a tall picture fits the height and sits at the right",
abs(h - area[3]) < 0.01 and abs(x + w - (612 - MARGIN)) < 0.01, (x, y, w, h))
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), fill=True)
check("fill covers the whole printable area",
x <= MARGIN and y <= MARGIN + 0.01 and x + w >= 612 - MARGIN and y + h >= 792 - MARGIN - 0.01)
check("and keeps the picture's shape, cutting off what overhangs",
abs(w / h - 1.6) < 0.001 and w > area[2])
check("fill ignores size and position",
printing.picture_rect(LETTER, MARGIN, (1600, 1000), 25, "top left", True)
== printing.picture_rect(LETTER, MARGIN, (1600, 1000), fill=True))
check("an unknown position is the centre",
printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "upside down")
== printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "centre"))
try:
printing.picture_rect(LETTER, MARGIN, (0, 10))
check("a picture with no size is refused", False)
except ValueError:
check("a picture with no size is refused", True)
check("automatic paper is landscape for a wide picture",
printing.picture_landscape("auto", 1600, 1000))
check("and portrait for a tall or square one",
not printing.picture_landscape("auto", 900, 1400)
and not printing.picture_landscape("auto", 1000, 1000))
check("and the teacher's choice overrides it",
not printing.picture_landscape("portrait", 1600, 1000)
and printing.picture_landscape("landscape", 900, 1400))
check("every position is on a three by three grid",
len(printing.PICTURE_POSITIONS) == 9
and sorted(printing.PICTURE_POSITIONS.values()) == [(c, r) for c in range(3) for r in range(3)])
print()
print("4c. black and white, made before the preview")
command = printing.grayscale_command(work / "in.pdf", work / "out gray.pdf")
check("Ghostscript writes a PDF", command[0] == "gs" and "-sDEVICE=pdfwrite" in command, command)
check("in grey and nothing else",
"-sColorConversionStrategy=Gray" in command and "-dProcessColorModel=/DeviceGray" in command, command)
# pdfwrite otherwise turns a page to match its text; a landscape picture
# on a portrait sheet would come back turned.
check("never turning a page", "-dAutoRotatePages=/None" in command, command)
check("annotations drawn in as they print, not left for a filter to draw in colour",
"-dPreserveAnnots=false" in command and "-dPrinted" in command, command)
check("safely, and without stopping to ask",
"-dSAFER" in command and "-dBATCH" in command and "-dNOPAUSE" in command, command)
check("to the file asked for, even with a space in its name",
f"-sOutputFile={work / 'out gray.pdf'}" in command, command)
check("and the source after --, so no name can be read as an option",
command[-2:] == ["--", str(work / "in.pdf")], command)
print()
print("5. Wonder Ink prints through this, and only this")
ink = (root / "scripts/classroom-wonderink").read_text()
@@ -186,15 +257,59 @@ check("no QPrinter", "QPrinter(" not in code and "QPrintDialog" not in code)
check("no QWebEngineView.print()", not re.search(r"\bview\.print\(", code))
check("a lesson page becomes a PDF by printToPdf", "printToPdf(" in code)
check("which is given up on if the engine never answers",
"PREPARE_TIMEOUT_MS" in code and "self._timer.start(PREPARE_TIMEOUT_MS)" in code)
"def _wait(self, message, too_long, timeout=PREPARE_TIMEOUT_MS):" in code
and "self._timer.start(timeout)" in code)
check("black and white has longer, since a page of photographs is slow, not stuck",
"GRAY_TIMEOUT_MS = 60_000" in code and " GRAY_TIMEOUT_MS,\n" in code)
check("the preview submits with classroom_printing.submit",
"classroom_printing.submit(" in code)
check("there is no file chooser in it", "QFileDialog" not in code)
check("a worksheet tab is printed from its file",
"self._pdf_views[view] = resolved" in code)
check("an inline PDF is fetched as a file first", "view.page().download(view.url())" in code)
check("a page's background is not printed, as in Chrome",
"PrintElementBackgrounds, False" in code)
check("a picture on its own is laid out, not printed as the viewer page",
"dialog.show_picture(image)" in code and "document.contentType" in ink)
preview_code = code[code.index("class PrintPreview"):code.index("\ndef notify")]
check("the print interface says Color, not Colour",
'"Color"' in preview_code and '"Colour"' not in preview_code)
check("an exception in a handler is logged, not fatal",
"sys.excepthook = keep_going" in code)
# 0.2.6.3: black and white is shown as it prints.
check("black and white is made by classroom_printing.grayscale_command, in the background",
"classroom_printing.grayscale_command(" in preview_code and "QProcess(" in preview_code)
check("choosing Color or Black and white lays the preview out again",
"self._colour.currentIndexChanged.connect" in preview_code)
check("a failed conversion is judged by its exit and its pages, never printed in colour",
"process.exitCode() != 0" in preview_code
and "page_count(target) != expected" in preview_code)
check("the driver is still told ColorModel=Grayscale for the grey file",
'gray = self._options[printer]["gray"]' in preview_code)
check("and nothing prints that is not what is shown",
"(self._gray is not None and self._pdf == self._gray[1]) != (gray is not None)" in preview_code)
# 0.2.6.3: right-click says what it prints.
menu_code = code[code.index(" def contextMenuEvent"):code.index("\nclass WonderInk")]
check("right-click offers Print page always",
'"Print page…"' in menu_code and "print_view(self)" in menu_code)
check("and Print image only where the engine's hit test found a picture or a canvas",
'"Print image…"' in menu_code and "MediaTypeImage" in menu_code
and "MediaTypeCanvas" in menu_code and "print_image(self)" in menu_code)
check("the old single Print… is gone from the menu", '"Print…"' not in menu_code)
take_code = code[code.index(" def _take_picture"):code.index(" def _show_preview")]
check("the picture is the engine's own copy, not fetched again",
"WebAction.CopyImageToClipboard" in take_code
and "download(" not in take_code and "QNetwork" not in take_code)
check("what was on the clipboard is kept and put back",
"keep_clipboard(clipboard)" in take_code and "put_back()" in take_code)
check("a picture that never arrives is said so, not printed as the page",
"TAKE_PICTURE_MS" in take_code and "dialog.fail(" in take_code
and "print_view" not in take_code)
check("and it goes to the picture layout, on white",
"dialog.show_picture(printable_picture(image))" in take_code
and "Qt.GlobalColor.white" in code)
check("the module is packaged",
"lib/classroom_printing.py" in (root / "tools/build-release.sh").read_text())
+258
View File
@@ -0,0 +1,258 @@
#!/bin/bash
# Wonder Ink's temporary workspace restore: which tabs come back, and when.
#
# lib/classroom_wonderink_workspace.py decides, from a file and the clock,
# whether a teacher's recent tabs are opened again, and which of them may
# be. Every way that could go wrong is either the next teacher meeting the
# last one's tabs, a credential written to disk, or a file Wonder Ink was
# never meant to open — so each is tested here with a clock the test
# controls. Whether the engine really restores them is
# tools/lab/wonderink-workspace-check's question.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
WORK="$WORK" PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
import base64
import json
import os
import stat
import sys
from pathlib import Path
import classroom_wonderink_workspace as ws
root = Path(sys.argv[1])
work = Path(os.environ["WORK"])
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
HOUR = 60 * 60
NOW = 1_790_000_000.0
downloads = work / "Downloads" / "Wonder Ink"
downloads.mkdir(parents=True)
sheet = downloads / "Colour the Ark.pdf"
sheet.write_bytes(b"%PDF-1.4 worksheet")
path = work / "wonderink-workspace.json"
SITE = "https://app.wonderink.org/?"
SLIDE = "https://dcc-sbbd-user-content.s3.amazonaws.com/5ee6/69fd-SaulWelcomeQuestionSlide.png"
VIDEO = "https://player.vimeo.com/video/1144568454"
THREE = [ws.tab("web", SITE, "Welcome Question"), ws.tab("web", SLIDE, "Slide"), ws.tab("web", VIDEO, "Video")]
print("1. how long")
check("two hours by default", ws.idle_limit({}) == 2 * HOUR)
check("the test override shortens it", ws.idle_limit({ws.TEST_OVERRIDE: "120"}) == 120)
check("but never below ten seconds", ws.idle_limit({ws.TEST_OVERRIDE: "1"}) == ws.TEST_SHORTEST)
check("and never lengthens it", ws.idle_limit({ws.TEST_OVERRIDE: "999999"}) == 2 * HOUR)
check("nonsense in it is the default", ws.idle_limit({ws.TEST_OVERRIDE: "soon"}) == 2 * HOUR)
check("and it says it is one", ws.is_test_limit({ws.TEST_OVERRIDE: "120"}) and not ws.is_test_limit({}))
print()
print("2. A. restore within the window")
def keep(tabs=THREE, active=1, last=NOW, attempts=0):
ws.write(path, tabs, active, last, attempts)
for label, minutes in (("five minutes", 5), ("ninety minutes", 90), ("a second before two hours", 120 - 1 / 60)):
keep()
data, why = ws.read(path, now=NOW + minutes * 60)
check(f"{label} later: restored", data is not None and len(data["tabs"]) == 3, why)
check("with the time of the last use, not the time it was opened again", data["last_activity"] == NOW)
print()
print("3. B. expiration")
for label, seconds in (("two hours and a minute", 2 * HOUR + 60), ("the next day", 24 * HOUR),
("the next week", 7 * 24 * HOUR)):
keep()
data, why = ws.read(path, now=NOW + seconds)
check(f"{label} later: fresh ({why})", data is None and why == "expired")
check("and the file is gone", not path.exists())
keep(last=NOW)
data, why = ws.read(path, now=NOW + 30, limit=20)
check("the test override applies to a kept workspace", data is None and why == "expired", why)
keep(last=NOW)
data, why = ws.read(path, now=NOW - 2 * HOUR)
check("a clock gone back two hours: fresh, not a workspace made young", data is None and "clock" in why, why)
keep(last=NOW)
data, why = ws.read(path, now=NOW - 30)
check("a clock corrected by half a minute: still restored", data is not None, why)
print()
print("4. D. which tabs, in which order, which in front")
keep()
data, _ = ws.read(path, now=NOW + 60)
check("the order is kept", [t["where"] for t in data["tabs"]] == [SITE, SLIDE, VIDEO])
check("and the tab in front", data["active"] == 1)
check("and readable titles", [t["title"] for t in data["tabs"]] == ["Welcome Question", "Slide", "Video"])
tabs, active = ws.plan(THREE, 1, downloads)
check("all three can be opened, the middle one in front", len(tabs) == 3 and active == 1)
five_site = [ws.tab("web", SITE, f"Lesson {n}") for n in range(3)] + [ws.tab("web", SLIDE, "Slide"),
ws.tab("web", "https://app.wonderink.org/", "Home")]
tabs, active = ws.plan(five_site, 4, downloads)
check("WonderInk's own tabs, which share one address, become one sign-in page, not four",
[t["where"] for t in tabs] == [SITE, SLIDE], tabs)
check("and a WonderInk tab in front brings its one tab to the front", active == 0)
tabs, active = ws.plan(five_site, 3, downloads)
check("the slide in front stays in front", active == 1)
with_sheet = [ws.tab("web", SITE, "Lesson"), ws.tab("worksheet", str(sheet), "Colour the Ark"), ws.tab("web", SLIDE, "Slide")]
tabs, active = ws.plan(with_sheet, 1, downloads)
check("a worksheet that still exists comes back, where it was and named", tabs[1] == ws.tab("worksheet", str(sheet), "Colour the Ark")
and active == 1, tabs)
gone = downloads / "Gone.pdf"
tabs, active = ws.plan([ws.tab("web", SITE), ws.tab("worksheet", str(gone), "Gone"), ws.tab("web", SLIDE)], 1, downloads)
check("a worksheet whose file has gone is left out, silently", [t["where"] for t in tabs] == [SITE, SLIDE], tabs)
check("and the tab in front becomes the one before it", active == 0)
print()
print("5. only files Wonder Ink downloaded, and only PDFs")
elsewhere = work / "secret.pdf"
elsewhere.write_bytes(b"%PDF-1.4 not a worksheet")
(downloads / "sub").mkdir()
(downloads / "sub" / "deep.pdf").write_bytes(b"%PDF-1.4")
notpdf = downloads / "notes.pdf"
notpdf.write_text("<html>an error page</html>")
link = downloads / "link.pdf"
link.symlink_to(elsewhere)
for label, target in (("a file outside Downloads/Wonder Ink", elsewhere), ("a system file", Path("/etc/passwd")),
("a file in a folder inside it", downloads / "sub" / "deep.pdf"),
("a file that is not a PDF", notpdf), ("a link out of it", link),
("a path climbing out of it", downloads / ".." / ".." / "secret.pdf")):
tabs, _ = ws.plan([ws.tab("worksheet", str(target))], 0, downloads)
check(f"{label}: never opened", tabs == [], tabs)
print()
print("6. only http and https, and no credentials")
for label, url in (("a file: address", "file:///etc/passwd"), ("javascript:", "javascript:alert(1)"),
("data:", "data:text/html,hi"), ("blob:", "blob:https://app.wonderink.org/abc"),
("chrome:", "chrome://settings"), ("about:blank", "about:blank"),
("a user and password in it", "https://teacher:[email protected]/"),
("a token", "https://example.org/v?token=abc"), ("an access_token", "https://example.org/v?access_token=abc"),
("a password", "https://example.org/?password=abc"), ("an S3 signature", "https://b.s3.amazonaws.com/x.png?X-Amz-Signature=abc"),
("a key", "https://example.org/?api_key=abc"), ("a session", "https://example.org/?sessionid=abc")):
check(f"{label}: not kept", not ws.keepable_url(url))
check("an ordinary lesson resource is", ws.keepable_url(SLIDE) and ws.keepable_url(VIDEO + "?h=91f1770ec7"))
print()
print("7. F. privacy: what is on disk")
basic = "Basic " + base64.b64encode(b"[email protected]:hunter2hunter2").decode()
risky = THREE + [ws.tab("web", "https://teacher:[email protected]/", "creds"),
ws.tab("web", "https://example.org/?token=" + basic, "token")]
ws.write(path, risky, 0, NOW)
text = path.read_text()
data = json.loads(text)
check("only what may be opened again is written", len(data["tabs"]) == 3, data["tabs"])
check("no password, Basic-auth credential or token in the file",
"hunter2" not in text and "Basic " not in text and "token" not in text.lower() and "@" not in text, text)
check("no cookie, header or storage of any kind",
not any(k in text.lower() for k in ("cookie", "rrf-auth", "x-rrf", "authorization", "localstorage", "sessionstorage")))
check("exactly these fields, and no others",
set(data) == {"format", "last_activity", "active", "attempts", "tabs"}
and all(set(t) == {"kind", "where", "title"} for t in data["tabs"]), data)
check("readable by the classroom account only", stat.S_IMODE(path.stat().st_mode) == 0o600)
check("no half-written file is left beside it", not path.with_name(path.name + ".new").exists())
check("a workspace nobody has used (no time of last use) is not written",
not ws.write(path, THREE, 0, None) and not path.exists())
check("nor one with nothing to open", not ws.write(path, [ws.tab("web", "about:blank")], 0, NOW) and not path.exists())
print()
print("8. E. failure: always a fresh start, never a crash")
for label, content in (("an empty file", ""), ("not JSON", "{not json"), ("JSON of the wrong shape", "[1, 2]"),
("no time of last use", json.dumps({"format": 1, "tabs": THREE, "active": 0})),
("a time that is not a number", json.dumps({"format": 1, "last_activity": "soon", "tabs": THREE})),
("no tabs", json.dumps({"format": 1, "last_activity": NOW, "active": 0, "tabs": []})),
("a future format", json.dumps({"format": 2, "last_activity": NOW, "active": 0, "tabs": THREE})),
("tabs that are not a list", json.dumps({"format": 1, "last_activity": NOW, "tabs": 7}))):
path.write_text(content)
data, why = ws.read(path, now=NOW + 5)
check(f"{label}: fresh ({why}), and removed", data is None and not path.exists(), why)
tabs, _ = ws.plan([{"kind": "web"}, "not a tab", {"kind": "program", "where": "/bin/sh"}, ws.tab("web", SLIDE)], 0, downloads)
check("tabs that make no sense are skipped, the rest kept", [t["where"] for t in tabs] == [SLIDE], tabs)
path.mkdir()
data, why = ws.read(path, now=NOW)
check("a directory where the file should be: fresh, and no crash", data is None, why)
path.rmdir()
(path.with_name(path.name + ".new")).write_text('{"format": 1, "last_act')
keep()
data, why = ws.read(path, now=NOW + 5)
check("a write cut off half way (the .new file) leaves the last whole one", data is not None, why)
print()
print("9. E. a restore that crashes Wonder Ink")
keep(attempts=1)
data, why = ws.read(path, now=NOW + 60)
check("started from once without settling: tried once more", data is not None and data["attempts"] == 1, why)
keep(attempts=2)
data, why = ws.read(path, now=NOW + 60)
check("twice: abandoned, and Wonder Ink starts fresh", data is None and "failed to restore" in why, why)
check("and the workspace is gone", not path.exists())
print()
print("10. Wonder Ink keeps it, and only as this says")
ink = (root / "scripts/classroom-wonderink").read_text()
code = "\n".join(line for line in ink.splitlines() if not line.lstrip().startswith("#"))
keeper = code[code.index("class WorkspaceKeeper"):code.index("class LessonPage")]
check("the engine still keeps no cookie", "PersistentCookiesPolicy.NoPersistentCookies" in code)
check("the keeper never touches a cookie, a header or a page's storage",
not any(w in keeper for w in ("cookieStore", "QNetworkCookie", "setHttpHeader", "localStorage", "runJavaScript")))
check("nothing else in Wonder Ink writes a cookie down", "toRawForm" not in code and "cookieStore()" not in code)
check("activity is the teacher's input: button, key, wheel, touch — not the pointer moving",
all(f"QEvent.Type.{t}" in keeper for t in ("MouseButtonPress", "KeyPress", "Wheel", "TouchBegin"))
and "MouseMove" not in keeper)
check("a restore carries the old time of last use over, not now",
'self._last_wall = kept["last_activity"]' in keeper)
check("a restore is counted in the file before any tab loads",
keeper.index("self._attempts = kept[\"attempts\"] + 1") < keeper.index("for item in tabs:")
and "workspace.write(self._path, tabs, active, self._last_wall, self._attempts)" in keeper)
check("a restored tab loads when it is first shown, the one in front at once (a hidden PDF opened at 10%)",
"self._window._pending[view] = (lambda v=view, u=where: v.load(u))" in keeper
and "pending = self._pending.pop(view, None)" in code)
check("and until then it is kept by its saved address, not dropped as an empty tab",
"if view in self._window._pending:\n tabs.append(self._pending_tabs[view])" in keeper)
check("and the count cleared once Wonder Ink has settled", "QTimer.singleShot(self.SETTLED_MS, self._settled)" in keeper)
check("the tabs are written when they change, not only at the end",
"view.urlChanged.connect(lambda *_: self._workspace_changed())" in code
and "tabMoved.connect(lambda *_: self._workspace_changed())" in code
and "currentChanged.connect(lambda *_: self._workspace_changed())" in code)
check("a page changing its own tab is not activity", "urlChanged.connect(lambda *_: self._workspace_changed())" in code
and "touched()" not in code[code.index("def _workspace_changed"):code.index("def _workspace_changed") + 200])
check("at quit it keeps the tabs, then stops filtering events",
"aboutToQuit.connect(self.shut_down)" in keeper and "removeEventFilter(self)" in keeper)
check("worksheet downloads are connected before the workspace opens",
code.index("profile.downloadRequested.connect(window.on_download)\n open_workspace(window)") > 0)
setters = [str(p.relative_to(root)) for top in ("config", "packaging", "systemd", "scripts")
for p in (root / top).rglob("*") if p.is_file() and not p.is_symlink()
and not str(p).startswith(str(root / "packaging" / "root"))
and ws.TEST_OVERRIDE in p.read_text(errors="ignore")]
check("nothing the classroom installs sets the test-only override", setters == [], setters)
check("and Wonder Ink says so in the journal when it is set", "TEST ONLY" in code and "workspace.is_test_limit()" in code)
check("the module is packaged", "lib/classroom_wonderink_workspace.py" in (root / "tools/build-release.sh").read_text())
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY
+10
View File
@@ -197,6 +197,16 @@ for restriction in ("run_command=false", "shell_access=false",
"action/lock_screen=false",
"plasma/plasmashell/unlockedDesktop=false"):
check(f"{restriction}", restriction in restrictions)
check("a notification's Configure button opens nothing",
"kcm_notifications=false" in restrictions)
check("and neither does any other settings module a teacher might reach",
all(f"{m}=false" in restrictions for m in (
"kcm_pulseaudio", "kcm_clock", "kcm_kscreen", "kcm_users",
"kcm_sddm", "kcm_keys", "kcm_bluetooth", "kcm_powerdevilprofilesconfig")))
check("a locked panel offers no widget settings",
"plasma/allow_configure_when_locked=false" in restrictions)
check("and the help center a settings window's Help button opens is not installed",
"khelpcenter" in re.search(r"^Conflicts: (.*)$", control, re.MULTILINE).group(1))
check("the restrictions are immutable", "[KDE Action Restrictions][$i]" in restrictions)
print()
+5
View File
@@ -388,6 +388,11 @@ install -Dm644 \
"$ROOT/lib/classroom_printing.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_printing.py"
# Wonder Ink's recent tabs, restored within two hours of their last use.
install -Dm644 \
"$ROOT/lib/classroom_wonderink_workspace.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_wonderink_workspace.py"
# PRESENTATIONS.
#
# The library a teacher sees, and the watcher that fills it from their
+20
View File
@@ -370,6 +370,26 @@ 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')"
# THE TEACHER CANNOT OPEN SYSTEM SETTINGS, AND THE MAINTENANCE TERMINAL CAN.
# Every settings module this machine has must be refused in the classroom's
# cascade — a notification's Configure button opens whichever one it names —
# and none may be refused in the cascade the maintenance terminal uses.
teacher_cascade() { in_lab "XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg kreadconfig6 --file kdeglobals --group 'KDE Control Module Restrictions' --key $1 --default true 2>/dev/null"; }
modules=$(in_lab 'find /usr/lib/x86_64-linux-gnu/qt6/plugins -path "*kcms*" -name "*.so" | sed "s#.*/##; s#\.so\$##" | sort')
open_modules=$(for m in $modules; do [[ "$(teacher_cascade "$m")" == false ]] || echo "$m"; done | tr '\n' ' ')
check "every settings module is refused to the teacher (unrestricted: ${open_modules:-none})" 0 \
"$(wc -w <<< "$open_modules")"
check "the notification settings the Configure button opens are among them" false \
"$(teacher_cascade kcm_notifications)"
check "but not to the maintenance terminal" true \
"$(in_lab "XDG_CONFIG_DIRS=/etc/xdg kreadconfig6 --file kdeglobals --group 'KDE Control Module Restrictions' --key kcm_notifications --default true 2>/dev/null")"
check "which still starts outside the classroom's configuration" 1 \
"$(in_lab 'grep -c "XDG_CONFIG_DIRS=/etc/xdg konsole" /usr/local/bin/classroom-admin-terminal || true')"
check "a locked panel offers no widget settings" false \
"$(in_lab "XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg kreadconfig6 --file kdeglobals --group 'KDE Action Restrictions' --key plasma/allow_configure_when_locked --default true 2>/dev/null")"
check "and no help center is installed to open from a Help button" 0 \
"$(in_lab 'dpkg -l khelpcenter 2>/dev/null | grep -c "^ii" || true')"
# 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.
+8 -4
View File
@@ -17,11 +17,15 @@ docker exec "$NAME" sh -c '
chmod +x /usr/sbin/policy-rc.d
apt-get update -qq >/dev/null 2>&1'
# apt-get install on the file, exactly as ekklesia-classroom-update does.
# Not dpkg -i and then apt-get -f: when the package conflicts with
# something installed (khelpcenter, from 0.2.6.2) dpkg refuses, and -f then
# "fixes" it by dropping the classroom package rather than the conflict —
# measured, and a lab that installs differently from a classroom tests the
# wrong thing.
docker exec "$NAME" sh -c "
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${DEB#$ROOT/}' \
>/dev/null 2>&1 || true
DEBIAN_FRONTEND=noninteractive apt-get -y -qq -f install >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive dpkg --configure ekklesia-classroom >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '/srv/classroom/${DEB#$ROOT/}' \
>/tmp/from-package-install.log 2>&1 || cat /tmp/from-package-install.log
rm -f /usr/sbin/policy-rc.d"
state=$(docker exec "$NAME" dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom)
+18
View File
@@ -135,5 +135,23 @@ check("and the window will open exactly that path", window.may_open(spaces))
print()
print(f"{passed} passed, {failed} failed")
# Pages go before their profile. Left to Python's own teardown the order
# is arbitrary, and a profile released while a page still uses it is a
# segfault on the way out — measured, intermittently, AFTER every check
# had passed, which made a passing run report failure. The real Wonder
# Ink was closed six times in the same lab and exited 0 each time.
from PyQt6.QtCore import QCoreApplication, QEvent
for index in range(window._tabs.count()):
view = window._tabs.widget(index)
view.page().deleteLater()
view.deleteLater()
window.close()
window.deleteLater()
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
profile.deleteLater()
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
sys.stdout.flush()
sys.exit(1 if failed else 0)
PY
+195
View File
@@ -0,0 +1,195 @@
"""A signed-in lesson with pictures in it, for right-click printing.
wonderink-lesson-site.py PICTURES PORT OTHER_PORT LOG
Run inside the lab by tools/lab/wonderink-print-check. Stands in for
Wonder Ink's own site, which the lab has no account for, with every kind
of picture a lesson might put under a teacher's pointer:
/login sets a session cookie (HttpOnly, SameSite=Lax), as a
sign-in does, and goes on to /lesson
/lesson the page; its pictures are all behind that cookie
/private/NAME a picture, 403 without the cookie
/once/N/NAME a picture that can be fetched exactly once — so a
print that fetches it again gets a 404, not a picture
OTHER_PORT the same pictures from another origin, with no CORS
and a stand-in for WonderInk's own start page and sign-in, built the way
the real site's is (read from its code, and measured with a real
account): the sign-in is cookies its JavaScript writes, and rrf-auth is
"Basic " and the base64 of the email and password — so a check can prove
that nothing Wonder Ink writes down holds one. Sign out calls
/rrframework/dataStore/signout and then sets them to "".
/app "Signed in as …" if rrf-auth is set, else a sign-in form
/app?busy=1 the same, plus what a site does by itself: a request
every second and an animation, and no input at all
/links every kind of link a lesson has, for following them
Every request is logged, one JSON line each, so a check can say what was
fetched while it printed. What was measured with this, and why Print
image fetches nothing, is in classroom-wonderink's _take_picture.
"""
import http.server
import json
import re
import sys
import threading
import time
import uuid
from pathlib import Path
from urllib.parse import urlparse
PICTURES = Path(sys.argv[1])
PORT, OTHER = int(sys.argv[2]), int(sys.argv[3])
LOG = Path(sys.argv[4])
TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp", ".pdf": "application/pdf"}
fetched_once = set()
lock = threading.Lock()
LESSON = """<html><head><title>Picture lesson</title><style>
body { margin: 8px; font: 15px sans-serif; }
img, canvas { width: 180px; height: 120px; object-fit: contain; border: 1px solid #ccc; margin: 4px; }
</style></head><body>
<h1 style="color:#c00">Noah's Ark</h1>
<p id="words">Noah built an ark, and the animals came two by two. Colour the pictures below.</p>
<img id="private-jpg" src="/private/ark-wide.jpg">
<img id="private-png" src="/private/ark-tall.png">
<img id="clear" src="/private/ark-clear.png">
<img id="webp" src="/private/ark-wide.webp">
<img id="once" src="/once/NONCE/ark-wide.png">
<img id="cross" src="http://localhost:OTHER/img/ark-tall.png">
<img id="blob">
<canvas id="canvas" width="1600" height="1000"></canvas>
<img id="broken" src="/private/not-there.png">
<a href="/lesson#more"><img id="linked" src="/private/ark-tall.png"></a>
<p><a id="text-link" href="/links" target="_blank">More links for this lesson</a></p>
<script>
var waiting = 2;
function one() { if (--waiting === 0) window.__ready = true; }
fetch('/private/ark-tall.png', {credentials: 'include'})
.then(function (r) { return r.blob(); })
.then(function (b) { var i = document.getElementById('blob'); i.onload = one; i.src = URL.createObjectURL(b); });
var art = new Image();
art.onload = function () { document.getElementById('canvas').getContext('2d').drawImage(art, 0, 0); one(); };
art.src = '/private/ark-wide.png';
</script></body></html>"""
APP = """<html><head><title>App</title></head><body>
<div id="state"></div>
<input id="email" type="text"><input id="password" type="password">
<button id="signin">Sign In</button> <button id="signout">Sign Out</button>
<div id="spinner" style="width:20px;height:20px;background:#c00"></div>
<script>
function cookie(name) { var m = document.cookie.match('(^|; )' + name + '=([^;]*)'); return m ? m[2] : null; }
function show() { var t = cookie('rrf-auth');
document.getElementById('state').textContent = t ? 'Signed in as teacher' : 'Signed out'; document.title = t ? 'Signed in' : 'Sign-in'; }
document.getElementById('signin').onclick = function () {
// As the real site: the "token" is Basic auth — the email and password —
// in a cookie its JavaScript writes. Analytics cookies alongside, as there.
document.cookie = 'rrf-auth=' + encodeURIComponent('Basic ' + btoa('[email protected]:hunter2hunter2')) + '; path=/';
document.cookie = 'rrf-session-id=SID; path=/'; document.cookie = 'rrf_tsec_tok=T2; path=/';
document.cookie = '_ga=GA1.1.analytics; path=/'; document.cookie = '_fbp=fb.1.tracking; path=/'; show(); };
function server() { var x = new XMLHttpRequest(); x.open('GET', '/whoami', false); x.send(); return x.status; }
document.getElementById('signout').onclick = function () {
fetch('/rrframework/dataStore/signout').then(function () {
document.cookie = 'rrf-auth=; path=/'; document.cookie = 'rrf-session-id=; path=/'; document.cookie = 'rrf_tsec_tok=; path=/'; show(); }); };
show();
if (location.search.indexOf('busy=1') >= 0) {
setInterval(function () { fetch('/beacon?' + Date.now()); }, 1000);
var a = 0; (function spin() { a += 3; document.getElementById('spinner').style.transform = 'rotate(' + a + 'deg)'; requestAnimationFrame(spin); })();
}
</script></body></html>"""
LINKS = """<html><head><title>Links</title></head><body style="font: 16px sans-serif">
<h1 id="top">Links in a lesson</h1>
<p><a id="blank" target="_blank" rel="noopener noreferrer" href="/private/ark-wide.png">Saul Welcome Question Slide</a></p>
<p><a id="same" href="/lesson">The lesson, no target</a></p>
<p><a id="external" target="_blank" href="http://localhost:OTHER/img/ark-tall.png">A picture on another site</a></p>
<p><a id="pdf" target="_blank" href="/private/ark.pdf">The worksheet (PDF)</a></p>
<p><a id="linked-picture" target="_blank" href="/private/ark-tall.png"><img src="/private/ark-tall.png" width="120"></a></p>
<p><button id="button" onclick="this.textContent = 'Pressed'">A lesson button</button></p>
<p><a id="anchor" href="#bottom">Go to the bottom</a></p>
<p><button id="popup" onclick="window.open('/private/ark-clear.png', '_blank')">Open the picture</button></p>
<div style="height: 1500px"></div><p id="bottom">The bottom</p>
</body></html>"""
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def answer(self, code, body=b"", kind="text/plain", headers=()):
self.send_response(code)
self.send_header("Content-Type", kind)
self.send_header("Content-Length", str(len(body)))
for name, value in headers:
self.send_header(name, value)
self.end_headers()
self.wfile.write(body)
def picture(self, name):
path = PICTURES / Path(name).name
if not path.is_file() or path.suffix not in TYPES:
return self.answer(404, b"no such picture")
return self.answer(200, path.read_bytes(), TYPES[path.suffix],
[("Cache-Control", "private, max-age=600")])
def do_GET(self):
path = urlparse(self.path).path
signed_in = "session=ok" in self.headers.get("Cookie", "")
with lock, open(LOG, "a") as log:
log.write(json.dumps({"t": time.time(), "port": self.server.server_port,
"path": path, "signed_in": signed_in}) + "\n")
if self.server.server_port == OTHER:
if path.startswith("/img/"):
return self.picture(path[5:])
return self.answer(404)
if path == "/app":
return self.answer(200, APP.encode(), "text/html", [("Cache-Control", "no-store")])
if path == "/whoami":
token = re.search(r"(?:^|; )rrf-auth=([^;]+)", self.headers.get("Cookie", ""))
return self.answer(200 if token else 403, b"{}", "application/json")
if path in ("/rrframework/dataStore/signout", "/beacon"):
return self.answer(200, b"{}", "application/json")
if path == "/login":
return self.answer(302, headers=[
("Set-Cookie", "session=ok; Path=/; HttpOnly; SameSite=Lax"),
("Location", "/lesson")])
if not signed_in:
return self.answer(403, b"sign in first")
if path == "/links":
return self.answer(200, LINKS.replace("OTHER", str(OTHER)).encode(), "text/html")
if path == "/lesson":
page = LESSON.replace("NONCE", uuid.uuid4().hex).replace("OTHER", str(OTHER))
return self.answer(200, page.encode(), "text/html")
if path.startswith("/private/"):
return self.picture(path[9:])
if path.startswith("/once/"):
with lock:
if path in fetched_once:
return self.answer(404, b"this address has been used")
fetched_once.add(path)
return self.picture(path.rsplit("/", 1)[1])
return self.answer(404)
for port in (OTHER, PORT):
server = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
threading.Event().wait()
+207
View File
@@ -0,0 +1,207 @@
"""Make the test pictures, and measure where a printed picture really is.
wonderink-picture-measure.py make DIRECTORY
wonderink-picture-measure.py measure PDF WIDTHxHEIGHT SIZE POSITION PAPER FILL
wonderink-picture-measure.py darkness PDF
wonderink-picture-measure.py colour PDF
Run inside the lab by tools/lab/wonderink-print-check. The pictures are
white with a black border drawn on their own edges and a pink mark near
one corner, so the ink on a printed page says exactly where the picture
landed — and any other dark ink on the page is something that was added,
which is what the black bars were.
The page is rendered at 72 dpi, so one pixel is one PostScript point, and
compared against classroom_printing.picture_rect: the same arithmetic
Wonder Ink lays the page out with, asked independently of the preview.
"""
import json
import subprocess
import sys
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
import classroom_printing # noqa: E402
MARGIN_POINTS = 10 / 25.4 * 72
def make(directory):
from PyQt6.QtCore import QRectF, Qt
from PyQt6.QtGui import QColor, QFont, QImage, QPainter, QPen
from PyQt6.QtWidgets import QApplication
app = QApplication(sys.argv[:1]) # noqa: F841 — kept, or Qt has no application
def art(width, height, name, label, transparent=False):
image = QImage(width, height, QImage.Format.Format_ARGB32)
image.fill(QColor(0, 0, 0, 0) if transparent else QColor("white"))
painter = QPainter(image)
painter.setPen(QPen(QColor("black"), 8))
painter.drawRect(QRectF(4, 4, width - 8, height - 8))
painter.setPen(QPen(QColor("black"), 6))
painter.drawEllipse(QRectF(width * 0.2, height * 0.25, width * 0.6, height * 0.5))
painter.setFont(QFont("DejaVu Sans", int(min(width, height) * 0.05)))
painter.drawText(QRectF(0, height * 0.05, width, height * 0.15),
Qt.AlignmentFlag.AlignCenter, label)
painter.fillRect(QRectF(width * 0.05, height * 0.8, width * 0.1, height * 0.1),
QColor("#e0006c"))
painter.end()
image.save(f"{directory}/{name}")
art(1600, 1000, "ark-wide.png", "Colour the Ark")
art(900, 1400, "ark-tall.png", "Noah")
art(1600, 1000, "ark-wide.jpg", "Colour the Ark")
# Transparent around the drawing: on paper that has to be white, not
# black, which is what a careless composite would make of it.
art(1200, 1200, "ark-clear.png", "Clear", transparent=True)
webp(directory)
def webp(directory):
"""ark-wide.png as WebP. Qt cannot write WebP, or read it; Chromium does both."""
import base64
from PyQt6.QtCore import QTimer, QUrl
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QApplication
app = QApplication.instance() or QApplication(sys.argv[:1])
source = base64.b64encode(open(f"{directory}/ark-wide.png", "rb").read()).decode()
view = QWebEngineView()
def made(answer):
if isinstance(answer, str) and answer.startswith("data:image/webp;base64,"):
with open(f"{directory}/ark-wide.webp", "wb") as handle:
handle.write(base64.b64decode(answer.split(",", 1)[1]))
app.quit()
def loaded(_ok):
view.page().runJavaScript("""var i = new Image(); i.onload = function(){
var c = document.createElement('canvas'); c.width = i.width; c.height = i.height;
c.getContext('2d').drawImage(i, 0, 0); window.__webp = c.toDataURL('image/webp', 0.95); };
i.src = 'data:image/png;base64,%s'; 0""" % source)
QTimer.singleShot(1500, lambda: view.page().runJavaScript("window.__webp", made))
view.loadFinished.connect(loaded)
view.setHtml("<html></html>")
QTimer.singleShot(20000, app.quit)
app.exec()
def page_pixels(pdf, page=1):
ppm = subprocess.run(["pdftoppm", "-r", "72", "-f", str(page), "-l", str(page), "-singlefile", pdf],
capture_output=True, check=True).stdout
# P6 header: magic, width, height, maxval, then raw RGB.
fields, index = [], 0
while len(fields) < 4:
while ppm[index:index + 1].isspace():
index += 1
start = index
while not ppm[index:index + 1].isspace():
index += 1
fields.append(ppm[start:index])
width, height = int(fields[1]), int(fields[2])
return width, height, ppm[index + 1:]
def measure(pdf, picture, size, position, paper, fill):
width, height, rgb = page_pixels(pdf)
picture_w, picture_h = (int(n) for n in picture.split("x"))
landscape = classroom_printing.picture_landscape(paper, picture_w, picture_h)
x, y, w, h = classroom_printing.picture_rect(
(width, height), MARGIN_POINTS, (picture_w, picture_h),
int(size), position.replace("-", " "), fill == "fill")
# The expected ink, clipped to the printable area as the page is.
left = max(x, MARGIN_POINTS)
top = max(y, MARGIN_POINTS)
right = min(x + w, width - MARGIN_POINTS)
bottom = min(y + h, height - MARGIN_POINTS)
xs, ys, outside, pink = [], [], 0, 0
for row in range(height):
for column in range(width):
i = (row * width + column) * 3
r, g, b = rgb[i], rgb[i + 1], rgb[i + 2]
dark = r + g + b < 3 * 110
magenta = r > 150 and g < 90 and b > 60
if dark or magenta:
xs.append(column)
ys.append(row)
if not (left - 2 <= column <= right + 2 and top - 2 <= row <= bottom + 2):
outside += 1
if magenta:
pink += 1
# The corner mark, wherever it landed and whatever colour it came out:
# pink on a colour page, grey on a black-and-white one. Inked if most
# of the middle of where it should be is not paper.
mark_left = max(x + w * 0.07, MARGIN_POINTS)
mark_right = min(x + w * 0.13, width - MARGIN_POINTS)
mark_top = max(y + h * 0.82, MARGIN_POINTS)
mark_bottom = min(y + h * 0.88, height - MARGIN_POINTS)
mark = [
sum(rgb[(row * width + column) * 3:(row * width + column) * 3 + 3]) < 3 * 200
for row in range(int(mark_top), int(mark_bottom))
for column in range(int(mark_left), int(mark_right))
]
found = (min(xs), min(ys), max(xs) + 1, max(ys) + 1) if xs else None
expected = (round(left), round(top), round(right), round(bottom))
off = max(abs(a - b) for a, b in zip(found, expected)) if found else None
print(json.dumps({
"page": [width, height],
"page_landscape": width > height,
"wanted_landscape": landscape,
"found": found,
"expected": expected,
"off_by_points": off,
"ink_outside_picture": outside,
"pink_pixels": pink,
"mark_inked": bool(mark) and sum(mark) > 0.8 * len(mark),
"colour_spread": spread(rgb),
}))
def spread(rgb):
"""The most any pixel's red, green and blue differ: 0 on a grey page."""
return max((max(rgb[i:i + 3]) - min(rgb[i:i + 3]) for i in range(0, len(rgb) - 2, 3)), default=0)
def colour(pdf):
"""Is every page grey? The colour spread of each page, and how many."""
pages = int(subprocess.run(["pdfinfo", pdf], capture_output=True, text=True).stdout
.split("Pages:")[1].split()[0])
spreads = [spread(page_pixels(pdf, n)[2]) for n in range(1, pages + 1)]
print(json.dumps({"pages": pages, "spreads": spreads, "gray": max(spreads) == 0}))
def darkness(pdf):
"""How much of the first page is dark, 0 to 1."""
width, height, rgb = page_pixels(pdf)
sums = [rgb[i] + rgb[i + 1] + rgb[i + 2] for i in range(0, len(rgb) - 2, 3)]
dark = sum(1 for total in sums if total < 3 * 110)
# Anything printed at all. Blink darkens light text when a page's
# background is not printed, so white-on-black lesson text comes out
# grey on white rather than vanishing; this is what says it did.
ink = sum(1 for total in sums if total < 3 * 200)
print(json.dumps({"dark_fraction": round(dark / (width * height), 4),
"ink_fraction": round(ink / (width * height), 4)}))
if __name__ == "__main__":
if sys.argv[1] == "make":
make(sys.argv[2])
elif sys.argv[1] == "darkness":
darkness(sys.argv[2])
elif sys.argv[1] == "colour":
colour(sys.argv[2])
else:
measure(*sys.argv[2:])
+260 -1
View File
@@ -25,6 +25,11 @@ lab() { docker exec -i "$NAME" bash -c "$1"; }
RIG=/opt/wonderink-check/wonderink-print-rig.py
lab 'mkdir -p /opt/wonderink-check'
docker cp "$ROOT/tools/lab/wonderink-print-rig.py" "$NAME:$RIG" >/dev/null
MEASURE=/opt/wonderink-check/wonderink-picture-measure.py
docker cp "$ROOT/tools/lab/wonderink-picture-measure.py" "$NAME:$MEASURE" >/dev/null
SITE=/opt/wonderink-check/wonderink-lesson-site.py
SITE_LOG=/opt/wonderink-check/site.log
docker cp "$ROOT/tools/lab/wonderink-lesson-site.py" "$NAME:$SITE" >/dev/null
lab "chmod -R a+rX /opt/wonderink-check && test -r $RIG" || {
echo "could not put the rig in the lab" >&2
exit 1
@@ -63,13 +68,33 @@ install -d -o teacher -m 700 /tmp/teacher-rt
# An inline PDF, served the way a site shows one rather than sends it. Its
# own port and directory: a lab often has another test server on 8088.
mkdir -p /opt/wonderink-check/www
# CUPS keeps what it was given, so a check can compare the submitted
# file with the one the preview showed, byte for byte.
cupsctl PreserveJobFiles=Yes
' || { echo "could not prepare the lab's CUPS" >&2; exit 1; }
lab "test -f /opt/wonderink-check/www/ark-wide.webp || \
QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS='--no-sandbox --disable-gpu' \
python3 $MEASURE make /opt/wonderink-check/www; \
chmod -R a+r /opt/wonderink-check/www; test -f /opt/wonderink-check/www/ark-wide.webp" || {
echo "could not make the test pictures" >&2
exit 1
}
# Started detached. A background process started inside a docker exec
# dies with it in the Plasma lab, whose PID 1 is systemd.
lab 'pgrep -f "[h]ttp.server 8089" >/dev/null' || \
docker exec -d -w /opt/wonderink-check/www "$NAME" python3 -m http.server 8089
# The signed-in lesson with pictures in it, restarted each run so that its
# one-time addresses start unused.
lab "pkill -f '[w]onderink-lesson-site.py' ; : > $SITE_LOG; chmod a+rw $SITE_LOG"
docker exec -d "$NAME" python3 "$SITE" /opt/wonderink-check/www 8094 8095 "$SITE_LOG"
lab 'for _ in $(seq 20); do curl -s -o /dev/null http://127.0.0.1:8094/login && exit 0; sleep 0.3; done; exit 1' || {
echo "the lesson site did not start" >&2
exit 1
}
passed=0
failed=0
@@ -97,12 +122,97 @@ run() {
}
count() { wc -w <<< "$jobs"; }
# The file CUPS was handed for a job, kept by PreserveJobFiles.
spool_file() { local id="${1%% *}"; printf '/var/spool/cups/d%05d-001' "${id##*-}"; }
said() { grep -q -- "$1" <<< "$out"; }
# Every line of the rig's output that starts with WORD.
lines() { grep -o -- "$1.*" <<< "$out"; }
# Is every page of a PDF in the lab grey? {"pages", "spreads", "gray"}
colour_of() { lab "python3 $MEASURE colour $1"; }
is_gray() { python3 -c 'import json,sys; sys.exit(0 if json.loads(sys.argv[1])["gray"] else 1)' "$1"; }
# What the lesson site was asked for after the right-click.
fetched_since() {
local since
since=$(grep -o 'since=[0-9.]*' <<< "$out" | head -1 | cut -d= -f2)
lab "python3 -c 'import json,sys; print(sum(1 for l in open(\"$SITE_LOG\") if json.loads(l)[\"t\"] > float(sys.argv[1])))' ${since:-0}"
}
teacher_text_kept() { said "CLIPBOARD \"TEACHER'S TEXT, John 3:16\""; }
# picture layout, measured on the page CUPS kept: judges its own lines.
judge_layout() { # FILE_DIMS SIZE POSITION PAPER FILL COLOUR
local dims=$1 size=$2 position=$3 paper=$4 fill=$5 color=$6 spool result
spool=$(spool_file "$jobs")
judge "CUPS was given exactly the page the preview showed" \
"[[ -n '$spool' ]] && lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
result=$(lab "python3 $MEASURE measure $spool $dims $size $position $paper $fill")
field() { python3 -c "import json,sys; print(json.loads(sys.argv[1])[sys.argv[2]])" "$result" "$1"; }
judge "the paper is turned the way asked ($paper): $(field page)" \
'[[ "$(field page_landscape)" == "$(field wanted_landscape)" ]]'
judge "the picture is where and as large as asked, within 3 points (off by $(field off_by_points))" \
'[[ "$(field off_by_points)" != None ]] && (( $(field off_by_points) <= 3 ))'
judge "no ink anywhere else on the page — no bars ($(field ink_outside_picture) pixels)" \
'[[ "$(field ink_outside_picture)" == 0 ]]'
if [[ "$fill" == fill ]]; then
# The mark is in the picture's left fifth, which filling a
# portrait page with a wide picture cuts off. That is what the
# control says it does; the fit scenarios prove it happens only
# when asked.
judge "Fill cut the edges off, as it says it will" '(( $(field pink_pixels) == 0 ))'
elif [[ "$color" == gray ]]; then
judge "the corner mark is on the paper, in grey — nothing cut off" '[[ "$(field mark_inked)" == True ]]'
judge "and the page is grey, as the preview showed (colour spread $(field colour_spread))" \
'(( $(field colour_spread) == 0 ))'
judge "and it went in black and white, by the driver's own option" \
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]'
else
judge "the pink corner mark is on the paper — nothing cut off" '(( $(field pink_pixels) > 0 ))'
fi
}
exited_cleanly() { said 'exit=0' && ! said 'Traceback' && ! said 'Segmentation' && ! said 'Aborted'; }
# Where a picture goes, measured on the page CUPS was given:
# picture:FILE:SIZE:POSITION:PAPER:FILL[:gray]
PICTURES=(
picture:ark-wide.png:100:centre:auto:fit
picture:ark-wide.png:100:centre:portrait:fit
picture:ark-wide.png:50:top-left:auto:fit
picture:ark-tall.png:100:centre:auto:fit
picture:ark-tall.png:33:bottom-right:portrait:fit
picture:ark-wide.png:100:centre:portrait:fill
picture:ark-wide.jpg:75:right:auto:fit:gray
picture:ark-clear.png:50:top:auto:fit
picture:ark-wide.png:50:top-left:auto:fit:gray
)
# Black and white shown as it prints, and Color again (0.2.6.3).
COLOURS=(bw-web bw-pdf bw-colour-again bw-cancel)
# Links in a signed-in lesson, followed with one click (0.2.6.4).
LINKS=(links:follow links:pdf links:image links:image-rclick links:tabs-print)
# Right-click in a signed-in lesson (0.2.6.3):
# rclick-image:ELEMENT:SIZE:POSITION[:gray], the elements in
# wonderink-lesson-site.py.
RIGHT_CLICKS=(
rclick-page
rclick-page-gray
rclick-image:private-jpg:50:top-left
rclick-image:private-png:100:centre
rclick-image:clear:50:top-left
rclick-image:once:100:centre
rclick-image:cross:75:right
rclick-image:blob:100:centre
rclick-image:webp:100:centre
rclick-image:canvas:100:centre
rclick-image:linked:33:bottom-right
rclick-image:private-jpg:50:top-left:gray
rclick-broken
rclick-cancel
)
scenarios=("$@")
[[ ${#scenarios[@]} -gt 0 ]] || scenarios=(web pdf pdf-options pdf-viewer-button inline
options bad-range broken cancel page-closes-itself close-window page-prints-again repeat)
options bad-range broken cancel page-closes-itself close-window page-prints-again repeat
dark-page "${PICTURES[@]}" "${COLOURS[@]}" "${RIGHT_CLICKS[@]}" "${LINKS[@]}")
# The inline scenario needs a PDF to serve; the worksheet scenario writes one.
lab 'test -f /opt/wonderink-check/www/ark.pdf' || { run pdf >/dev/null; lab 'cp "/home/teacher/Downloads/Wonder Ink/Colour the Ark.pdf" /opt/wonderink-check/www/ark.pdf; chmod a+r /opt/wonderink-check/www/ark.pdf'; }
@@ -162,6 +272,155 @@ for scenario in "${scenarios[@]}"; do
judge "one job" '[[ $(count) -eq 1 ]]' ;;
repeat)
judge "five prints in a row, five jobs" '[[ $(count) -eq 5 ]]' ;;
dark-page)
judge "one job" '[[ $(count) -eq 1 ]]'
spool=$(spool_file "$jobs")
answer=$(lab "python3 $MEASURE darkness $spool")
dark=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["dark_fraction"])' "$answer")
ink=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["ink_fraction"])' "$answer")
judge "a black page background does not print (dark: $dark of the page)" \
"python3 -c 'import sys; sys.exit(0 if $dark < 0.05 else 1)'"
judge "and its white text still does, darkened ($ink of the page)" \
"python3 -c 'import sys; sys.exit(0 if $ink > 0.005 else 1)'" ;;
picture:*)
IFS=: read -r _ file size position paper fill color <<< "$scenario"
judge "the picture controls appeared, and none for a page" "said 'PICTURE picture_box=True'"
judge "the print interface says Color" "said \"labels='Color','Color'\""
if [[ "$color" == gray ]]; then
judge "the preview shows it in black and white" "said 'shown_gray=True'"
fi
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
dims=$(lab "python3 -c 'from PyQt6.QtGui import QImage; i=QImage(\"/opt/wonderink-check/www/$file\"); print(f\"{i.width()}x{i.height()}\")'" 2>/dev/null)
judge_layout "$dims" "$size" "$position" "$paper" "$fill" "$color" ;;
bw-*)
judge "the preview starts in colour" "said 'COLOUR FIRST shown_gray=False'"
if [[ "$scenario" != bw-colour-again ]]; then
judge "Black and white shows a grey page, every time it is chosen" \
'! lines "want=gray" | grep -qv "shown_gray=True" && lines "want=gray" | grep -q .'
fi
if lines "want=colour" | grep -q .; then
judge "Color shows the colour page again, every time" '! lines "want=colour" | grep -qv "shown_gray=False"'
fi
case "$scenario" in
bw-web)
judge "turned over and over, it stays on page 2 of 3" '! lines TOGGLE | grep -qv "page=2/3"'
judge "black and white is made once, and the same file shown each time" \
'[[ $(lines "want=gray" | grep -o "pdf=[^ ]*" | sort -u | wc -l) -eq 1 ]]'
judge "and shown again at once (after the first: $(lines 'want=gray' | tail -n +2 | grep -o 'took=[0-9.]*' | tr '\n' ' '))" \
'! lines "want=gray" | tail -n +2 | grep -o "took=[0-9.]*" | cut -d= -f2 | grep -qv "^0\.[0-4]"'
judge "one job" '[[ $(count) -eq 1 ]]'
judge "page 2 only, two copies" \
'[[ "$(job_options $jobs)" == *page-ranges=2* && "$(job_options $jobs)" == *"argv[4]=\"2\""* ]]'
judge "in black and white, by the driver's own option" \
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
bw-pdf)
judge "one job" '[[ $(count) -eq 1 ]]'
judge "in black and white, by the driver's own option" \
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
bw-colour-again)
judge "one job" '[[ $(count) -eq 1 ]]'
judge "in colour, with no grey option" \
'[[ "$(job_options $jobs)" != *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlcolor* ]]' ;;
bw-cancel)
judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]'
judge "and nothing was left behind" "said 'scratch_left=\[\]'" ;;
esac
if [[ $(count) -eq 1 ]]; then
spool=$(spool_file "$jobs")
judge "CUPS was given exactly the file the preview showed" "lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
answer=$(colour_of "$spool")
if [[ "$scenario" == bw-colour-again ]]; then
judge "and it is in colour ($answer)" '! is_gray "$answer"'
else
judge "and every page of it is grey ($answer)" 'is_gray "$answer"'
fi
fi ;;
rclick-page)
judge "right-click on the words offers Print page, and not Print image" \
"said \"MENU .*'Print page…'\" && ! said \"'Print image…'\""
judge "which prints the lesson page, not a picture" "said 'PREVIEW picture=False'"
judge "one job" '[[ $(count) -eq 1 ]]'
judge "CUPS was given exactly the page the preview showed" \
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'"
judge "the teacher's clipboard is untouched" teacher_text_kept ;;
links:follow)
# link_is WHICH EXPECTED — the rig's line for that link contains EXPECTED.
link_is() { grep -o -- "LINK $1 .*" <<< "$out" | head -1 | grep -qF -- "$2"; }
judge "a text link to a picture opens it in a new tab, and Wonder Ink shows that tab" \
"link_is '#blank' \"tabs=2 current='/private/ark-wide.png' current_is_new=True\""
judge "another site's link: the same" \
"link_is '#external' \"tabs=3 current='http://localhost:8095/img/ark-tall.png' current_is_new=True\""
judge "a linked picture: the same" \
"link_is '#linked-picture' \"tabs=4 current='/private/ark-tall.png' current_is_new=True\""
judge "a worksheet PDF: a new tab, in the PDF viewer" \
"link_is '#pdf' \"tabs=5 current='/private/ark.pdf' current_is_new=True\""
judge "a lesson button that opens a window: a new tab, shown" \
"link_is '#popup' \"tabs=6 current='/private/ark-clear.png' current_is_new=True\""
judge "a lesson button that is not a link: no tab, no navigation" \
"link_is '#button' \"tabs=6 current='/links' current_is_new=False\""
judge "an in-page anchor: stays on the page, scrolled" \
"link_is '#anchor' \"tabs=6 current='/links#bottom' current_is_new=False\""
judge "a link with no target: followed in the same tab, as a browser does" \
"link_is '#same' \"tabs=6 current='/lesson' current_is_new=False\""
judge "and Back returns to the links" "link_is 'after Back' \"current='/links\""
judge "closing every tab the links opened leaves the lesson's own" \
"link_is 'after closing' \"tabs=1 current='/links\""
judge "every page and picture the links reached came through the signed-in session" \
"! lab \"grep '\\\"signed_in\\\": false' $SITE_LOG | grep -qE '\\\"path\\\": \\\"/(links|lesson|private)'\"" ;;
links:pdf)
judge "a worksheet reached by a link opens as a worksheet and prints" \
"said 'pages=2' && [[ \$(count) -eq 1 ]]"
judge "CUPS was given exactly the file the preview showed" \
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'" ;;
links:image|links:image-rclick)
if [[ "$scenario" == links:image-rclick ]]; then
judge "right-click on the picture a link opened offers Print image" "said \"MENU .*'Print image…'\""
fi
judge "the picture a link opened gets the picture layout" "said 'PICTURE picture_box=True image=1600x1000'"
judge "one job" '[[ $(count) -eq 1 ]]'
judge "CUPS was given exactly the page the preview showed" \
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'" ;;
links:tabs-print)
judge "three links opened and two closed again: two tabs left, back on the links" \
"said \"LINK before printing tabs=2 current='/links'\""
judge "the page they came from prints, one job" '[[ $(count) -eq 1 ]] && said "PREVIEW picture=False"'
judge "CUPS was given exactly the page the preview showed" \
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'" ;;
rclick-page-gray)
judge "a lesson page full of pictures turns grey in the preview ($(lines 'GRAY PAGE' | grep -o 'took=[0-9.]*'))" \
"said 'GRAY PAGE .*shown_gray=True'"
judge "one job" '[[ $(count) -eq 1 ]]'
spool=$(spool_file "$jobs")
judge "CUPS was given exactly the file the preview showed" "lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
answer=$(colour_of "$spool")
judge "and every page of it is grey ($answer)" 'is_gray "$answer"'
judge "in black and white, by the driver's own option" \
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
rclick-image:*)
IFS=: read -r _ element size position color <<< "$scenario"
judge "right-click on the picture offers Print image and Print page" \
"said \"MENU .*'Print image…', 'Print page…'\""
judge "Print image opens the picture layout" "said 'PICTURE picture_box=True'"
judge "named for the lesson it came from" "said \"title='Picture from Picture lesson'\""
judge "nothing was fetched again to get it ($(fetched_since) requests)" '[[ $(fetched_since) -eq 0 ]]'
judge "the teacher's clipboard is theirs again" teacher_text_kept
if [[ "$color" == gray ]]; then
judge "the preview shows it in black and white" "said 'shown_gray=True'"
fi
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
dims=$(grep -o 'image=[0-9]*x[0-9]*' <<< "$out" | head -1 | cut -d= -f2)
judge_layout "$dims" "$size" "$position" auto fit "$color" ;;
rclick-broken)
judge "a picture that never loaded is still offered as a picture" "said \"MENU .*'Print image…'\""
judge "and the teacher is told it could not be taken, with Print off" \
"said \"BROKEN status='This picture could not be taken from the page\" && said 'print_enabled=False pages=0 picture=False'"
judge "the whole page is not printed instead" '[[ $(count) -eq 0 ]]'
judge "the teacher's clipboard is untouched" teacher_text_kept ;;
rclick-cancel)
judge "the picture preview came up" "said 'PREVIEW picture=True'"
judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]'
judge "the teacher's clipboard is theirs again" teacher_text_kept
judge "and nothing was left behind" "said 'scratch_left=\[\]'" ;;
esac
[[ "$failed" -eq 0 ]] || { echo "$out" | tail -15 | sed 's/^/ /'; }
+312 -7
View File
@@ -23,9 +23,8 @@ from PyQt6.QtWebEngineCore import QWebEngineProfile, QWebEngineSettings
notes = []
wi.notify = lambda s, b: (notes.append(s), print("NOTIFY", s, "|", b, flush=True))
app = QApplication(sys.argv)
profile = QWebEngineProfile("rig", app)
for a in ("PdfViewerEnabled", "PluginsEnabled"):
profile.settings().setAttribute(getattr(QWebEngineSettings.WebAttribute, a), True)
# Wonder Ink's own profile, settings and all — not one of the rig's.
profile = wi.build_profile(app, "rig")
window = wi.WonderInk(profile)
profile.downloadRequested.connect(window.on_download)
window.resize(1280, 800); window.show()
@@ -60,7 +59,53 @@ def when(cond, then, label, limit=30):
def preview(): return window._preview
def ready(): return preview() is not None and preview()._pdf is not None
def describe(d):
return f"shown={d._page in d._images} pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}"
return f"picture={d._picture_box.isVisible()} shown={(d._pdf, d._page) in d._images} pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}"
def shown_gray():
"""Is the page the preview is drawing grey, pixel for pixel?"""
pixmap = preview()._view._pixmap
return None if pixmap is None else pixmap.toImage().allGray()
def keep_preview():
import shutil
out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True)
shutil.copy(preview()._pdf, out / "preview.pdf")
def toggles(sequence, then):
"""Color (0) and Black and white (1) in turn, as a teacher would, each shown before the next."""
def step(i):
if i == len(sequence): return then()
start = time.monotonic(); preview()._colour.setCurrentIndex(sequence[i])
def shown():
d = preview()
stamp(f"TOGGLE {i} want={'gray' if sequence[i] else 'colour'} took={time.monotonic() - start:.2f} "
f"pdf={d._pdf.name} source={d._source.name} page={d._page}/{d._pages} shown_gray={shown_gray()}")
step(i + 1)
when(ready, shown, f"toggle {i}")
step(0)
def colour_lesson(view, title="Rainbow lesson"):
# Backgrounds do not print, so the colour is in the words and the drawings.
shapes = ("<svg width='600' height='120'><circle cx='60' cy='60' r='50' fill='#e00000'/>"
"<rect x='130' y='10' width='200' height='100' fill='#0050e0'/>"
"<circle cx='450' cy='60' r='50' fill='#00a000'/></svg>")
view.setHtml(f"<html><head><title>{title}</title></head><body>" + "".join(
f"<h1 style='color:#c00'>{title}, part {i}</h1>{shapes}"
f"<p style='color:#060'>{'God put a rainbow in the sky. ' * 60}</p>" for i in range(4)
) + "</body></html>", QUrl("https://app.wonderink.org/rainbow"))
def colour_worksheet(name="Colour the Rainbow"):
from PyQt6.QtGui import QColor
d = wi.DOWNLOADS; d.mkdir(parents=True, exist_ok=True)
p = d / f"{name}.pdf"
w = QPdfWriter(str(p)); w.setPageSize(QPageSize(QPageSize.PageSizeId.Letter)); w.setResolution(300)
pa = QPainter(w); pa.setFont(QFont("DejaVu Sans", 28))
for page, colours in enumerate((("#e00000", "#ff9900"), ("#00a000", "#0050e0"))):
if page: w.newPage()
pa.setPen(QColor(colours[0])); pa.drawText(200, 400, f"WORKSHEET {name} page {page + 1}")
pa.fillRect(200, 600, 1000, 800, QColor(colours[1]))
pa.end()
return p
def finish(code=0):
stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}")
@@ -77,7 +122,8 @@ actions = {
"cancel": lambda view: when(ready, lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()), "preview"),
"options": lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("2-3"),
preview()._copies.setValue(2), preview()._colour.setCurrentIndex(1), press_print()), "preview"),
preview()._copies.setValue(2), preview()._colour.setCurrentIndex(1),
when(ready, press_print, "black and white")), "preview"),
"bad-range": lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("7"), preview()._print.click(),
stamp(f"bad range: open={preview() is not None} status={preview()._status.text()!r}"),
@@ -101,7 +147,265 @@ actions = {
QTimer.singleShot(800, lambda: (stamp(f"second request while open: still one preview={preview() is not None}"), press_print()))), "preview"),
}
if scenario == "repeat":
SITE = "http://127.0.0.1:8094"
if scenario.startswith("bw-"):
# bw-web: a colourful lesson, turned to page 2, Color and Black and white
# back and forth, then page 2 twice in black and white.
# bw-pdf: a colourful worksheet, all of it, in black and white.
# bw-colour-again: black and white, then back to Color, then Print.
# bw-cancel: black and white, then Cancel.
if scenario == "bw-pdf":
window.open_pdf(colour_worksheet()); view = window.current()
else:
view = window.open_tab(); colour_lesson(view)
def colour_first():
stamp(f"COLOUR FIRST shown_gray={shown_gray()} pdf={preview()._pdf.name}")
if scenario == "bw-web":
preview()._turn(1)
toggles([1, 0, 1, 0, 1], lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("2"),
preview()._copies.setValue(2), keep_preview(), press_print()))
elif scenario == "bw-pdf":
toggles([1], lambda: (keep_preview(), press_print()))
elif scenario == "bw-colour-again":
toggles([1, 0], lambda: (keep_preview(), press_print()))
else:
toggles([1], lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()))
begin = lambda *_: (QTimer.singleShot(0, lambda: window.print_view(view)), when(ready, colour_first, "preview"))
if scenario == "bw-pdf":
QTimer.singleShot(2500, begin)
else:
view.loadFinished.connect(begin)
elif scenario.startswith("links:"):
# links:follow every kind of link on the lab's signed-in links page,
# clicked in turn: what opens where, and what does not
# links:pdf a worksheet reached by a link, printed
# links:image a picture reached by a link, printed as a picture
# links:image-rclick the same picture, right-click Print image
# links:tabs-print several links opened, some closed, then the page printed
from PyQt6.QtCore import QEvent, QPointF, Qt
from PyQt6.QtGui import QMouseEvent
from PyQt6.QtWidgets import QApplication as Application
variant = scenario.split(":", 1)[1]
links_view = window.open_tab(); links_view.load(QUrl(f"{SITE}/login"))
def on_links():
return links_view.url().path() == "/lesson" or links_view.url().path() == "/links"
def click_in(view, selector, then, button=Qt.MouseButton.LeftButton):
js = ("(function(){var e=document.querySelector(%r); if(!e) return null; e.scrollIntoView({block:'center'});"
"var r=e.getBoundingClientRect(); return [r.x+r.width/2, r.y+r.height/2]})()" % selector)
def at(xy):
if not xy:
stamp(f"NO {selector}"); return finish(6)
zoom = view.zoomFactor(); point = QPointF(xy[0] * zoom, xy[1] * zoom)
for kind, buttons in ((QEvent.Type.MouseButtonPress, button), (QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
Application.sendEvent(view.focusProxy(), QMouseEvent(kind, point, QPointF(view.mapToGlobal(point)),
button, buttons, Qt.KeyboardModifier.NoModifier))
QTimer.singleShot(1500, then)
view.page().runJavaScript(js, at)
def tabs():
return [window._tabs.widget(i).url().toString().replace(SITE, "") for i in range(window._tabs.count())]
def report(label):
current = window.current()
stamp(f"LINK {label} tabs={len(tabs())} current={current.url().toString().replace(SITE, '')!r} "
f"current_is_new={current is not links_view} worksheet={current in window._pdf_views}")
def back_to_links():
window._tabs.setCurrentWidget(links_view)
def go_links(then):
# From the lesson the site sent us to after signing in, to the links page, in this tab.
links_view.load(QUrl(f"{SITE}/links"))
when(lambda: links_view.url().path() == "/links", lambda: QTimer.singleShot(1200, then), "the links page")
steps = []
if variant == "follow":
order = [("#blank", "text link, target=_blank, to a picture"), ("#external", "another site"),
("#linked-picture", "a linked picture"), ("#pdf", "a worksheet PDF"),
("#popup", "a lesson button that opens a window"), ("#button", "a lesson button"),
("#anchor", "an in-page anchor"), ("#same", "a link with no target")]
def next_link(i=0):
if i == len(order):
# Back, as the right-click menu's Back does, then close every tab the links opened.
links_view.page().triggerAction(links_view.page().WebAction.Back)
def closed_all():
report("after Back")
for view in [window._tabs.widget(j) for j in range(window._tabs.count())]:
if view is not links_view:
window.close_tab(window._tabs.indexOf(view))
QTimer.singleShot(800, lambda: (report("after closing the opened tabs"),
stamp(f"TABS {tabs()}"), finish()))
return QTimer.singleShot(2000, closed_all)
selector, label = order[i]
back_to_links()
click_in(links_view, selector, lambda: (report(f"{selector} ({label})"),
stamp(f"BUTTON {links_view.page().title()!r}") if selector == "#button" else None,
QTimer.singleShot(1500 if selector == "#pdf" else 200, lambda: next_link(i + 1))))
when(on_links, lambda: go_links(lambda: (report("start"), next_link())), "signed in")
elif variant in ("pdf", "image", "image-rclick", "tabs-print"):
target = {"pdf": "#pdf", "image": "#blank", "image-rclick": "#blank", "tabs-print": "#blank"}[variant]
def opened():
report(f"{target} opened")
view = window.current()
if variant == "pdf":
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(window.current())),
when(ready, lambda: (keep_preview(), press_print()), "worksheet preview")))
elif variant == "image":
QTimer.singleShot(0, lambda: window.print_view(view))
when(lambda: ready() and preview()._picture is not None, lambda: (
stamp(f"PICTURE picture_box={preview()._picture_box.isVisible()} image={preview()._picture.width()}x{preview()._picture.height()}"),
keep_preview(), press_print()), "picture preview")
elif variant == "image-rclick":
def menu_up(menu):
items = [a.text() for a in menu.actions() if a.text()]
stamp(f"MENU {items}")
action = next(a for a in menu.actions() if a.text() == "Print image…")
menu.close()
when(lambda: ready() and preview()._picture is not None, lambda: (
stamp(f"PICTURE picture_box={preview()._picture_box.isVisible()} image={preview()._picture.width()}x{preview()._picture.height()}"),
keep_preview(), press_print()), "picture preview")
QTimer.singleShot(0, action.trigger)
click_in(view, "img", lambda: when(lambda: Application.activePopupWidget() is not None,
lambda: menu_up(Application.activePopupWidget()), "menu"),
button=Qt.MouseButton.RightButton)
else:
# Three links opened, two of them closed again, then the page they came from printed.
def more():
back_to_links()
click_in(links_view, "#external", lambda: (back_to_links(), click_in(links_view, "#linked-picture", close_two)))
def close_two():
for view in [window._tabs.widget(j) for j in range(window._tabs.count())][-2:]:
window.close_tab(window._tabs.indexOf(view))
back_to_links(); report("before printing")
QTimer.singleShot(0, lambda: window.print_view(links_view))
when(ready, lambda: (keep_preview(), press_print()), "page preview")
more()
when(on_links, lambda: go_links(lambda: (back_to_links(), click_in(links_view, target, lambda: QTimer.singleShot(1500, opened)))), "signed in")
elif scenario.startswith("rclick"):
# A lesson behind a sign-in, with pictures in it; a real right-click.
# rclick-page on the words: the page prints
# rclick-image:ID:SIZE:POSITION[:gray] on a picture: that picture prints
# rclick-broken a picture that never loaded
# rclick-cancel a picture, then Cancel
from PyQt6.QtCore import QEvent, QPointF, Qt
from PyQt6.QtGui import QMouseEvent
from PyQt6.QtWidgets import QApplication as Application
parts = scenario.split(":")
target = {"rclick-page": "words", "rclick-page-gray": "words", "rclick-broken": "broken",
"rclick-cancel": "private-jpg"}.get(parts[0], parts[1] if len(parts) > 1 else "")
clipboard = Application.clipboard()
view = window.open_tab(); view.load(QUrl(f"{SITE}/login"))
page_ready = []
def poll_ready():
if view.url().path() == "/lesson":
view.page().runJavaScript("window.__ready === true", lambda ok: page_ready.append(1) if ok else None)
if not page_ready: QTimer.singleShot(200, poll_ready)
QTimer.singleShot(500, poll_ready)
def right_click(then):
js = ("(function(){var e=document.getElementById(%r);e.scrollIntoView({block:'center'});"
"var r=e.getBoundingClientRect();return [r.x+r.width/2,r.y+r.height/2]})()" % target)
def at(xy):
zoom = view.zoomFactor(); x, y = xy[0] * zoom, xy[1] * zoom
clipboard.setText("TEACHER'S TEXT, John 3:16")
stamp(f"RCLICK {target} at {x:.0f},{y:.0f} since={time.time():.3f}")
for kind, buttons in ((QEvent.Type.MouseButtonPress, Qt.MouseButton.RightButton),
(QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
Application.sendEvent(view.focusProxy(), QMouseEvent(
kind, QPointF(x, y), QPointF(view.mapToGlobal(QPointF(x, y))),
Qt.MouseButton.RightButton, buttons, Qt.KeyboardModifier.NoModifier))
when(lambda: Application.activePopupWidget() is not None,
lambda: then(Application.activePopupWidget()), "menu")
view.page().runJavaScript(js, at)
def choose(menu, text, after):
items = [a.text() for a in menu.actions() if a.text()]
stamp(f"MENU {items}")
action = next((a for a in menu.actions() if a.text() == text), None)
menu.close()
if action is None:
stamp(f"NO {text!r} IN THE MENU"); return finish(5)
after()
QTimer.singleShot(0, action.trigger)
def picture_ready():
d = preview(); return d is not None and d._picture is not None and d._pdf is not None
def clipboard_line():
stamp(f"CLIPBOARD {clipboard.text()!r}")
if parts[0] == "rclick-page":
go = lambda: right_click(lambda m: choose(m, "Print page…", lambda: when(ready, lambda: (
clipboard_line(), keep_preview(), press_print()), "page preview")))
elif parts[0] == "rclick-page-gray":
# The page with all its pictures, in black and white: the slow case.
def gray_page():
start = time.monotonic(); preview()._colour.setCurrentIndex(1)
when(ready, lambda: (
stamp(f"GRAY PAGE took={time.monotonic() - start:.1f} shown_gray={shown_gray()} pages={preview()._pages}"),
keep_preview(), press_print()), "black and white", limit=70)
go = lambda: right_click(lambda m: choose(m, "Print page…", lambda: when(ready, gray_page, "page preview")))
elif parts[0] == "rclick-broken":
go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when(
lambda: preview() is not None and "could not be taken" in preview()._status.text(), lambda: (
stamp(f"BROKEN status={preview()._status.text()!r} print_enabled={preview()._print.isEnabled()} "
f"pages={preview()._pages} picture={preview()._picture is not None}"),
clipboard_line(), preview()._cancel.click(), finish()), "the picture failing", limit=15)))
elif parts[0] == "rclick-cancel":
go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when(picture_ready, lambda: (
stamp("PREVIEW " + describe(preview())), clipboard_line(), preview()._cancel.click(), finish()),
"picture preview")))
else:
_, _, size, position, *colour = parts
def lay_out():
d = preview()
d._size.setCurrentIndex(d._size.findData(int(size)))
d._place.choose(position.replace("-", " "))
if colour == ["gray"]:
d._colour.setCurrentIndex(1)
QTimer.singleShot(900, lambda: when(ready, lambda: (
stamp(f"PICTURE picture_box={d._picture_box.isVisible()} image={d._picture.width()}x{d._picture.height()} "
f"title={d._title!r} shown_gray={shown_gray()}"),
clipboard_line(), keep_preview(), press_print()), "layout"))
go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when(picture_ready, lay_out, "picture preview")))
when(lambda: bool(page_ready), go, "the lesson and its pictures", limit=30)
elif scenario.startswith("picture:"):
# picture:FILE:SIZE:POSITION:PAPER:FILL[:color]
# Opens a picture on its own, chooses the layout with the preview's own
# controls, keeps the exact PDF the preview showed, then prints it.
_, name, size, position, paper, fill, *rest = scenario.split(":")
view = window.open_tab(); view.load(QUrl(f"http://127.0.0.1:8089/{name}"))
def ready_picture():
d = preview()
return d is not None and d._picture is not None and d._pdf is not None
def choose():
d = preview()
d._size.setCurrentIndex(d._size.findData(int(size)))
d._place.choose(position.replace("-", " "))
d._turned.setCurrentIndex(d._turned.findData(paper))
d._fill.setChecked(fill == "fill")
if rest and rest[0] == "gray":
d._colour.setCurrentIndex(1)
# The layout settles (120 ms), then black and white if asked for.
QTimer.singleShot(900, lambda: when(ready, keep_and_print, "layout"))
def keep_and_print():
import shutil
d = preview()
out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True)
shutil.copy(d._pdf, out / "preview.pdf")
stamp(f"PICTURE picture_box={d._picture_box.isVisible()} size_enabled={d._size.isEnabled()} "
f"image={d._picture.width()}x{d._picture.height()} labels={d._colour_label.text()!r},{d._colour.itemText(0)!r} "
f"shown_gray={shown_gray()}")
press_print()
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)),
when(ready_picture, choose, "picture preview")))
elif scenario == "dark-page":
# A lesson page with a black background. Chrome does not print page
# backgrounds unless asked, and neither may Wonder Ink.
view = window.open_tab()
view.setHtml("<html><head><title>Night sky</title></head><body style='background:#000;color:#fff'>"
+ "<h1>Stars</h1>" + "<p>God made the stars. </p>" * 40 + "</body></html>",
QUrl("https://app.wonderink.org/night"))
def keep_dark():
import shutil
out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True)
shutil.copy(preview()._pdf, out / "preview.pdf")
press_print()
view.loadFinished.connect(lambda ok: (QTimer.singleShot(0, lambda: window.print_view(view)),
when(ready, keep_dark, "dark preview")))
elif scenario == "repeat":
view = window.open_tab(); lesson(view); n = [0]
def once():
if n[0] == 5: return finish()
@@ -113,7 +417,8 @@ elif scenario in ("pdf", "pdf-options"):
if scenario == "pdf-options":
actions["pdf-options"] = lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("2"),
preview()._copies.setValue(3), preview()._colour.setCurrentIndex(1), press_print()), "preview")
preview()._copies.setValue(3), preview()._colour.setCurrentIndex(1),
when(ready, press_print, "black and white")), "preview")
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)),
actions[scenario if scenario == "pdf-options" else "web"](view)))
elif scenario == "pdf-viewer-button":
+234
View File
@@ -0,0 +1,234 @@
#!/bin/bash
# Temporary workspace restore: do a teacher's recent tabs come back — and
# only for a while, and never with their sign-in?
#
# Each scenario launches the INSTALLED classroom-wonderink one or more times
# (tools/lab/wonderink-workspace-rig.py), against the lab's stand-in for
# WonderInk's start page and a second origin standing in for its public
# resource files, and is judged by the tabs each launch opens and by the
# kept file itself. Time between launches is made to pass by moving the
# time of last use in the file; time within a launch is real, with the
# limit shortened by the test-only override where a scenario needs it.
#
# tools/lab/wonderink-workspace-check [SCENARIO...]
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
NAME="${LAB_NAME:-classroom-v2-lab}"
COMMAND="${WONDERINK:-/usr/local/bin/classroom-wonderink}"
DIR=/opt/wonderink-check
SITE=http://127.0.0.1:8094
SLIDE=http://localhost:8095/img/ark-wide.png
OTHER=http://localhost:8095/img/ark-tall.png
KEPT=/home/teacher/.local/share/ekklesia-classroom/wonderink-workspace.json
SHEETS="/home/teacher/Downloads/Wonder Ink"
lab() { docker exec -i "$NAME" bash -c "$1"; }
lab "mkdir -p $DIR"
for file in wonderink-workspace-rig.py wonderink-lesson-site.py wonderink-picture-measure.py; do
docker cp "$ROOT/tools/lab/$file" "$NAME:$DIR/$file" >/dev/null
done
lab "chmod -R a+rX $DIR; id teacher >/dev/null 2>&1 || useradd -m teacher; install -d -o teacher -m 700 /tmp/teacher-rt"
lab "test -f $DIR/www/ark-wide.webp || { mkdir -p $DIR/www; QT_QPA_PLATFORM=offscreen \
QTWEBENGINE_CHROMIUM_FLAGS='--no-sandbox --disable-gpu' python3 $DIR/wonderink-picture-measure.py make $DIR/www; }
chmod -R a+r $DIR/www"
lab "pkill -f '[w]onderink-lesson-site.py'; : > $DIR/site.log; chmod a+rw $DIR/site.log"
docker exec -d "$NAME" python3 "$DIR/wonderink-lesson-site.py" "$DIR/www" 8094 8095 "$DIR/site.log"
lab 'for _ in $(seq 20); do curl -s -o /dev/null http://127.0.0.1:8094/app && exit 0; sleep 0.3; done; exit 1' || {
echo "the lesson site did not start" >&2
exit 1
}
passed=0
failed=0
out=""
judge() {
if eval "$2"; then
printf ' \033[32mPASS\033[0m %s\n' "$1"
passed=$((passed + 1))
else
printf ' \033[31mFAIL\033[0m %s\n' "$1"
failed=$((failed + 1))
fi
}
# launch [limit=SECONDS] -- STEP... one Wonder Ink; its output appended to $out
launch() {
local env=""
[[ "$1" == limit=* ]] && { env="CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS=${1#limit=}"; shift; }
[[ "$1" == -- ]] && shift
out+=$'\n'"=== launch $*"$'\n'
out+=$(lab "su teacher -c 'cd /tmp; XDG_RUNTIME_DIR=/tmp/teacher-rt QT_QPA_PLATFORM=offscreen \
QTWEBENGINE_CHROMIUM_FLAGS=\"--no-sandbox --disable-gpu\" $env \
timeout 180 python3 $DIR/wonderink-workspace-rig.py $COMMAND $SITE $* 2>&1'; echo \"exit=\$?\"")
}
# The time of last use in the kept file, made SECONDS older (negative: the future).
age() {
lab "su teacher -c \"python3 -c 'import json,sys; p=sys.argv[1]; d=json.load(open(p)); d[\\\"last_activity\\\"]-=float(sys.argv[2]); json.dump(d, open(p, \\\"w\\\"))' $KEPT $1\""
}
said() { grep -q -- "$1" <<< "$out"; }
nth() { grep -o -- "$1.*" <<< "$out" | sed -n "${2}p"; }
kept_file() { lab "test -e $KEPT"; }
# The age a STATE line reports for the kept file.
age_of() { grep -o 'age=[0-9]*' <<< "$1" | cut -d= -f2; }
clean() { lab "rm -f $KEPT $KEPT.new; rm -rf \"$SHEETS\"; install -d -o teacher \"$SHEETS\""; out=""; }
# Every launch ended by itself with 0, except one the scenario killed on purpose.
no_crash() {
! said Traceback && ! said Segmentation && \
! awk '/\] CRASH/ {killed=1} /^exit=/ {if ($0 != "exit=0" && !(killed && $0 == "exit=137")) bad=1; killed=0} END {exit !bad}' <<< "$out"
}
THREE="touch open:$SLIDE open:$OTHER front:1 touch"
RESTORED="tabs=\\['/app', '$SLIDE', '$OTHER'\\] active=1"
FRESH="tabs=\\['/app'\\] active=0 kept\\[none\\]"
scenarios=("$@")
[[ ${#scenarios[@]} -gt 0 ]] || scenarios=(restore-5min restore-90min expire-2h1m expire-next-day expire-next-week
reopen-does-not-refresh activity-refreshes background-ignored preview-ignored printing-counts
closed-stays-closed reordered worksheet worksheet-gone crash-then-restore restore-crashes
corrupt future-format half-written privacy restored-tabs-print)
for scenario in "${scenarios[@]}"; do
echo "$scenario"
clean
before=$failed
case "$scenario" in
restore-5min|restore-90min)
minutes=${scenario#restore-}; minutes=${minutes%min}
launch -- $THREE state quit
age $((minutes * 60))
launch -- state front:0 state quit
judge "three tabs, the middle one in front, closed; $minutes minutes later: all three back" \
"nth STATE 2 | grep -q \"$RESTORED\""
judge "in the same order, the same one in front" "nth START 2 | grep -q 'tabs=3'"
judge "the tabs behind wait to load until they are shown (a PDF behind opened at 10%)" \
"nth STATE 2 | grep -q 'unshown=2'"
judge "and one clicked loads: the start page, asking to sign in" \
"nth STATE 3 | grep -q \"pages=\\['Signed out'\\] unshown=1\"" ;;
expire-2h1m|expire-next-day|expire-next-week)
case "$scenario" in
expire-2h1m) seconds=$((2 * 3600 + 60)); label="two hours and a minute" ;;
expire-next-day) seconds=$((24 * 3600)); label="the next day" ;;
*) seconds=$((7 * 24 * 3600)); label="the next week" ;;
esac
launch -- $THREE quit
age $seconds
launch -- state quit
judge "$label: a fresh Wonder Ink, one tab on the start page" "nth STATE 1 | grep -q \"$FRESH\""
judge "the old workspace is gone, and says why" "said 'starting fresh (expired)' && ! kept_file" ;;
reopen-does-not-refresh)
launch limit=20 -- $THREE quit
launch limit=20 -- wait:8 quit
launch limit=20 -- wait:8 quit
launch limit=20 -- state quit
judge "reopened twice, nobody touching it: restored both times" \
"nth START 2 | grep -q 'tabs=3' && nth START 3 | grep -q 'tabs=3'"
judge "but reopening never renewed it: fresh once the limit had passed" \
"nth STATE 1 | grep -q \"$FRESH\"" ;;
activity-refreshes)
launch -- $THREE quit
age 600
launch -- wait:4 state touch quit
# The time of last use is written at most every 30 s, and at quit.
restored=$(age_of "$(nth STATE 1)"); touched=$(age_of "$(nth QUIT 2)")
judge "restoring it did not count as using it (age $restored s, from ten minutes)" "(( restored >= 600 ))"
judge "a click on the restored tabs does (age $touched s at quit)" "(( touched <= 5 ))" ;;
background-ignored)
launch -- "touch open:$SITE/app?busy=1 touch" quit
age 600
launch -- wait:20 state quit
judge "a page busy by itself for 20 s (a request a second, an animation) is not activity (age $(age_of "$(nth STATE 1)") s)" \
"(( \$(age_of \"\$(nth STATE 1)\") >= 615 ))"
judge "and it really was busy" "[[ \$(lab 'grep -c beacon $DIR/site.log') -gt 10 ]]" ;;
preview-ignored)
launch -- $THREE quit
age 600
launch -- preview:15 state quit
judge "a print preview left open 15 s untouched is not activity (age $(age_of "$(nth STATE 1)") s)" \
"(( \$(age_of \"\$(nth STATE 1)\") >= 610 ))" ;;
printing-counts)
launch -- $THREE quit
age 600
before_jobs=$(lab 'lpstat -W all -o 2>/dev/null | wc -l')
launch -- front:2 print state quit
judge "pressing Print is using Wonder Ink (age $(age_of "$(nth STATE 1)") s)" "(( \$(age_of \"\$(nth STATE 1)\") <= 5 ))"
judge "and the restored picture tab printed" "(( \$(lab 'lpstat -W all -o | wc -l') > before_jobs ))" ;;
closed-stays-closed)
launch -- $THREE close:1 touch quit
launch -- state quit
judge "a tab closed before closing Wonder Ink stays closed" \
"nth STATE 1 | grep -q \"tabs=\\['/app', '$OTHER'\\]\"" ;;
reordered)
launch -- $THREE move:0:2 touch quit
launch -- state quit
judge "tabs dragged into another order come back in that order" \
"nth STATE 1 | grep -q \"tabs=\\['$SLIDE', '$OTHER', '/app'\\]\"" ;;
worksheet)
launch -- touch worksheet:Colour-the-Ark open:$SLIDE front:1 touch quit
launch -- state quit
judge "a worksheet comes back as a worksheet, where it was, in front" \
"nth STATE 1 | grep -q \"tabs=\\['/app', 'worksheet:Colour-the-Ark.pdf', '$SLIDE'\\] active=1\"" ;;
worksheet-gone)
launch -- touch worksheet:Colour-the-Ark open:$SLIDE touch quit
lab "rm -f \"$SHEETS/Colour-the-Ark.pdf\""
launch -- state quit
judge "a worksheet whose file has gone is left out, and nothing else is" \
"nth STATE 1 | grep -q \"tabs=\\['/app', '$SLIDE'\\]\"" ;;
crash-then-restore)
launch -- $THREE wait:2 crash
launch -- state quit
judge "killed outright: the tabs come back all the same" "nth STATE 1 | grep -q \"$RESTORED\"" ;;
restore-crashes)
launch -- $THREE quit
launch -- crash
launch -- crash
launch -- state quit
judge "a restore that crashes is tried once more" "nth START 2 | grep -q 'tabs=3' && nth START 3 | grep -q 'tabs=3 attempts=2'"
judge "and then abandoned: fresh, without asking anybody" \
"said 'starting fresh (it failed to restore 2 times)' && nth STATE 1 | grep -q \"tabs=\\['/app'\\]\"" ;;
corrupt)
lab "su teacher -c 'mkdir -p $(dirname $KEPT); echo \"{not json\" > $KEPT'"
launch -- state quit
judge "an unreadable file: fresh, and Wonder Ink opens" "said 'starting fresh (unreadable)' && nth STATE 1 | grep -q \"$FRESH\"" ;;
future-format)
launch -- $THREE quit
lab "su teacher -c \"python3 -c 'import json,sys; p=sys.argv[1]; d=json.load(open(p)); d[\\\"format\\\"]=99; json.dump(d, open(p, \\\"w\\\"))' $KEPT\""
launch -- state quit
judge "a file from a later version: fresh" "said 'starting fresh (from another version)' && nth STATE 1 | grep -q \"$FRESH\"" ;;
half-written)
launch -- $THREE quit
lab "su teacher -c 'echo \"{\\\"format\\\": 1, \\\"last_act\" > $KEPT.new'"
launch -- state quit
judge "a write cut off half way leaves the last whole one, which is restored" "nth STATE 1 | grep -q \"$RESTORED\"" ;;
privacy)
launch -- signin touch state open:$SLIDE open:$OTHER touch quit
judge "signed in on the stand-in, whose sign-in is Basic auth as WonderInk's is" \
"nth STATE 1 | grep -q \"pages=\\['Signed in as teacher'\\]\""
judge "the kept file has no password, Basic-auth credential, cookie or header in it" \
"kept_file && ! lab \"grep -qiE 'hunter2|Basic|dGVhY2hlc|teacher@|rrf|cookie|x-rrf|authoriz|token' $KEPT\""
judge "only addresses, titles and times: the fields it is meant to have" \
"lab \"python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if set(d)=={\\\"format\\\",\\\"last_activity\\\",\\\"active\\\",\\\"attempts\\\",\\\"tabs\\\"} and all(set(t)=={\\\"kind\\\",\\\"where\\\",\\\"title\\\"} for t in d[\\\"tabs\\\"]) else 1)' $KEPT\""
launch -- front:0 state quit
judge "and the restored start page is signed out: the site asks again" \
"said 'restored 3 recent tabs' && nth STATE 2 | grep -q \"pages=\\['Signed out'\\]\"" ;;
restored-tabs-print)
launch -- touch worksheet:Colour-the-Ark open:$SLIDE touch quit
before_jobs=$(lab 'lpstat -W all -o 2>/dev/null | wc -l')
launch -- front:1 print front:2 print state quit
judge "a restored worksheet tab and a restored picture tab both print" \
"(( \$(lab 'lpstat -W all -o | wc -l') >= before_jobs + 2 ))" ;;
esac
judge "Wonder Ink never crashed by itself" no_crash
[[ "$failed" -eq "$before" ]] || \
{ echo "$out" | grep -E 'STATE|START|QUIT|CRASH|PRINTED|exit=|Traceback|starting|restored|TEST' | tail -14 | sed 's/^/ /'; }
done
echo
echo "$passed passed, $failed failed"
[[ "$failed" -eq 0 ]]
+201
View File
@@ -0,0 +1,201 @@
"""One launch of Wonder Ink, its recent tabs restored or not, and a few steps.
usage: wonderink-workspace-rig.py WONDERINK_COMMAND SITE STEP...
Run by tools/lab/wonderink-workspace-check inside the lab, against the
command the package installed, started as main() starts it: the real
profile, downloads connected, then open_workspace() — which restores the
recent tabs or opens the start page. The start page is the lab's
stand-in for WonderInk's sign-in (tools/lab/wonderink-lesson-site.py,
/app) rather than the real site.
Steps, in order:
open:PATH open SITE+PATH (or a full http address) in a new tab, in front
worksheet:NAME a worksheet PDF downloaded and opened in a tab
signin click the stand-in's Sign In (a real click)
touch one real click on the page in front
front:N bring tab N to the front, as clicking it does (a real click)
close:N close tab N, as its × does
move:A:B drag tab A to where B is
preview:N the print preview open N seconds, nobody touching it
print Print, and the preview's Print button pressed by a real click
wait:N N seconds, nobody touching anything
state what the tabs are, and what the kept file says
quit close the window
crash SIGKILL, no chance to write anything
"""
import importlib.machinery
import importlib.util
import json
import os
import signal
import sys
import time
from pathlib import Path
loader = importlib.machinery.SourceFileLoader("wonderink", sys.argv[1])
spec = importlib.util.spec_from_loader("wonderink", loader)
wi = importlib.util.module_from_spec(spec)
loader.exec_module(wi)
from PyQt6.QtCore import QEvent, QEventLoop, QPointF, Qt, QTimer # noqa: E402
from PyQt6.QtGui import QMouseEvent, QPageSize, QPainter, QPdfWriter # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
site, steps = sys.argv[2].rstrip("/"), sys.argv[3:]
wi.HOME_PAGE = f"{site}/app"
wi.notify = lambda summary, body: print("NOTIFY", summary, "|", body, flush=True)
app = QApplication(sys.argv[:1])
t0 = time.monotonic()
def stamp(message):
print(f"[{time.monotonic() - t0:6.2f}s] {message}", flush=True)
profile = wi.build_profile(app, "workspace-rig")
wi.DOWNLOADS.mkdir(parents=True, exist_ok=True)
window = wi.WonderInk(profile)
profile.downloadRequested.connect(window.on_download)
keeper = wi.open_workspace(window)
window.resize(1100, 700)
window.show()
stamp(f"START tabs={window._tabs.count()} attempts={keeper._attempts}")
def spin(seconds, until=None):
end = time.monotonic() + seconds
while time.monotonic() < end:
app.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
if until is not None and until():
return True
time.sleep(0.01)
return False
def js(code, view=None):
box = []
(view or window.current()).page().runJavaScript(code, lambda result: box.append(result))
spin(5, lambda: bool(box))
return box[0] if box else None
def press(widget, point, button=Qt.MouseButton.LeftButton):
for kind, buttons in ((QEvent.Type.MouseButtonPress, button),
(QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
QApplication.sendEvent(widget, QMouseEvent(kind, point, QPointF(widget.mapToGlobal(point)),
button, buttons, Qt.KeyboardModifier.NoModifier))
def click_page(selector="body"):
view = window.current()
where = js("(function(){var e=document.querySelector(%s); if(!e) return [20, 20]; var r=e.getBoundingClientRect();"
"return [r.x+Math.min(r.width/2, 40), r.y+Math.min(r.height/2, 10)]})()" % json.dumps(selector)) or [20, 20]
zoom = view.zoomFactor()
press(view.focusProxy(), QPointF(where[0] * zoom, where[1] * zoom))
def where_of(view):
# A restored tab not yet shown is the tab it will load when it is.
if view in window._pending:
kept = keeper._pending_tabs[view]
if kept["kind"] == "worksheet":
return "worksheet:" + Path(kept["where"]).name
return kept["where"].replace(site, "")
if view in window._pdf_views:
return "worksheet:" + window._pdf_views[view].name
return view.url().toString().replace(site, "")
def state(label="STATE"):
tabs = [where_of(window._tabs.widget(i)) for i in range(window._tabs.count())]
# What the stand-in's start page says in each tab that shows it.
pages = [js("document.getElementById('state') ? document.getElementById('state').textContent : ''",
window._tabs.widget(i)) for i in range(window._tabs.count())]
kept = "none"
if wi.WORKSPACE_FILE.exists():
data = json.loads(wi.WORKSPACE_FILE.read_text())
kept = (f"tabs={len(data['tabs'])} active={data['active']} attempts={data['attempts']} "
f"age={time.time() - data['last_activity']:.0f}")
stamp(f"{label} tabs={tabs} active={window._tabs.currentIndex()} kept[{kept}] pages={[p for p in pages if p]} "
f"unshown={len(window._pending)}")
def make_worksheet(name):
path = wi.DOWNLOADS / f"{name}.pdf"
writer = QPdfWriter(str(path))
writer.setPageSize(QPageSize(QPageSize.PageSizeId.Letter))
painter = QPainter(writer)
painter.drawText(200, 400, f"WORKSHEET {name}")
painter.end()
return path
def run():
spin(3)
for step in steps:
name, _, rest = step.partition(":")
stamp(f"STEP {step}")
if name == "open":
window.open_tab(rest if rest.startswith("http") else site + rest)
spin(2.5)
elif name == "worksheet":
window.open_pdf(make_worksheet(rest))
spin(2.5)
elif name == "signin":
click_page("#signin")
spin(1.5)
elif name == "touch":
click_page()
spin(1.5)
elif name == "front":
bar = window._tabs.tabBar()
press(bar, QPointF(bar.tabRect(int(rest)).center()))
spin(1.5)
elif name == "close":
window.close_tab(int(rest))
spin(1.5)
elif name == "move":
a, b = (int(n) for n in rest.split(":"))
window._tabs.tabBar().moveTab(a, b)
spin(1.5)
elif name == "preview":
QTimer.singleShot(int(float(rest) * 1000), lambda: window._preview.reject() if window._preview else None)
window.print_view(window.current())
spin(1.5)
elif name == "print":
def pressed():
preview = window._preview
if preview is None or preview._pdf is None:
return QTimer.singleShot(200, pressed)
button = preview._print
press(button, QPointF(button.rect().center()))
QTimer.singleShot(500, pressed)
window.print_view(window.current())
spin(2)
stamp(f"PRINTED preview_open={window._preview is not None}")
elif name == "wait":
spin(float(rest))
elif name == "state":
state()
elif name == "quit":
window.close()
spin(1)
state("QUIT")
app.quit()
return
elif name == "crash":
stamp("CRASH")
os.kill(os.getpid(), signal.SIGKILL)
app.quit()
QTimer.singleShot(0, run)
QTimer.singleShot(240_000, lambda: (stamp("TIMEOUT"), app.exit(3)))
sys.exit(app.exec())