Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23194a085c | ||
|
|
fd4330a649 | ||
|
|
78a844968c | ||
|
|
b5d9535467 | ||
|
|
5fef4e02a9 | ||
|
|
f9a0ee08f9 | ||
|
|
cb12e59dea | ||
|
|
594fbd810e | ||
|
|
550c0bf126 | ||
|
|
2325a5993b | ||
|
|
2d1cc016c5 | ||
|
|
59cb51c68b | ||
|
|
0bbe2a1ee0 | ||
|
|
cd798a33a1 | ||
|
|
3855989029 | ||
|
|
940f63e0e5 |
@@ -26,6 +26,12 @@ editable_desktop_icons=false
|
|||||||
editable_bookmarks=false
|
editable_bookmarks=false
|
||||||
movable_toolbars=false
|
movable_toolbars=false
|
||||||
plasma/plasmashell/unlockedDesktop=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
|
plasma-desktop/scripting_console=false
|
||||||
# Nothing in the classroom should offer to file a bug or open a manual.
|
# Nothing in the classroom should offer to file a bug or open a manual.
|
||||||
action/help_report_bug=false
|
action/help_report_bug=false
|
||||||
@@ -47,6 +53,115 @@ action/help_contents=false
|
|||||||
# is not shown doors that will not open.
|
# is not shown doors that will not open.
|
||||||
[KDE Control Module Restrictions][$i]
|
[KDE Control Module Restrictions][$i]
|
||||||
kcm_networkmanagement=false
|
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
|
# Not immutable. Marking this group [$i] froze the colour scheme so hard
|
||||||
# that `classroom-theme light` changed the running session and then lost
|
# that `classroom-theme light` changed the running session and then lost
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -116,3 +116,45 @@ and nothing needs reinstalling.
|
|||||||
To put the session screen back, remove the D-Bus shadow and the
|
To put the session screen back, remove the D-Bus shadow and the
|
||||||
`kglobalshortcutsrc` entries; both are package files, so an update will
|
`kglobalshortcutsrc` entries; both are package files, so an update will
|
||||||
restore them, which is the intended direction.
|
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.
|
||||||
|
|||||||
@@ -32,11 +32,28 @@ The size applies to every tab and is remembered next time.
|
|||||||
## Printing
|
## Printing
|
||||||
|
|
||||||
Press **Ctrl** and **P**, click **Print** at the top right, or right-click
|
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`.
|
* Choose **Only some pages** to print, for example, `2` or `1-3`.
|
||||||
* Set **Copies** for the number of children.
|
* 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
|
Click **Print**. Wonder Ink says when the printer has it and goes back to
|
||||||
the lesson. **Cancel** prints nothing.
|
the lesson. **Cancel** prints nothing.
|
||||||
@@ -44,14 +61,28 @@ the lesson. **Cancel** prints nothing.
|
|||||||
## Worksheets and colouring pages
|
## Worksheets and colouring pages
|
||||||
|
|
||||||
Click the worksheet link in the lesson. It opens in a new tab, ready to
|
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
|
read or print. Any other link in a lesson — a slide, a picture, a video —
|
||||||
make it bigger or smaller.
|
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
|
Worksheets you download are kept separately from everything else on the
|
||||||
computer, so they never get mixed up with anything.
|
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
|
## When you have finished
|
||||||
|
|
||||||
**Close Wonder Ink at the end of your session.** Closing it signs you
|
**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
|
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.
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
|
|||||||
<body>
|
<body>
|
||||||
<div class="sheet">
|
<div class="sheet">
|
||||||
<h1>Classroom manual</h1>
|
<h1>Classroom manual</h1>
|
||||||
<p class="revision">River of Glory · Revision 23 September 2026</p>
|
<p class="revision">River of Glory · Revision 26 September 2026</p>
|
||||||
<nav id='contents'></nav>
|
<nav id='contents'></nav>
|
||||||
<section id="00-welcome">
|
<section id="00-welcome">
|
||||||
<h2>Your classroom computer</h2>
|
<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>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>
|
<p>The size applies to every tab and is remembered next time.</p>
|
||||||
<h3>Printing</h3>
|
<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>
|
<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 colour ink.</li></ul>
|
<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>
|
<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>
|
<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'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'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>
|
<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'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>
|
<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>
|
<p><a class="top" href="#contents">Back to the top</a></p>
|
||||||
</section>
|
</section>
|
||||||
<section id="60-parent-call" data-feature="PARENT_CALL_ENABLED">
|
<section id="60-parent-call" data-feature="PARENT_CALL_ENABLED">
|
||||||
|
|||||||
@@ -3,47 +3,50 @@
|
|||||||
<< /Type /Catalog /Pages 2 0 R >>
|
<< /Type /Catalog /Pages 2 0 R >>
|
||||||
endobj
|
endobj
|
||||||
2 0 obj
|
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
|
endobj
|
||||||
3 0 obj
|
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
|
endobj
|
||||||
4 0 obj
|
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
|
endobj
|
||||||
5 0 obj
|
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
|
endobj
|
||||||
6 0 obj
|
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
|
endobj
|
||||||
7 0 obj
|
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
|
endobj
|
||||||
8 0 obj
|
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
|
endobj
|
||||||
9 0 obj
|
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
|
endobj
|
||||||
10 0 obj
|
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
|
endobj
|
||||||
11 0 obj
|
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
|
endobj
|
||||||
12 0 obj
|
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
|
endobj
|
||||||
13 0 obj
|
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
|
endobj
|
||||||
14 0 obj
|
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 >>
|
<< /Length 765 >>
|
||||||
stream
|
stream
|
||||||
BT /F2 26 Tf 1 0 0 1 62.0 720.0 Tm (Classroom manual) Tj ET
|
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 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 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 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
|
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
|
BT /F1 10.5 Tf 1 0 0 1 62.0 554.1 Tm (the office for a new one.) Tj ET
|
||||||
endstream
|
endstream
|
||||||
endobj
|
endobj
|
||||||
15 0 obj
|
16 0 obj
|
||||||
<< /Length 1250 >>
|
<< /Length 1250 >>
|
||||||
stream
|
stream
|
||||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Your classroom computer) Tj ET
|
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
|
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
|
endstream
|
||||||
endobj
|
endobj
|
||||||
16 0 obj
|
17 0 obj
|
||||||
<< /Length 1408 >>
|
<< /Length 1408 >>
|
||||||
stream
|
stream
|
||||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (The Browser) Tj ET
|
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
|
BT /F1 10.5 Tf 1 0 0 1 62.0 503.3 Tm (The computer is shared.) Tj ET
|
||||||
endstream
|
endstream
|
||||||
endobj
|
endobj
|
||||||
17 0 obj
|
18 0 obj
|
||||||
<< /Length 1694 >>
|
<< /Length 1694 >>
|
||||||
stream
|
stream
|
||||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Sound and volume) Tj ET
|
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
|
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
|
endstream
|
||||||
endobj
|
endobj
|
||||||
18 0 obj
|
19 0 obj
|
||||||
<< /Length 4084 >>
|
<< /Length 4084 >>
|
||||||
stream
|
stream
|
||||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Presentations) Tj ET
|
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
|
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
|
endstream
|
||||||
endobj
|
endobj
|
||||||
19 0 obj
|
20 0 obj
|
||||||
<< /Length 3089 >>
|
<< /Length 3089 >>
|
||||||
stream
|
stream
|
||||||
BT /F1 10.5 Tf 1 0 0 1 62.0 780.0 Tm (computer is switched off and on again.) Tj ET
|
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
|
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
|
endstream
|
||||||
endobj
|
endobj
|
||||||
20 0 obj
|
21 0 obj
|
||||||
<< /Length 3431 >>
|
<< /Length 4396 >>
|
||||||
stream
|
stream
|
||||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Wonder Ink) Tj ET
|
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
|
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 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 /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 /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 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 (the pages exactly as they will print.) 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 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 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 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 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 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 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 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 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 (nothing.) 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 /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 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 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 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 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 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 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 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 234.8 Tm (get mixed up with anything.) 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 /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 238.8 Tm (prints the same way.) 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 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 171.3 Tm (this room starts with their own account and not yours.) 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
|
endstream
|
||||||
endobj
|
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 >>
|
<< /Length 1475 >>
|
||||||
stream
|
stream
|
||||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Parent Call) Tj ET
|
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
|
BT /F1 10.5 Tf 1 0 0 1 62.0 496.3 Tm (If that happens, send somebody.) Tj ET
|
||||||
endstream
|
endstream
|
||||||
endobj
|
endobj
|
||||||
22 0 obj
|
24 0 obj
|
||||||
<< /Length 1500 >>
|
<< /Length 1500 >>
|
||||||
stream
|
stream
|
||||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Playing a DVD) Tj ET
|
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
|
BT /F1 10.5 Tf 1 0 0 1 62.0 467.5 Tm (Take the disc with you.) Tj ET
|
||||||
endstream
|
endstream
|
||||||
endobj
|
endobj
|
||||||
23 0 obj
|
25 0 obj
|
||||||
<< /Length 1166 >>
|
<< /Length 1166 >>
|
||||||
stream
|
stream
|
||||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Printing) Tj ET
|
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
|
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
|
endstream
|
||||||
endobj
|
endobj
|
||||||
24 0 obj
|
26 0 obj
|
||||||
<< /Length 1766 >>
|
<< /Length 1766 >>
|
||||||
stream
|
stream
|
||||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (If something goes wrong) Tj ET
|
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
|
BT /F1 10.5 Tf 1 0 0 1 62.0 396.1 Tm (the one that saves everybody time.) Tj ET
|
||||||
endstream
|
endstream
|
||||||
endobj
|
endobj
|
||||||
25 0 obj
|
27 0 obj
|
||||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
|
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
|
||||||
endobj
|
endobj
|
||||||
26 0 obj
|
28 0 obj
|
||||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
|
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
|
||||||
endobj
|
endobj
|
||||||
xref
|
xref
|
||||||
0 27
|
0 29
|
||||||
0000000000 65535 f
|
0000000000 65535 f
|
||||||
0000000009 00000 n
|
0000000009 00000 n
|
||||||
0000000058 00000 n
|
0000000058 00000 n
|
||||||
0000000180 00000 n
|
0000000187 00000 n
|
||||||
0000000319 00000 n
|
0000000326 00000 n
|
||||||
0000000458 00000 n
|
0000000465 00000 n
|
||||||
0000000597 00000 n
|
0000000604 00000 n
|
||||||
0000000736 00000 n
|
0000000743 00000 n
|
||||||
0000000875 00000 n
|
0000000882 00000 n
|
||||||
0000001014 00000 n
|
0000001021 00000 n
|
||||||
0000001153 00000 n
|
0000001160 00000 n
|
||||||
0000001293 00000 n
|
0000001300 00000 n
|
||||||
0000001433 00000 n
|
0000001440 00000 n
|
||||||
0000001573 00000 n
|
0000001580 00000 n
|
||||||
0000001713 00000 n
|
0000001720 00000 n
|
||||||
0000002530 00000 n
|
0000001860 00000 n
|
||||||
0000003833 00000 n
|
0000002677 00000 n
|
||||||
0000005294 00000 n
|
0000003980 00000 n
|
||||||
0000007041 00000 n
|
0000005441 00000 n
|
||||||
0000011178 00000 n
|
0000007188 00000 n
|
||||||
0000014320 00000 n
|
0000011325 00000 n
|
||||||
0000017804 00000 n
|
0000014467 00000 n
|
||||||
0000019332 00000 n
|
0000018916 00000 n
|
||||||
0000020885 00000 n
|
0000020415 00000 n
|
||||||
0000022104 00000 n
|
0000021943 00000 n
|
||||||
0000023923 00000 n
|
0000023496 00000 n
|
||||||
0000024021 00000 n
|
0000024715 00000 n
|
||||||
|
0000026534 00000 n
|
||||||
|
0000026632 00000 n
|
||||||
trailer
|
trailer
|
||||||
<< /Size 27 /Root 1 0 R >>
|
<< /Size 29 /Root 1 0 R >>
|
||||||
startxref
|
startxref
|
||||||
24124
|
26735
|
||||||
%%EOF
|
%%EOF
|
||||||
@@ -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
|
**Remove**. Its managed original remains recoverable even when PDF or page
|
||||||
generation fails.
|
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
|
## The three things kept apart
|
||||||
|
|
||||||
This is the part most worth not breaking.
|
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
|
`--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
|
timers and the expiry date. It is the fastest way to answer "why has this
|
||||||
gone" and "why has this not appeared".
|
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).
|
||||||
|
|||||||
@@ -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 |
|
| `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 |
|
| `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
|
## Failures, kept
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 281 KiB |
@@ -575,14 +575,14 @@ ifupdown stanza is already gone. **Do not run `--adopt` on it.**
|
|||||||
still reports `yes`
|
still reports `yes`
|
||||||
- [ ] The laptop still reconnects after a reboot
|
- [ ] 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,
|
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.
|
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.
|
Those are these checks. Stable stays on 0.2.2 until they pass.
|
||||||
|
|
||||||
Before starting, on each machine: `dpkg-query -W ekklesia-classroom`
|
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:
|
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
|
- [ ] 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
|
- [ ] Click the worksheet; it opens as a tab named after the file, with a
|
||||||
document icon
|
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
|
own pages, page count right, ‹ › turn them
|
||||||
- [ ] Printer shows **Elmer**, preselected
|
- [ ] Printer shows **Elmer**, preselected
|
||||||
- [ ] Print → a notification "Sent to the printer — … — Elmer", Wonder Ink
|
- [ ] Print → a notification "Sent to the printer — … — Elmer", Wonder Ink
|
||||||
stays open on the lesson
|
stays open on the lesson
|
||||||
- [ ] **The worksheet comes out of Elmer**, every page, right size, in colour
|
- [ ] **The worksheet comes out of Elmer**, every page, right size, in colour
|
||||||
- [ ] Pages `2`, Copies `2`, Black and white → exactly two grey copies of
|
- [ ] Pages `2`, Copies `2`, Black and white → the preview turns grey,
|
||||||
page 2 come out
|
and exactly two grey copies of page 2 come out
|
||||||
- [ ] Print a normal lesson page the same way; it comes out, Letter
|
- [ ] Print a normal lesson page the same way; it comes out, Letter
|
||||||
- [ ] Open the preview and press Cancel → nothing prints
|
- [ ] Open the preview and press Cancel → nothing prints
|
||||||
- [ ] Print three times in a row → three printouts, no crash
|
- [ ] Print three times in a row → three printouts, no crash
|
||||||
- [ ] `lpstat -W completed -o` shows each job; `journalctl --user -b | grep
|
- [ ] `lpstat -W completed -o` shows each job; `journalctl --user -b | grep
|
||||||
"printed '"` shows Wonder Ink's line for each
|
"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
|
### Size and zoom
|
||||||
|
|
||||||
- [ ] A lesson in Wonder Ink is the same size as the same page in the
|
- [ ] 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
|
with a driver named above points at the network
|
||||||
- [ ] `free -m`: Wonder Ink needs about 610 MB plus 115 MB per extra tab
|
- [ ] `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
|
Use a **Viewer** account. An Admin sees lessons in WonderInk's editor,
|
||||||
are session-only and measured so; the site's Local Storage is not
|
where a click on a link shows its Edit/Unlink bubble — WonderInk's design,
|
||||||
cleared, which is the one way this could fail and has never been
|
not Wonder Ink's; Ctrl+click opens it. See wonder-ink.md, *Links in a
|
||||||
tested with a real account)
|
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
|
### After a reboot, and the other rooms
|
||||||
|
|
||||||
@@ -660,4 +761,4 @@ classroom-wonderink --diagnose
|
|||||||
says `false`)
|
says `false`)
|
||||||
|
|
||||||
If anything here fails, leave the desktops on stable 0.2.2 and fix it in
|
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.
|
||||||
|
|||||||
@@ -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
|
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
|
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,
|
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,
|
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.
|
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
|
* **Pages** — all, or some: `2`, `1-3`, `1, 3, 5-6`, checked against the
|
||||||
document with the reason given if wrong;
|
document with the reason given if wrong;
|
||||||
* **Copies** — 1 to 50;
|
* **Copies** — 1 to 50;
|
||||||
* **Colour** — colour or black and white, only where the printer's driver
|
* **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;
|
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
|
* **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
|
the job, and a notification says where it went. A refused job keeps the
|
||||||
preview open with CUPS's reason.
|
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
|
does a page calling `window.print()` meanwhile. The PDF viewer's own print
|
||||||
button opens the same preview.
|
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
|
Temporary PDFs live in the teacher's `$XDG_RUNTIME_DIR` and are removed
|
||||||
when the preview closes.
|
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
|
`tools/lab/wonderink-print-check` runs all of this against the installed
|
||||||
command in the lab, and judges every scenario by what CUPS actually
|
command in the lab, and judges every scenario by what CUPS actually
|
||||||
received. `tests/test-classroom-printing.sh` covers the CUPS side at every
|
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
|
sign-in and the room's shared YouTube account are different sessions in
|
||||||
different storage that cannot reach each other. The browser is still
|
different storage that cannot reach each other. The browser is still
|
||||||
Chrome and nothing in this change touches it.
|
Chrome and nothing in this change touches it.
|
||||||
* **Cookies live only as long as the window.** This is what the Chrome
|
* **Cookies live only as long as the window**, as Chrome's
|
||||||
policy's `CookiesSessionOnlyForUrls` did for wonderink.org, and it is
|
`CookiesSessionOnlyForUrls` did for wonderink.org, so closing Wonder Ink
|
||||||
the reason closing Wonder Ink ends the teacher's session rather than
|
signs the teacher out and the next person is not signed in as the last
|
||||||
leaving the next person signed in as the last one. The whole profile
|
one. WonderInk's sign-in is the teacher's password in a cookie
|
||||||
rather than one domain now, because this profile only ever goes where a
|
(measured, 0.2.6.4), which is one more reason it never reaches the
|
||||||
lesson sends it. If a room ever wants sign-ins to survive, it is the one
|
disk. What does survive a close, for two hours, is the list of tabs —
|
||||||
line `setPersistentCookiesPolicy` in `classroom-wonderink`.
|
see *Temporary workspace restore*, below.
|
||||||
* **The old Chrome profile is left where it is**, at
|
* **The old Chrome profile is left where it is**, at
|
||||||
`~/.local/share/ekklesia-classroom/wonderink`. The new one is
|
`~/.local/share/ekklesia-classroom/wonderink`. The new one is
|
||||||
`wonderink-shell` beside it, so rolling back to 0.2.0 finds the old one
|
`wonderink-shell` beside it, so rolling back to 0.2.0 finds the old one
|
||||||
intact.
|
intact.
|
||||||
* **One scale for the room** — see *How large a lesson is*, below.
|
* **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
|
## How large a lesson is
|
||||||
|
|
||||||
Teachers found lessons in Wonder Ink too small next to the same site in
|
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
|
`/etc/opt/chrome/policies/managed/` sets first-run, sign-in, password
|
||||||
and homepage behaviour only. None of it touches media, autoplay,
|
and homepage behaviour only. None of it touches media, autoplay,
|
||||||
iframes, fullscreen or site permissions.
|
iframes, fullscreen or site permissions.
|
||||||
|
|
||||||
|
## Later
|
||||||
|
|
||||||
|
What testing of 0.2.6.1 asked for next is in [backlog.md](backlog.md).
|
||||||
|
|||||||
@@ -288,6 +288,44 @@ class Presentation:
|
|||||||
def failed(self):
|
def failed(self):
|
||||||
return self.state == "failed"
|
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
|
@property
|
||||||
def error(self):
|
def error(self):
|
||||||
return self.data.get("error") or ""
|
return self.data.get("error") or ""
|
||||||
@@ -458,6 +496,24 @@ def library(root=None):
|
|||||||
return found
|
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):
|
def remove(presentation):
|
||||||
"""Delete exactly one managed entry, unless it is on screen now."""
|
"""Delete exactly one managed entry, unless it is on screen now."""
|
||||||
try:
|
try:
|
||||||
@@ -642,6 +698,21 @@ PDFTOTEXT = "pdftotext"
|
|||||||
CONVERT_TIMEOUT = 300
|
CONVERT_TIMEOUT = 300
|
||||||
RENDER_TIMEOUT = 600
|
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):
|
def _run(command, timeout, **kwargs):
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -704,6 +775,9 @@ def convert_to_pdf(source, destination):
|
|||||||
shutil.rmtree(profile, ignore_errors=True)
|
shutil.rmtree(profile, ignore_errors=True)
|
||||||
shutil.rmtree(workspace, ignore_errors=True)
|
shutil.rmtree(workspace, ignore_errors=True)
|
||||||
|
|
||||||
|
if killed(result.returncode):
|
||||||
|
return False, KILLED
|
||||||
|
|
||||||
return False, (result.stderr or result.stdout or
|
return False, (result.stderr or result.stdout or
|
||||||
"the file could not be opened").strip()
|
"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)
|
time.sleep(0.4)
|
||||||
|
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
|
if killed(process.returncode):
|
||||||
|
return 0, KILLED
|
||||||
|
|
||||||
return 0, (process.stderr.read() if process.stderr else
|
return 0, (process.stderr.read() if process.stderr else
|
||||||
"the slides could not be rendered")
|
"the slides could not be rendered")
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,42 @@ def parse_pages(text, count):
|
|||||||
return ",".join(parts)
|
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"):
|
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).
|
"""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.
|
# without the program's name in front of it.
|
||||||
message = re.sub(r"^lp:\s*", "", message) or "The printer did not accept the job."
|
message = re.sub(r"^lp:\s*", "", message) or "The printer did not accept the job."
|
||||||
return False, message
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,9 +4,9 @@ Section: misc
|
|||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: amd64
|
Architecture: amd64
|
||||||
Maintainer: Ekklesia
|
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
|
Recommends: vlc
|
||||||
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup
|
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup, khelpcenter
|
||||||
Description: Ekklesia managed classroom environment
|
Description: Ekklesia managed classroom environment
|
||||||
A classroom appliance built on Debian's own Plasma desktop. The room's
|
A classroom appliance built on Debian's own Plasma desktop. The room's
|
||||||
panel, window behaviour, notifications, palette and lockdown are
|
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.
|
import costs 0.34s for a ten-slide deck and nothing at all afterwards.
|
||||||
libreoffice-gtk3 is deliberately NOT here: nothing interactive runs.
|
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
|
The fonts are what a Google Slides export asks for. The metric
|
||||||
compatible families cover the Microsoft names — Liberation for Arial,
|
compatible families cover the Microsoft names — Liberation for Arial,
|
||||||
Times New Roman and Courier New, Carlito for Calibri, Caladea for
|
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
|
The Conflicts line is deliberate. A welcome tour, a software centre and
|
||||||
a phone pairing daemon are all things plasma-desktop recommends and a
|
a phone pairing daemon are all things plasma-desktop recommends and a
|
||||||
room a teacher walks into should not have.
|
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.
|
||||||
|
|||||||
@@ -158,11 +158,29 @@ def forget_presentation(presentation_id, root):
|
|||||||
library.write_json(library.ledger_path(root), data)
|
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):
|
def prepare(presentation):
|
||||||
"""Build the derived PDF, pages and cover from a managed original."""
|
"""Build the derived PDF, pages and cover from a managed original."""
|
||||||
data = presentation.data
|
data = presentation.data
|
||||||
managed = presentation.managed_copy
|
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():
|
if managed is None or not managed.is_file():
|
||||||
raise RuntimeError("the managed original is missing")
|
raise RuntimeError("the managed original is missing")
|
||||||
|
|
||||||
@@ -189,12 +207,18 @@ def prepare(presentation):
|
|||||||
managed, presentation.slides)
|
managed, presentation.slides)
|
||||||
|
|
||||||
if not converted:
|
if not converted:
|
||||||
|
if error == library.KILLED:
|
||||||
|
raise NeedsMemory(error)
|
||||||
|
|
||||||
raise RuntimeError(error or "the file could not be converted")
|
raise RuntimeError(error or "the file could not be converted")
|
||||||
|
|
||||||
pages, error = library.render_pages(
|
pages, error = library.render_pages(
|
||||||
presentation.slides, presentation.pages_directory)
|
presentation.slides, presentation.pages_directory)
|
||||||
|
|
||||||
if not pages:
|
if not pages:
|
||||||
|
if error == library.KILLED:
|
||||||
|
raise NeedsMemory(error)
|
||||||
|
|
||||||
raise RuntimeError(error or "the slides could not be rendered")
|
raise RuntimeError(error or "the slides could not be rendered")
|
||||||
|
|
||||||
data["pages"] = pages
|
data["pages"] = pages
|
||||||
@@ -211,6 +235,7 @@ def prepare(presentation):
|
|||||||
|
|
||||||
data["state"] = "ready"
|
data["state"] = "ready"
|
||||||
data["error"] = None
|
data["error"] = None
|
||||||
|
data["error_kind"] = None
|
||||||
presentation.save()
|
presentation.save()
|
||||||
|
|
||||||
say(f"imported {data['title']!r} ({pages} slides)")
|
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;
|
# teacher can remove it, and a technician can recover or inspect it;
|
||||||
# no successfully downloaded file is lost because a derived asset
|
# no successfully downloaded file is lost because a derived asset
|
||||||
# could not be made.
|
# could not be made.
|
||||||
data["state"] = "failed"
|
fail(presentation, error)
|
||||||
data["error"] = str(error)[:300]
|
|
||||||
presentation.save()
|
|
||||||
|
|
||||||
say(f"could not import {source.name}: {error}")
|
say(f"could not import {source.name}: {error}")
|
||||||
|
|
||||||
return presentation
|
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):
|
def sweep(root=None):
|
||||||
"""Recover imports interrupted during a move, conversion or rendering.
|
"""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()
|
if (managed is not None and managed.is_file()
|
||||||
and (source is None or not source.exists())):
|
and (source is None or not source.exists())):
|
||||||
|
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}")
|
say(f"resuming an interrupted import: {presentation.title!r}")
|
||||||
try:
|
attempt(presentation)
|
||||||
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}")
|
|
||||||
stale += 1
|
stale += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -414,6 +482,23 @@ def status(root=None):
|
|||||||
return 0
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Bring finished downloads into the Presentations library.")
|
description="Bring finished downloads into the Presentations library.")
|
||||||
@@ -439,7 +524,17 @@ def main():
|
|||||||
say(f"removed {removed} expired presentation(s)")
|
say(f"removed {removed} expired presentation(s)")
|
||||||
return 0
|
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()
|
sweep()
|
||||||
|
retry_requested()
|
||||||
|
|
||||||
if arguments.once:
|
if arguments.once:
|
||||||
imported = scan()
|
imported = scan()
|
||||||
@@ -453,6 +548,7 @@ def main():
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
retry_requested()
|
||||||
scan()
|
scan()
|
||||||
|
|
||||||
if time.monotonic() - last_cleanup > CLEANUP_SECONDS:
|
if time.monotonic() - last_cleanup > CLEANUP_SECONDS:
|
||||||
|
|||||||
@@ -682,11 +682,11 @@ class Library(Gtk.Window):
|
|||||||
would make the buttons unclickable.
|
would make the buttons unclickable.
|
||||||
"""
|
"""
|
||||||
return tuple(
|
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
|
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."""
|
"""Size to the cards while staying inside the panel's work area."""
|
||||||
workarea = monitor_workarea(self)
|
workarea = monitor_workarea(self)
|
||||||
maximum_width = max(420, int(workarea.width * 0.92))
|
maximum_width = max(420, int(workarea.width * 0.92))
|
||||||
@@ -705,7 +705,10 @@ class Library(Gtk.Window):
|
|||||||
columns = min(count, available, 4)
|
columns = min(count, available, 4)
|
||||||
rows = (count + columns - 1) // columns
|
rows = (count + columns - 1) // columns
|
||||||
width = min(maximum_width, max(420, 40 + columns * card_with_gap))
|
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)
|
self.resize(width, height)
|
||||||
|
|
||||||
@@ -735,14 +738,19 @@ class Library(Gtk.Window):
|
|||||||
# something is "being prepared" when it never will be is how
|
# something is "being prepared" when it never will be is how
|
||||||
# somebody ends up waiting through a Bible study for it.
|
# somebody ends up waiting through a Bible study for it.
|
||||||
ready = sum(1 for p in presentations if p.ready)
|
ready = sum(1 for p in presentations if p.ready)
|
||||||
broken = sum(1 for p in presentations if p.failed)
|
again = sum(1 for p in presentations if p.failure == "memory")
|
||||||
working = len(presentations) - ready - broken
|
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"]
|
parts = [f"{ready} ready to present"]
|
||||||
|
|
||||||
if working:
|
if working:
|
||||||
parts.append(f"{working} still being prepared")
|
parts.append(f"{working} still being prepared")
|
||||||
|
|
||||||
|
if again:
|
||||||
|
parts.append(f"{again} to try again")
|
||||||
|
|
||||||
if broken:
|
if broken:
|
||||||
parts.append(f"{broken} could not be opened")
|
parts.append(f"{broken} could not be opened")
|
||||||
|
|
||||||
@@ -752,7 +760,8 @@ class Library(Gtk.Window):
|
|||||||
self.grid.add(self.card(presentation))
|
self.grid.add(self.card(presentation))
|
||||||
|
|
||||||
self.grid.show_all()
|
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):
|
def card(self, presentation):
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||||
@@ -802,6 +811,20 @@ class Library(Gtk.Window):
|
|||||||
look = Gtk.Button(label="Quick Look")
|
look = Gtk.Button(label="Quick Look")
|
||||||
look.connect("clicked", lambda *_: self.quick_look(presentation))
|
look.connect("clicked", lambda *_: self.quick_look(presentation))
|
||||||
buttons.pack_start(look, True, True, 0)
|
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:
|
elif presentation.failed:
|
||||||
# SHORT, AND IN WORDS A TEACHER CAN ACT ON.
|
# SHORT, AND IN WORDS A TEACHER CAN ACT ON.
|
||||||
#
|
#
|
||||||
@@ -878,6 +901,12 @@ class Library(Gtk.Window):
|
|||||||
self._signature = None
|
self._signature = None
|
||||||
self.refresh()
|
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):
|
def remove(self, presentation):
|
||||||
dialog = Gtk.MessageDialog(
|
dialog = Gtk.MessageDialog(
|
||||||
transient_for=self,
|
transient_for=self,
|
||||||
|
|||||||
@@ -24,5 +24,26 @@ Slice=session.slice
|
|||||||
Nice=10
|
Nice=10
|
||||||
IOSchedulingClass=idle
|
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]
|
[Install]
|
||||||
WantedBy=plasma-workspace.target
|
WantedBy=plasma-workspace.target
|
||||||
|
|||||||
@@ -422,6 +422,105 @@ check("normal title bars contain only Close",
|
|||||||
check("the unfinished Help launcher is absent from the panel",
|
check("the unfinished Help launcher is absent from the panel",
|
||||||
"ekklesia-manual.desktop" not in layout)
|
"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)
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|||||||
@@ -177,6 +177,77 @@ ok, answer = printing.submit(pdf, "Elmer")
|
|||||||
check("a refused job is a failure, in CUPS's own words",
|
check("a refused job is a failure, in CUPS's own words",
|
||||||
not ok and answer == 'Destination "Elmer" is not accepting jobs.', answer)
|
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()
|
||||||
print("5. Wonder Ink prints through this, and only this")
|
print("5. Wonder Ink prints through this, and only this")
|
||||||
ink = (root / "scripts/classroom-wonderink").read_text()
|
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("no QWebEngineView.print()", not re.search(r"\bview\.print\(", code))
|
||||||
check("a lesson page becomes a PDF by printToPdf", "printToPdf(" in code)
|
check("a lesson page becomes a PDF by printToPdf", "printToPdf(" in code)
|
||||||
check("which is given up on if the engine never answers",
|
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",
|
check("the preview submits with classroom_printing.submit",
|
||||||
"classroom_printing.submit(" in code)
|
"classroom_printing.submit(" in code)
|
||||||
check("there is no file chooser in it", "QFileDialog" not in code)
|
check("there is no file chooser in it", "QFileDialog" not in code)
|
||||||
check("a worksheet tab is printed from its file",
|
check("a worksheet tab is printed from its file",
|
||||||
"self._pdf_views[view] = resolved" in code)
|
"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("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",
|
check("an exception in a handler is logged, not fatal",
|
||||||
"sys.excepthook = keep_going" in code)
|
"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",
|
check("the module is packaged",
|
||||||
"lib/classroom_printing.py" in (root / "tools/build-release.sh").read_text())
|
"lib/classroom_printing.py" in (root / "tools/build-release.sh").read_text())
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -197,6 +197,16 @@ for restriction in ("run_command=false", "shell_access=false",
|
|||||||
"action/lock_screen=false",
|
"action/lock_screen=false",
|
||||||
"plasma/plasmashell/unlockedDesktop=false"):
|
"plasma/plasmashell/unlockedDesktop=false"):
|
||||||
check(f"{restriction}", restriction in restrictions)
|
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)
|
check("the restrictions are immutable", "[KDE Action Restrictions][$i]" in restrictions)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|||||||
@@ -388,6 +388,11 @@ install -Dm644 \
|
|||||||
"$ROOT/lib/classroom_printing.py" \
|
"$ROOT/lib/classroom_printing.py" \
|
||||||
"$STAGE/usr/local/lib/ekklesia-classroom/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.
|
# PRESENTATIONS.
|
||||||
#
|
#
|
||||||
# The library a teacher sees, and the watcher that fills it from their
|
# The library a teacher sees, and the watcher that fills it from their
|
||||||
|
|||||||
@@ -370,6 +370,26 @@ check "device.conf carries the setting" 1 \
|
|||||||
"$(in_lab 'grep -c "^WONDERINK_ENABLED=" /etc/ekklesia-classroom/device.conf || true')"
|
"$(in_lab 'grep -c "^WONDERINK_ENABLED=" /etc/ekklesia-classroom/device.conf || true')"
|
||||||
check "and it defaults to on" true \
|
check "and it defaults to on" true \
|
||||||
"$(in_lab 'sed -n "s/^WONDERINK_ENABLED=//p" /etc/ekklesia-classroom/device.conf')"
|
"$(in_lab 'sed -n "s/^WONDERINK_ENABLED=//p" /etc/ekklesia-classroom/device.conf')"
|
||||||
|
# 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
|
# 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
|
# 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.
|
# until 0.2.6.1 and so failed on every lab run in between.
|
||||||
|
|||||||
@@ -17,11 +17,15 @@ docker exec "$NAME" sh -c '
|
|||||||
chmod +x /usr/sbin/policy-rc.d
|
chmod +x /usr/sbin/policy-rc.d
|
||||||
apt-get update -qq >/dev/null 2>&1'
|
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 "
|
docker exec "$NAME" sh -c "
|
||||||
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${DEB#$ROOT/}' \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '/srv/classroom/${DEB#$ROOT/}' \
|
||||||
>/dev/null 2>&1 || true
|
>/tmp/from-package-install.log 2>&1 || cat /tmp/from-package-install.log
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get -y -qq -f install >/dev/null 2>&1
|
|
||||||
DEBIAN_FRONTEND=noninteractive dpkg --configure ekklesia-classroom >/dev/null 2>&1
|
|
||||||
rm -f /usr/sbin/policy-rc.d"
|
rm -f /usr/sbin/policy-rc.d"
|
||||||
|
|
||||||
state=$(docker exec "$NAME" dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom)
|
state=$(docker exec "$NAME" dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom)
|
||||||
|
|||||||
@@ -135,5 +135,23 @@ check("and the window will open exactly that path", window.may_open(spaces))
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print(f"{passed} passed, {failed} failed")
|
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)
|
sys.exit(1 if failed else 0)
|
||||||
PY
|
PY
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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:])
|
||||||
@@ -25,6 +25,11 @@ lab() { docker exec -i "$NAME" bash -c "$1"; }
|
|||||||
RIG=/opt/wonderink-check/wonderink-print-rig.py
|
RIG=/opt/wonderink-check/wonderink-print-rig.py
|
||||||
lab 'mkdir -p /opt/wonderink-check'
|
lab 'mkdir -p /opt/wonderink-check'
|
||||||
docker cp "$ROOT/tools/lab/wonderink-print-rig.py" "$NAME:$RIG" >/dev/null
|
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" || {
|
lab "chmod -R a+rX /opt/wonderink-check && test -r $RIG" || {
|
||||||
echo "could not put the rig in the lab" >&2
|
echo "could not put the rig in the lab" >&2
|
||||||
exit 1
|
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
|
# 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.
|
# own port and directory: a lab often has another test server on 8088.
|
||||||
mkdir -p /opt/wonderink-check/www
|
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; }
|
' || { 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
|
# Started detached. A background process started inside a docker exec
|
||||||
# dies with it in the Plasma lab, whose PID 1 is systemd.
|
# dies with it in the Plasma lab, whose PID 1 is systemd.
|
||||||
lab 'pgrep -f "[h]ttp.server 8089" >/dev/null' || \
|
lab 'pgrep -f "[h]ttp.server 8089" >/dev/null' || \
|
||||||
docker exec -d -w /opt/wonderink-check/www "$NAME" python3 -m http.server 8089
|
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
|
passed=0
|
||||||
failed=0
|
failed=0
|
||||||
|
|
||||||
@@ -97,12 +122,97 @@ run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
count() { wc -w <<< "$jobs"; }
|
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"; }
|
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'; }
|
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=("$@")
|
||||||
[[ ${#scenarios[@]} -gt 0 ]] || scenarios=(web pdf pdf-options pdf-viewer-button inline
|
[[ ${#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.
|
# 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'; }
|
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 ]]' ;;
|
judge "one job" '[[ $(count) -eq 1 ]]' ;;
|
||||||
repeat)
|
repeat)
|
||||||
judge "five prints in a row, five jobs" '[[ $(count) -eq 5 ]]' ;;
|
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
|
esac
|
||||||
|
|
||||||
[[ "$failed" -eq 0 ]] || { echo "$out" | tail -15 | sed 's/^/ /'; }
|
[[ "$failed" -eq 0 ]] || { echo "$out" | tail -15 | sed 's/^/ /'; }
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ from PyQt6.QtWebEngineCore import QWebEngineProfile, QWebEngineSettings
|
|||||||
notes = []
|
notes = []
|
||||||
wi.notify = lambda s, b: (notes.append(s), print("NOTIFY", s, "|", b, flush=True))
|
wi.notify = lambda s, b: (notes.append(s), print("NOTIFY", s, "|", b, flush=True))
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
profile = QWebEngineProfile("rig", app)
|
# Wonder Ink's own profile, settings and all — not one of the rig's.
|
||||||
for a in ("PdfViewerEnabled", "PluginsEnabled"):
|
profile = wi.build_profile(app, "rig")
|
||||||
profile.settings().setAttribute(getattr(QWebEngineSettings.WebAttribute, a), True)
|
|
||||||
window = wi.WonderInk(profile)
|
window = wi.WonderInk(profile)
|
||||||
profile.downloadRequested.connect(window.on_download)
|
profile.downloadRequested.connect(window.on_download)
|
||||||
window.resize(1280, 800); window.show()
|
window.resize(1280, 800); window.show()
|
||||||
@@ -60,7 +59,53 @@ def when(cond, then, label, limit=30):
|
|||||||
def preview(): return window._preview
|
def preview(): return window._preview
|
||||||
def ready(): return preview() is not None and preview()._pdf is not None
|
def ready(): return preview() is not None and preview()._pdf is not None
|
||||||
def describe(d):
|
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):
|
def finish(code=0):
|
||||||
stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}")
|
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"),
|
"cancel": lambda view: when(ready, lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()), "preview"),
|
||||||
"options": lambda view: when(ready, lambda: (
|
"options": lambda view: when(ready, lambda: (
|
||||||
preview()._which.setCurrentIndex(1), preview()._range.setText("2-3"),
|
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: (
|
"bad-range": lambda view: when(ready, lambda: (
|
||||||
preview()._which.setCurrentIndex(1), preview()._range.setText("7"), preview()._print.click(),
|
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}"),
|
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"),
|
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]
|
view = window.open_tab(); lesson(view); n = [0]
|
||||||
def once():
|
def once():
|
||||||
if n[0] == 5: return finish()
|
if n[0] == 5: return finish()
|
||||||
@@ -113,7 +417,8 @@ elif scenario in ("pdf", "pdf-options"):
|
|||||||
if scenario == "pdf-options":
|
if scenario == "pdf-options":
|
||||||
actions["pdf-options"] = lambda view: when(ready, lambda: (
|
actions["pdf-options"] = lambda view: when(ready, lambda: (
|
||||||
preview()._which.setCurrentIndex(1), preview()._range.setText("2"),
|
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)),
|
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)),
|
||||||
actions[scenario if scenario == "pdf-options" else "web"](view)))
|
actions[scenario if scenario == "pdf-options" else "web"](view)))
|
||||||
elif scenario == "pdf-viewer-button":
|
elif scenario == "pdf-viewer-button":
|
||||||
|
|||||||
@@ -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 ]]
|
||||||
@@ -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())
|
||||||