Compare commits
@@ -24,8 +24,18 @@ cursorTheme=breeze_cursors
|
||||
library=org.kde.breeze
|
||||
theme=Breeze
|
||||
|
||||
# The picture a desktop gets when nothing has said which. That is every
|
||||
# desktop Plasma makes on its own after the layout has run: a television
|
||||
# switched on after the computer, a second screen, a new activity.
|
||||
#
|
||||
# A wallpaper PACKAGE NAME, not a file. Plasma's DefaultWallpaper looks it
|
||||
# up as wallpapers/<name> and nothing else. This line was a path until
|
||||
# 0.2.6.6, which Plasma cannot load as a package, so every such desktop
|
||||
# fell back to Plasma's own purple one — measured, and it is what Tiny
|
||||
# Pebbles showed after the 0.2.2 upgrade. The package is a directory whose
|
||||
# one image is a link to the classroom's file; see build-release.sh.
|
||||
[Wallpaper]
|
||||
Image=/usr/share/backgrounds/ekklesia/classroom-calm.png
|
||||
Image=EkklesiaClassroom
|
||||
|
||||
[KSplash]
|
||||
Theme=None
|
||||
|
||||
@@ -37,6 +37,13 @@ var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
|
||||
var PANEL_HEIGHT = 76;
|
||||
|
||||
// The idle desktop: the wallpaper, and nothing on it.
|
||||
//
|
||||
// Only the desktops that exist right now. A screen that appears later —
|
||||
// the television switched on after a reboot at three in the morning, which
|
||||
// is what Tiny Pebbles had — gets a desktop Plasma makes without this
|
||||
// file. That one takes the look and feel's default wallpaper, which is the
|
||||
// classroom's package, and classroom-desktop-wallpaper writes it in by
|
||||
// name before the shell next starts.
|
||||
var screens = desktops();
|
||||
for (var i = 0; i < screens.length; i++) {
|
||||
var desktop = screens[i];
|
||||
@@ -87,26 +94,9 @@ if (PARENT_CALL) {
|
||||
parentCall.writeConfig("maxSectionCount", 1);
|
||||
}
|
||||
|
||||
// HELP, beside Parent Call and not in the row with the applications.
|
||||
//
|
||||
// Every room gets it: the manual is the one thing that is never optional,
|
||||
// because a teacher who needs it is by definition a teacher who does not
|
||||
// know where to look. It shows only the pages for the features THIS
|
||||
// device has — see classroom-manual — so the button means the same thing
|
||||
// in every room while what it opens does not.
|
||||
//
|
||||
// On the left, with Parent Call, for the same reason Parent Call is
|
||||
// there: it is not one of the room's applications, and putting it in the
|
||||
// middle row would move buttons teachers reach for without looking.
|
||||
var help = panel.addWidget("org.kde.plasma.quicklaunch");
|
||||
help.currentConfigGroup = ["General"];
|
||||
help.writeConfig(
|
||||
"launcherUrls",
|
||||
"file:///usr/share/applications/ekklesia-manual.desktop"
|
||||
);
|
||||
help.writeConfig("showLauncherNames", false);
|
||||
help.writeConfig("enablePopup", false);
|
||||
help.writeConfig("maxSectionCount", 1);
|
||||
// Help is intentionally absent from this release's teacher-facing panel.
|
||||
// Its launcher, source pages and build pipeline remain installed for the
|
||||
// documentation correction pass; only the unfinished entry point is hidden.
|
||||
|
||||
var leftGap = panel.addWidget("org.kde.plasma.panelspacer");
|
||||
leftGap.currentConfigGroup = ["General"];
|
||||
|
||||
@@ -26,6 +26,12 @@ editable_desktop_icons=false
|
||||
editable_bookmarks=false
|
||||
movable_toolbars=false
|
||||
plasma/plasmashell/unlockedDesktop=false
|
||||
# A locked panel still offered "Configure Digital Clock…" on the clock's
|
||||
# right-click menu — measured on Plasma 6.3. This is Plasma's own key for
|
||||
# "no widget settings while the desktop is locked", and with it that entry
|
||||
# is gone. (The clock's "Adjust Date and Time…" stays, and opens a System
|
||||
# Settings with nothing in it: every module is restricted below.)
|
||||
plasma/allow_configure_when_locked=false
|
||||
plasma-desktop/scripting_console=false
|
||||
# Nothing in the classroom should offer to file a bug or open a manual.
|
||||
action/help_report_bug=false
|
||||
@@ -47,6 +53,115 @@ action/help_contents=false
|
||||
# is not shown doors that will not open.
|
||||
[KDE Control Module Restrictions][$i]
|
||||
kcm_networkmanagement=false
|
||||
#
|
||||
# AND EVERY OTHER SETTINGS MODULE. A notification's Configure button —
|
||||
# measured on Plasma 6.3, from the "Memory Shortage Avoided" notice the
|
||||
# kernel's memory killer raised — runs `kcmshell6 notifications`, which
|
||||
# opened KDE's whole notification settings under the teacher's account:
|
||||
# every application and system service, with Apply. The notification
|
||||
# applet draws that button without asking this group; kcmshell6 and
|
||||
# System Settings do ask it, and with the module listed here the same
|
||||
# click opens only "The module kcm_notifications is disabled ... by the
|
||||
# system administrator". Measured.
|
||||
#
|
||||
# KDE has no wildcard for this, so the list is every module installed by
|
||||
# Debian 13's Plasma 6.3, as found on the lab machine. A teacher has a use
|
||||
# for none of them. tools/lab/converged-check fails if a module is ever
|
||||
# installed that is not listed here, so a Plasma update cannot quietly
|
||||
# reopen a door.
|
||||
#
|
||||
# The maintenance terminal is untouched: it runs with
|
||||
# XDG_CONFIG_DIRS=/etc/xdg, outside this file, so System Settings started
|
||||
# from it is the whole of System Settings.
|
||||
kcm_about-distro=false
|
||||
kcm_access=false
|
||||
kcm_activities=false
|
||||
kcm_audio_information=false
|
||||
kcm_autostart=false
|
||||
kcm_baloofile=false
|
||||
kcm_block_devices=false
|
||||
kcm_bluetooth=false
|
||||
kcm_bolt=false
|
||||
kcm_clock=false
|
||||
kcm_colors=false
|
||||
kcm_componentchooser=false
|
||||
kcm_cpu=false
|
||||
kcm_cursortheme=false
|
||||
kcm_desktoppaths=false
|
||||
kcm_desktoptheme=false
|
||||
kcm_device_automounter=false
|
||||
kcm_disks=false
|
||||
kcm_edid=false
|
||||
kcm_egl=false
|
||||
kcm_energyinfo=false
|
||||
kcm_feedback=false
|
||||
kcm_filetypes=false
|
||||
kcm_firewall=false
|
||||
kcm_firmware_security=false
|
||||
kcm_fontinst=false
|
||||
kcm_fonts=false
|
||||
kcm_gamecontroller=false
|
||||
kcm_glx=false
|
||||
kcm_icons=false
|
||||
kcm_interrupts=false
|
||||
kcm_kded=false
|
||||
kcm_keyboard=false
|
||||
kcm_keys=false
|
||||
kcm_kgamma=false
|
||||
kcm_krunner_kill=false
|
||||
kcm_krunnersettings=false
|
||||
kcm_kscreen=false
|
||||
kcm_kwallet5=false
|
||||
kcm_kwin_effects=false
|
||||
kcm_kwin_scripts=false
|
||||
kcm_kwin_virtualdesktops=false
|
||||
kcm_kwindecoration=false
|
||||
kcm_kwinoptions=false
|
||||
kcm_kwinrules=false
|
||||
kcm_kwinscreenedges=false
|
||||
kcm_kwinsupportinfo=false
|
||||
kcm_kwintabbox=false
|
||||
kcm_kwintouchscreen=false
|
||||
kcm_kwinxwayland=false
|
||||
kcm_landingpage=false
|
||||
kcm_lookandfeel=false
|
||||
kcm_memory=false
|
||||
kcm_mouse=false
|
||||
kcm_netpref=false
|
||||
kcm_network=false
|
||||
kcm_nightlight=false
|
||||
kcm_notifications=false
|
||||
kcm_opencl=false
|
||||
kcm_pci=false
|
||||
kcm_plasmasearch=false
|
||||
kcm_powerdevilprofilesconfig=false
|
||||
kcm_proxy=false
|
||||
kcm_pulseaudio=false
|
||||
kcm_qtquicksettings=false
|
||||
kcm_recentFiles=false
|
||||
kcm_regionandlang=false
|
||||
kcm_samba=false
|
||||
kcm_screenlocker=false
|
||||
kcm_sddm=false
|
||||
kcm_smserver=false
|
||||
kcm_solid_actions=false
|
||||
kcm_soundtheme=false
|
||||
kcm_splashscreen=false
|
||||
kcm_style=false
|
||||
kcm_tablet=false
|
||||
kcm_touchpad=false
|
||||
kcm_touchscreen=false
|
||||
kcm_usb=false
|
||||
kcm_users=false
|
||||
kcm_virtualkeyboard=false
|
||||
kcm_vulkan=false
|
||||
kcm_wallpaper=false
|
||||
kcm_wayland=false
|
||||
kcm_webshortcuts=false
|
||||
kcm_workspace=false
|
||||
kcm_xserver=false
|
||||
kcmspellchecking=false
|
||||
kwincompositing=false
|
||||
|
||||
# Not immutable. Marking this group [$i] froze the colour scheme so hard
|
||||
# that `classroom-theme light` changed the running session and then lost
|
||||
|
||||
@@ -82,15 +82,14 @@ NoiseStrength=2
|
||||
[org.kde.kdecoration2][$i]
|
||||
library=org.kde.breeze
|
||||
theme=Breeze
|
||||
# A pin and a close button, and nothing else.
|
||||
#
|
||||
# The pin is the Live View story: keeping a window above the others is a
|
||||
# window-manager state, not a feature the classroom has to invent. There is
|
||||
# deliberately no minimise button, because a minimised window is the one
|
||||
# thing a teacher cannot see the state of — measured, not assumed. Double
|
||||
# clicking the title bar still maximises, so nothing is unreachable.
|
||||
# A close button, and nothing else. `F` was the keep-above button, rendered
|
||||
# by Breeze as an unexplained double chevron beside Close. Classroom windows
|
||||
# that must stay above others request that state themselves; teachers do not
|
||||
# need a title-bar toggle for it. There is deliberately no minimise button,
|
||||
# because a minimised window is the one thing a teacher cannot see the state
|
||||
# of. Double clicking the title bar still maximises ordinary windows.
|
||||
ButtonsOnLeft=
|
||||
ButtonsOnRight=FX
|
||||
ButtonsOnRight=X
|
||||
ShowToolTips=false
|
||||
BorderSize=None
|
||||
BorderSizeAuto=false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Window rules.
|
||||
#
|
||||
# There are two: one about video, one about the Parent Call card.
|
||||
# There are three: video, the Parent Call card, and the presentation surface.
|
||||
#
|
||||
# Everything else the classroom used to arrange by hand is ordinary window
|
||||
# manager behaviour and needs no rule: a dialog stays in front of the window
|
||||
@@ -17,8 +17,8 @@
|
||||
# 314-line window arbiter was doing.
|
||||
|
||||
[General]
|
||||
count=2
|
||||
rules=classroom-video,classroom-parent-call
|
||||
count=3
|
||||
rules=classroom-video,classroom-parent-call,classroom-presentation
|
||||
|
||||
[classroom-video]
|
||||
Description=Video is an appliance screen, not a window
|
||||
@@ -48,3 +48,20 @@ skippagerrule=2
|
||||
# button, rather than behind it.
|
||||
above=true
|
||||
aboverule=2
|
||||
|
||||
[classroom-presentation]
|
||||
Description=A presentation is the whole display, not a large window
|
||||
# The library and Quick Look have the same Wayland app id, so the stable
|
||||
# presenter title is part of this match. Matching the app alone would make
|
||||
# every Presentations window fullscreen.
|
||||
wmclass=ekklesia-presentations
|
||||
wmclassmatch=1
|
||||
wmclasscomplete=false
|
||||
title=Presenting
|
||||
titlematch=1
|
||||
fullscreen=true
|
||||
fullscreenrule=2
|
||||
noborder=true
|
||||
noborderrule=2
|
||||
skiptaskbar=true
|
||||
skiptaskbarrule=2
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"KPlugin": {
|
||||
"Authors": [
|
||||
{
|
||||
"Name": "Ekklesia"
|
||||
}
|
||||
],
|
||||
"Id": "EkklesiaClassroom",
|
||||
"License": "GPLv2+",
|
||||
"Name": "Ekklesia Classroom"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`kglobalshortcutsrc` entries; both are package files, so an update will
|
||||
restore them, which is the intended direction.
|
||||
|
||||
## Settings the teacher must not reach (0.2.6.2)
|
||||
|
||||
During testing the kernel's memory killer raised Plasma's *Memory Shortage
|
||||
Avoided* notice, and its **Configure** button opened KDE's Notifications
|
||||
settings under the teacher's account — every application and system
|
||||
service, with Apply. Reproduced in the lab: the button runs
|
||||
`kcmshell6 notifications`.
|
||||
|
||||
Plasma draws that button on any notification whose application has KDE
|
||||
notification settings, without asking the Kiosk policy. What does ask is
|
||||
the thing it launches: `kcmshell6` and System Settings refuse a module
|
||||
listed under `[KDE Control Module Restrictions]`. KDE has no wildcard for
|
||||
that group, so **every settings module Debian 13's Plasma 6.3 installs —
|
||||
90, found on the lab machine — is listed**, immutable, in the classroom's
|
||||
`kdeglobals`. Measured afterwards:
|
||||
|
||||
| Entry point | Before | After |
|
||||
| --- | --- | --- |
|
||||
| A notification's Configure button | Notifications settings, editable | *The module kcm_notifications is disabled … by the system administrator* |
|
||||
| That window's **Help** button | KDE Help Center: menus, man pages, file dialogs | not installed (`Conflicts: khelpcenter`; plasma-desktop only recommends it) |
|
||||
| Volume: right-click | *Configure Audio Devices…* | entry gone — the applet asks the policy |
|
||||
| Clock: right-click | *Adjust Date and Time…*, *Configure Digital Clock…* | *Configure* gone (`plasma/allow_configure_when_locked=false`); *Adjust* opens a System Settings with nothing in it |
|
||||
| Bell, empty panel: right-click | no menu | no menu |
|
||||
| Network | already refused (0.2.4) | unchanged |
|
||||
|
||||
The button itself still shows on such a notification, and still opens the
|
||||
small "disabled" window: hiding it would mean replacing Plasma's
|
||||
notification applet, which is exactly what this appliance does not do.
|
||||
Notifications themselves are untouched — Parent Call, the room's
|
||||
messages, Wonder Ink's "Sent to the printer" all still appear.
|
||||
|
||||
**The maintenance route is unaffected.** `classroom-admin-terminal` starts
|
||||
Konsole with `XDG_CONFIG_DIRS=/etc/xdg`, outside this file, so anything a
|
||||
technician starts from it — `systemsettings`, `kcmshell6 kcm_notifications`
|
||||
— is the whole of it. Verified in the lab: the same module refused to the
|
||||
teacher opened fully, editable, from the maintenance terminal.
|
||||
|
||||
`tools/lab/converged-check` fails if any settings module on the machine is
|
||||
not refused in the classroom's cascade, so a Plasma update that adds one
|
||||
cannot quietly reopen this; and it checks the maintenance cascade still
|
||||
refuses nothing.
|
||||
|
||||
@@ -20,16 +20,69 @@ and the **×** on a tab to close it.
|
||||
There is no address bar, on purpose. Wonder Ink can take you anywhere
|
||||
the lesson needs to go.
|
||||
|
||||
## Making the lesson bigger or smaller
|
||||
|
||||
Press **Ctrl** and **+** to make the lesson bigger, **Ctrl** and **−** to
|
||||
make it smaller, and **Ctrl** and **0** to go back to normal. You can also
|
||||
right-click the lesson and choose **Make the lesson bigger**. When several
|
||||
tabs are open, the **− 100% +** buttons at the top right do the same.
|
||||
|
||||
The size applies to every tab and is remembered next time.
|
||||
|
||||
## Printing
|
||||
|
||||
Press **Ctrl** and **P**, click **Print** at the top right, or right-click
|
||||
and choose **Print page…**. You will see the pages exactly as they will
|
||||
print.
|
||||
|
||||
* Choose **Only some pages** to print, for example, `2` or `1-3`.
|
||||
* Set **Copies** for the number of children.
|
||||
* Choose **Black and white** to save color ink. The preview turns grey,
|
||||
so you see what will come out; choose **Color** to go back.
|
||||
|
||||
**Printing a picture from the lesson.** Right-click the picture and
|
||||
choose **Print image…**. Only that picture prints, not the page around
|
||||
it. This works for pictures you cannot open by themselves.
|
||||
|
||||
In the preview for a picture, also under **Picture**: choose its
|
||||
**Size** (for example 50%), click one of the nine **Position** squares to
|
||||
put it in a corner or the middle, and choose **Paper** upright or
|
||||
sideways. The preview changes as you choose, and shows exactly what will
|
||||
print. **Fill the whole page** makes it cover the paper but cuts off the
|
||||
edges. A picture that is open in a tab by itself prints the same way.
|
||||
|
||||
If Wonder Ink says a picture *could not be taken from the page*, it had
|
||||
not finished loading: close the preview and try again, or choose **Print
|
||||
page…** to print the whole page instead.
|
||||
|
||||
Click **Print**. Wonder Ink says when the printer has it and goes back to
|
||||
the lesson. **Cancel** prints nothing.
|
||||
|
||||
## Worksheets and colouring pages
|
||||
|
||||
Click the worksheet link in the lesson. It opens in a new tab, ready to
|
||||
read or print.
|
||||
read or print. Any other link in a lesson — a slide, a picture, a video —
|
||||
also opens in a new tab with one click, and Wonder Ink shows it straight
|
||||
away; the lesson stays open in its own tab. A worksheet's own **−** and
|
||||
**+** at the top of the page make it bigger or smaller.
|
||||
|
||||
Worksheets you download are kept separately from everything else on the
|
||||
computer, so they never get mixed up with anything.
|
||||
|
||||
## If Wonder Ink closes by accident
|
||||
|
||||
Open it again. If you used it in the last two hours, **your recent tabs
|
||||
come back** — the slides, pictures and worksheets you had open, in the
|
||||
same order. Wonder Ink's own lesson tab comes back at the sign-in page:
|
||||
sign in again and open your lesson.
|
||||
|
||||
Your password is never kept. After two hours that nobody has used Wonder
|
||||
Ink, it forgets the tabs and the next person starts with a fresh Wonder
|
||||
Ink.
|
||||
|
||||
## When you have finished
|
||||
|
||||
**Close Wonder Ink at the end of your session.** Closing it signs you
|
||||
out, so the next teacher in this room starts with their own account and
|
||||
not yours.
|
||||
not yours. Your recent tabs are kept for two hours in case you need them
|
||||
again, but never your sign-in.
|
||||
|
||||
@@ -43,7 +43,7 @@ figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
|
||||
<body>
|
||||
<div class="sheet">
|
||||
<h1>Classroom manual</h1>
|
||||
<p class="revision">River of Glory · Revision 22 September 2026</p>
|
||||
<p class="revision">River of Glory · Revision 29 September 2026</p>
|
||||
<nav id='contents'></nav>
|
||||
<section id="00-welcome">
|
||||
<h2>Your classroom computer</h2>
|
||||
@@ -131,11 +131,24 @@ figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
|
||||
<ol><li>Click <strong>Wonder Ink</strong> on the bar.</li><li>Sign in with your Wonder Ink account.</li></ol>
|
||||
<p>A lesson often opens several tabs — the lesson, its video, an activity. They appear in a row across the top. Click a tab to move between them, and the <strong>×</strong> on a tab to close it.</p>
|
||||
<p>There is no address bar, on purpose. Wonder Ink can take you anywhere the lesson needs to go.</p>
|
||||
<h3>Making the lesson bigger or smaller</h3>
|
||||
<p>Press <strong>Ctrl</strong> and <strong>+</strong> to make the lesson bigger, <strong>Ctrl</strong> and <strong>−</strong> to make it smaller, and <strong>Ctrl</strong> and <strong>0</strong> to go back to normal. You can also right-click the lesson and choose <strong>Make the lesson bigger</strong>. When several tabs are open, the <strong>− 100% +</strong> buttons at the top right do the same.</p>
|
||||
<p>The size applies to every tab and is remembered next time.</p>
|
||||
<h3>Printing</h3>
|
||||
<p>Press <strong>Ctrl</strong> and <strong>P</strong>, click <strong>Print</strong> at the top right, or right-click and choose <strong>Print page…</strong>. You will see the pages exactly as they will print.</p>
|
||||
<ul><li>Choose <strong>Only some pages</strong> to print, for example, `2` or `1-3`.</li><li>Set <strong>Copies</strong> for the number of children.</li><li>Choose <strong>Black and white</strong> to save color ink. The preview turns grey, so you see what will come out; choose <strong>Color</strong> to go back.</li></ul>
|
||||
<p><strong>Printing a picture from the lesson.</strong> Right-click the picture and choose <strong>Print image…</strong>. Only that picture prints, not the page around it. This works for pictures you cannot open by themselves.</p>
|
||||
<p>In the preview for a picture, also under <strong>Picture</strong>: choose its <strong>Size</strong> (for example 50%), click one of the nine <strong>Position</strong> squares to put it in a corner or the middle, and choose <strong>Paper</strong> upright or sideways. The preview changes as you choose, and shows exactly what will print. <strong>Fill the whole page</strong> makes it cover the paper but cuts off the edges. A picture that is open in a tab by itself prints the same way.</p>
|
||||
<p>If Wonder Ink says a picture *could not be taken from the page*, it had not finished loading: close the preview and try again, or choose <strong>Print page…</strong> to print the whole page instead.</p>
|
||||
<p>Click <strong>Print</strong>. Wonder Ink says when the printer has it and goes back to the lesson. <strong>Cancel</strong> prints nothing.</p>
|
||||
<h3>Worksheets and colouring pages</h3>
|
||||
<p>Click the worksheet link in the lesson. It opens in a new tab, ready to read or print.</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>
|
||||
<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>
|
||||
<p><strong>Close Wonder Ink at the end of your session.</strong> Closing it signs you out, so the next teacher in this room starts with their own account and not yours.</p>
|
||||
<p><strong>Close Wonder Ink at the end of your session.</strong> Closing it signs you out, so the next teacher in this room starts with their own account and not yours. Your recent tabs are kept for two hours in case you need them again, but never your sign-in.</p>
|
||||
<p><a class="top" href="#contents">Back to the top</a></p>
|
||||
</section>
|
||||
<section id="60-parent-call" data-feature="PARENT_CALL_ENABLED">
|
||||
|
||||
@@ -3,47 +3,50 @@
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R] /Count 11 >>
|
||||
<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /Count 12 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 14 0 R >>
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 15 0 R >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 15 0 R >>
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 16 0 R >>
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 16 0 R >>
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 17 0 R >>
|
||||
endobj
|
||||
6 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 17 0 R >>
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 18 0 R >>
|
||||
endobj
|
||||
7 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 18 0 R >>
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 19 0 R >>
|
||||
endobj
|
||||
8 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 19 0 R >>
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 20 0 R >>
|
||||
endobj
|
||||
9 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 20 0 R >>
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 21 0 R >>
|
||||
endobj
|
||||
10 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 21 0 R >>
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 22 0 R >>
|
||||
endobj
|
||||
11 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 22 0 R >>
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 23 0 R >>
|
||||
endobj
|
||||
12 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 23 0 R >>
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 24 0 R >>
|
||||
endobj
|
||||
13 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 24 0 R >>
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 25 0 R >>
|
||||
endobj
|
||||
14 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 26 0 R >>
|
||||
endobj
|
||||
15 0 obj
|
||||
<< /Length 765 >>
|
||||
stream
|
||||
BT /F2 26 Tf 1 0 0 1 62.0 720.0 Tm (Classroom manual) Tj ET
|
||||
BT /F1 13 Tf 1 0 0 1 62.0 677.1 Tm (River of Glory) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 656.6 Tm (Revision 22 September 2026) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 656.6 Tm (Revision 29 September 2026) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 623.7 Tm (This manual covers every classroom computer in the church. Not every room has every button, so) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 608.8 Tm (some pages begin by saying when they apply. If your computer does not have what a page describes,) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 593.9 Tm (that page is not for your room.) Tj ET
|
||||
@@ -51,7 +54,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 569.0 Tm (Keep this copy in the room. If the date ab
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 554.1 Tm (the office for a new one.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
16 0 obj
|
||||
<< /Length 1250 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Your classroom computer) Tj ET
|
||||
@@ -68,7 +71,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 580.7 Tm (The right-hand end shows the network, the
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 560.8 Tm (To open something, click its button once. To come back to it later, click the same button again.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
17 0 obj
|
||||
<< /Length 1408 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (The Browser) Tj ET
|
||||
@@ -89,7 +92,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 518.2 Tm (Anything you sign in to yourself - a perso
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 503.3 Tm (The computer is shared.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
18 0 obj
|
||||
<< /Length 1694 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Sound and volume) Tj ET
|
||||
@@ -113,7 +116,7 @@ BT /F1 10.5 Tf 1 0 0 1 94.0 512.3 Tm (across.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 492.4 Tm (If none of that works, tell the office. Do not unplug anything.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
19 0 obj
|
||||
<< /Length 4084 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Presentations) Tj ET
|
||||
@@ -164,7 +167,7 @@ BT /F1 10.5 Tf 1 0 0 1 94.0 113.3 Tm (Click Use this slide as the picture.) Tj E
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 93.4 Tm (The card changes straight away, and stays that way - after you close the library, and after the) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
20 0 obj
|
||||
<< /Length 3089 >>
|
||||
stream
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 780.0 Tm (computer is switched off and on again.) Tj ET
|
||||
@@ -205,8 +208,8 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 223.3 Tm (Its card says so, and offers only Remove.
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 203.4 Tm (Download it again. Your original download is still there either way.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<< /Length 1838 >>
|
||||
21 0 obj
|
||||
<< /Length 4396 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Wonder Ink) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
@@ -221,16 +224,57 @@ BT /F1 10.5 Tf 1 0 0 1 94.0 634.5 Tm (Sign in with your Wonder Ink account.) Tj
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 614.6 Tm (A lesson often opens several tabs - the lesson, its video, an activity. They appear in a row) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 599.7 Tm (across the top. Click a tab to move between them, and the x on a tab to close it.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 579.7 Tm (There is no address bar, on purpose. Wonder Ink can take you anywhere the lesson needs to go.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 551.8 Tm (Worksheets and colouring pages) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 531.1 Tm (Click the worksheet link in the lesson. It opens in a new tab, ready to read or print.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 511.2 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 496.3 Tm (get mixed up with anything.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 468.4 Tm (When you have finished) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 447.6 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 432.7 Tm (this room starts with their own account and not yours.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 551.8 Tm (Making the lesson bigger or smaller) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 531.1 Tm (Press Ctrl and + to make the lesson bigger, Ctrl and ? to make it smaller, and Ctrl and 0 to go) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 516.2 Tm (back to normal. You can also right-click the lesson and choose Make the lesson bigger. When) 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 /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 page?. You will) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 417.8 Tm (see the pages exactly as they will print.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 397.9 Tm (-) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 397.9 Tm (Choose Only some pages to print, for example, `2` or `1-3`.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 383.0 Tm (-) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 383.0 Tm (Set Copies for the number of children.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 368.1 Tm (-) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 368.1 Tm (Choose Black and white to save color ink. The preview turns grey, so you see what will come) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 353.1 Tm (out; choose Color to go back.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 333.2 Tm (Printing a picture from the lesson. Right-click the picture and choose Print image?. Only that) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 318.3 Tm (picture prints, not the page around it. This works for pictures you cannot open by themselves.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 298.4 Tm (In the preview for a picture, also under Picture: choose its Size \(for example 50%\), click one of) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 283.5 Tm (the nine Position squares to put it in a corner or the middle, and choose Paper upright or) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 268.6 Tm (sideways. The preview changes as you choose, and shows exactly what will print. Fill the whole) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 253.7 Tm (page makes it cover the paper but cuts off the edges. A picture that is open in a tab by itself) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 238.8 Tm (prints the same way.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 218.9 Tm (If Wonder Ink says a picture *could not be taken from the page*, it had not finished loading:) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 203.9 Tm (close the preview and try again, or choose Print page? to print the whole page instead.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 184.0 Tm (Click Print. Wonder Ink says when the printer has it and goes back to the lesson. Cancel prints) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 169.1 Tm (nothing.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 141.2 Tm (Worksheets and colouring pages) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 120.5 Tm (Click the worksheet link in the lesson. It opens in a new tab, ready to read or print. Any other) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 105.6 Tm (link in a lesson - a slide, a picture, a video - also opens in a new tab with one click, and Wonder) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 90.7 Tm (Ink shows it straight away; the lesson stays open in its own tab. A worksheet's own ? and + at the) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
22 0 obj
|
||||
<< /Length 1446 >>
|
||||
stream
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 780.0 Tm (top of the page make it bigger or smaller.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 760.1 Tm (Worksheets you download are kept separately from everything else on the computer, so they never) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 745.2 Tm (get mixed up with anything.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 717.3 Tm (If Wonder Ink closes by accident) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 696.5 Tm (Open it again. If you used it in the last two hours, your recent tabs come back - the slides,) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 681.6 Tm (pictures and worksheets you had open, in the same order. Wonder Ink's own lesson tab comes back at) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 666.7 Tm (the sign-in page: sign in again and open your lesson.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 646.8 Tm (Your password is never kept. After two hours that nobody has used Wonder Ink, it forgets the) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 631.9 Tm (tabs and the next person starts with a fresh Wonder Ink.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 604.0 Tm (When you have finished) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 583.2 Tm (Close Wonder Ink at the end of your session. Closing it signs you out, so the next teacher in) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 568.3 Tm (this room starts with their own account and not yours. Your recent tabs are kept for two hours in) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 553.4 Tm (case you need them again, but never your sign-in.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<< /Length 1475 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Parent Call) Tj ET
|
||||
@@ -253,7 +297,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 516.2 Tm (when it did not.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 496.3 Tm (If that happens, send somebody.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
22 0 obj
|
||||
24 0 obj
|
||||
<< /Length 1500 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Playing a DVD) Tj ET
|
||||
@@ -277,7 +321,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 487.4 Tm (Click X on that row to stop, or eject to s
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 467.5 Tm (Take the disc with you.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
25 0 obj
|
||||
<< /Length 1166 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Printing) Tj ET
|
||||
@@ -296,7 +340,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 591.8 Tm (in the queue and prints when the printer i
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 571.9 Tm (Sending it again two or three times is how ten copies of the same worksheet appear later.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
26 0 obj
|
||||
<< /Length 1766 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (If something goes wrong) Tj ET
|
||||
@@ -320,43 +364,45 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 411.0 Tm (Tell the office, and say which room and wh
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 396.1 Tm (the one that saves everybody time.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
27 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
26 0 obj
|
||||
28 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
xref
|
||||
0 27
|
||||
0 29
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000180 00000 n
|
||||
0000000319 00000 n
|
||||
0000000458 00000 n
|
||||
0000000597 00000 n
|
||||
0000000736 00000 n
|
||||
0000000875 00000 n
|
||||
0000001014 00000 n
|
||||
0000001153 00000 n
|
||||
0000001293 00000 n
|
||||
0000001433 00000 n
|
||||
0000001573 00000 n
|
||||
0000001713 00000 n
|
||||
0000002530 00000 n
|
||||
0000003833 00000 n
|
||||
0000005294 00000 n
|
||||
0000007041 00000 n
|
||||
0000011178 00000 n
|
||||
0000014320 00000 n
|
||||
0000016211 00000 n
|
||||
0000017739 00000 n
|
||||
0000019292 00000 n
|
||||
0000020511 00000 n
|
||||
0000022330 00000 n
|
||||
0000022428 00000 n
|
||||
0000000187 00000 n
|
||||
0000000326 00000 n
|
||||
0000000465 00000 n
|
||||
0000000604 00000 n
|
||||
0000000743 00000 n
|
||||
0000000882 00000 n
|
||||
0000001021 00000 n
|
||||
0000001160 00000 n
|
||||
0000001300 00000 n
|
||||
0000001440 00000 n
|
||||
0000001580 00000 n
|
||||
0000001720 00000 n
|
||||
0000001860 00000 n
|
||||
0000002677 00000 n
|
||||
0000003980 00000 n
|
||||
0000005441 00000 n
|
||||
0000007188 00000 n
|
||||
0000011325 00000 n
|
||||
0000014467 00000 n
|
||||
0000018916 00000 n
|
||||
0000020415 00000 n
|
||||
0000021943 00000 n
|
||||
0000023496 00000 n
|
||||
0000024715 00000 n
|
||||
0000026534 00000 n
|
||||
0000026632 00000 n
|
||||
trailer
|
||||
<< /Size 27 /Root 1 0 R >>
|
||||
<< /Size 29 /Root 1 0 R >>
|
||||
startxref
|
||||
22531
|
||||
26735
|
||||
%%EOF
|
||||
@@ -52,6 +52,8 @@ hash.
|
||||
This is not a new renderer. LibreOffice does exactly the rendering
|
||||
Impress would have done live — it is the same engine — and poppler does
|
||||
what every PDF viewer does. The classroom's own code displays a PNG.
|
||||
Because playback is made from static page images, PowerPoint animations,
|
||||
video, audio and other interactive content are not supported.
|
||||
What it buys:
|
||||
|
||||
* **A teacher can never reach an editor**, because no editor ever runs
|
||||
@@ -74,14 +76,15 @@ interactive runs, so no UI integration package is needed.
|
||||
## Where things are
|
||||
|
||||
```
|
||||
~/Downloads/ the teacher's own download. NEVER touched.
|
||||
~/Downloads/ completed presentations move from here
|
||||
~/Downloads/Wonder Ink/ Wonder Ink's worksheets. NEVER touched.
|
||||
|
||||
~/.local/share/ekklesia-classroom/presentations/
|
||||
imported.json which downloads have been dealt with
|
||||
<id>/
|
||||
presentation.json title, pages, thumbnail slide, timers
|
||||
Session 7 - Romans.pptx the managed copy, original name kept
|
||||
original/
|
||||
Session 7 - Romans.pptx the one original, name kept
|
||||
slides.pdf what everything is rendered from
|
||||
pages/page-001.png ... one per slide, 1920px wide
|
||||
thumbnail.png the card picture, from the chosen slide
|
||||
@@ -108,20 +111,76 @@ A file is imported when **all** of these hold:
|
||||
* its fingerprint — path, size, modification time — is not already in
|
||||
`imported.json`.
|
||||
|
||||
Two downloads of the same name are two presentations. The second gets
|
||||
`(2)` after its title; neither overwrites the other.
|
||||
Two downloads of the same name are two presentations in separate id
|
||||
directories. The second gets `(2)` after its title; neither overwrites the
|
||||
other.
|
||||
|
||||
**Crash safety.** The metadata is written saying `importing` *before* any
|
||||
work, and the fingerprint is recorded at the same moment so a file that
|
||||
crashes the converter cannot be retried forever. On startup the importer
|
||||
sweeps every directory still saying `importing`, deletes it and forgets
|
||||
its fingerprint, so an import interrupted by a reboot simply happens
|
||||
again. A presentation becomes visible to a teacher only once every file
|
||||
**Move and crash safety.** On one filesystem the completed download is
|
||||
renamed atomically. Across filesystems it is copied to a hidden temporary,
|
||||
flushed, published atomically, and only then removed from Downloads. The
|
||||
metadata and transaction fingerprint are written first. On startup, an
|
||||
interruption before the source was removed discards the partial destination
|
||||
and retries; an interruption after the move resumes conversion from the
|
||||
managed original. A presentation becomes ready only once every derived file
|
||||
it needs is on disk.
|
||||
|
||||
A file that cannot be converted gets a card saying so, offering only
|
||||
**Remove**. The teacher's download is untouched, which is what the card
|
||||
says.
|
||||
**Remove**. Its managed original remains recoverable even when PDF or page
|
||||
generation fails.
|
||||
|
||||
## Memory (0.2.6.2)
|
||||
|
||||
On a 4 GB Plasma VM the kernel killed `ekklesia-classroom-presentations.service`
|
||||
mid-import and Plasma said *Memory Shortage Avoided*. Measured afterwards,
|
||||
LibreOffice converting one deck with the importer's own code:
|
||||
|
||||
| Deck | PPTX | LibreOffice peak | Rendering peak |
|
||||
| --- | --- | --- | --- |
|
||||
| 10 slides of text | 20 KB | 203 MB, 0.8 s | 38 MB |
|
||||
| 30 slides, 12 photos shared | 23.5 MB | 738 MB, 16 s | 70 MB |
|
||||
| 60 slides, the same 12 photos | 23.6 MB | 754 MB, 28 s | 70 MB |
|
||||
| 30 slides, 30 different 12 MP photos | 58.7 MB | **1.43 GB**, 22 s | 69 MB |
|
||||
| 20 slides, 20 different 27 MP photos | 143 MB | **2.08 GB**, 24 s | 70 MB |
|
||||
|
||||
It grows with the photographs, not the slides, and it is a spike rather
|
||||
than a leak. PDF export with images reduced to 200 dpi saved only 5%,
|
||||
because LibreOffice holds the full images regardless. So the failure was a
|
||||
photo-heavy deck meeting a 4 GB machine with the room's other programs
|
||||
open — and what made it a problem was what happened next. systemd's
|
||||
default `OOMPolicy=stop` took the whole service down with the killed
|
||||
LibreOffice; it restarted, `sweep()` resumed the same conversion with no
|
||||
limit, and the library said *Getting this ready…* throughout.
|
||||
|
||||
Now:
|
||||
|
||||
* **`MemoryMax=40%`** — 1.6 GB on a 4 GB machine, 3.2 GB on 8 GB. Measured
|
||||
in the lab at 1.6 GB, the 2.1 GB deck still imported in 33 s: much of
|
||||
that peak is file-backed and reclaimable, and on a machine with swap the
|
||||
rest is swapped rather than killed (1.4 GB of it at a 200 MB ceiling).
|
||||
* **`OOMScoreAdjust=800`** — under pressure everywhere, LibreOffice goes
|
||||
before the lesson on the screen, the browser or the panel.
|
||||
* **`OOMPolicy=continue`** — a killed LibreOffice is a failed import, not a
|
||||
dead service. Measured with no swap and a 300 MB ceiling: the kernel
|
||||
killed LibreOffice, the watcher kept its PID with no restart, and the
|
||||
import was recorded as `failed`, kind `memory`.
|
||||
* **Two attempts, then stop.** Each attempt is counted before it starts.
|
||||
Measured by killing the whole service during attempt 1 and again during
|
||||
attempt 2: it resumed once, then said "giving up … the teacher can try
|
||||
again" and did not start a third.
|
||||
* **The card says so**, and offers **Try again**: *This needed more memory
|
||||
than the computer had free. Close other programs, then choose Try
|
||||
again.* Try again was pressed in the lab and the deck became ready.
|
||||
A file that simply will not convert still says *This file could not be
|
||||
opened* and offers no Try again, because trying again will not help.
|
||||
* **Never stuck.** An import still "getting ready" 20 minutes after it
|
||||
began — longer than any attempt can take — is shown as the same Try
|
||||
again card, because nothing is working on it.
|
||||
* **One import at a time**, by an exclusive lock as well as the one loop;
|
||||
measured, three decks downloaded together converted one after another
|
||||
with never more than one LibreOffice running.
|
||||
|
||||
What the lab cannot give is a real 4 GB machine's mix of programs; the
|
||||
ceilings were emulated per service.
|
||||
|
||||
## The three things kept apart
|
||||
|
||||
@@ -129,7 +188,7 @@ This is the part most worth not breaking.
|
||||
|
||||
| | Changed by | Never changed by |
|
||||
| --- | --- | --- |
|
||||
| **Library thumbnail** | *Use this slide as the picture* in Quick Look | presenting, importing |
|
||||
| **Library cover** | *Use as cover* in Quick Look | presenting, importing |
|
||||
| **Starting slide** | nothing — always slide 1 | choosing a thumbnail |
|
||||
| **Retention** | a slide actually reaching the screen | Quick Look, opening the library, choosing a thumbnail, preview generation, a viewer that failed to start |
|
||||
|
||||
@@ -159,8 +218,9 @@ The cleanup runs hourly in the importer and:
|
||||
directory so a crash cannot make a presentation permanently
|
||||
un-removable;
|
||||
* only ever removes a directory under the library root;
|
||||
* never touches `~/Downloads`, and therefore never a Wonder Ink
|
||||
worksheet;
|
||||
* removes the managed original and all derived assets together;
|
||||
* never touches unrelated files in `~/Downloads`, and therefore never a
|
||||
Wonder Ink worksheet;
|
||||
* tolerates a directory that is already half gone.
|
||||
|
||||
## The presenter
|
||||
@@ -181,11 +241,13 @@ Page Up and Page Down are what the clicker in the cupboard sends.
|
||||
Going past the last slide finishes, rather than showing a black "click to
|
||||
exit" slide.
|
||||
|
||||
**Fullscreen is re-asserted, not assumed.** Measured in the lab: focusing
|
||||
another window on the same workspace dropped the slideshow to half the
|
||||
screen with the library beside it. `window-state-event` puts it back for
|
||||
as long as the presentation is running, and stops the moment Escape is
|
||||
pressed so it can never fight the teacher's way out.
|
||||
**Fullscreen is re-asserted, not assumed.** The presenter requests
|
||||
fullscreen after its Wayland surface is mapped, and its stable `Presenting`
|
||||
title lets a presenter-only KWin rule apply fullscreen and remove the border
|
||||
without affecting the library or Quick Look. `window-state-event` reasserts
|
||||
the state for as long as the presentation is running and stops the moment
|
||||
Escape is pressed. The library is hidden during playback and restored on
|
||||
exit; the Plasma panel itself is never hidden or reconfigured.
|
||||
|
||||
## Fonts
|
||||
|
||||
@@ -218,3 +280,7 @@ classroom-manual --features which manual pages this device shows
|
||||
`--status` prints titles, slide counts, the chosen thumbnail slide, both
|
||||
timers and the expiry date. It is the fastest way to answer "why has this
|
||||
gone" and "why has this not appeared".
|
||||
|
||||
## Later
|
||||
|
||||
The library's next round — a maximisable window, a tile grid, click to advance, a hidden pointer — is in [backlog.md](backlog.md).
|
||||
|
||||
@@ -71,6 +71,50 @@ The boot provisioning command never upgrades or reconfigures anything already in
|
||||
never touches a repository it did not add. Classroom software updates
|
||||
are the OTA channel's job, not this.
|
||||
|
||||
## When a classroom updates
|
||||
|
||||
**Every day at 03:00**, local time, spread over half an hour so the rooms
|
||||
do not all ask at once: `ekklesia-classroom-update.timer`,
|
||||
`OnCalendar=*-*-* 03:00:00`, `RandomizedDelaySec=30m`. It has been so
|
||||
since 0.1.5; the lab's installed stable machine logged its checks at 03:01
|
||||
on 26 September and 03:04 on 27 September.
|
||||
|
||||
**A computer that was switched off at 03:00** checks once, soon after it
|
||||
is next switched on (`Persistent=true`). Measured on the lab's systemd
|
||||
with the machine really stopped across the time: the missed check ran 50 s
|
||||
after it started, once, and not again. Nothing else starts the check —
|
||||
no boot trigger, no repeat — so it cannot loop.
|
||||
|
||||
Each check follows only the device's own `CHANNEL` (a stable room stays
|
||||
on stable), installs only a newer version, and only a package whose
|
||||
SHA-256, name and version are what the manifest says.
|
||||
|
||||
**The reboot after an update waits for nobody to be using the computer**
|
||||
(since 0.2.6.5). It used to happen the moment the update was installed:
|
||||
harmless at 03:00, and a room restarting itself mid-morning after a night
|
||||
switched off. Now `classroom-reboot-when-idle` reboots once no keyboard,
|
||||
mouse, touchpad or touchscreen has been touched for 30 minutes *and* no
|
||||
sound has played for as long — a video to the room is not an idle
|
||||
computer. At 03:00 that is about 03:30. Switched off before then, nothing
|
||||
is lost: the update is installed and the next start is on it. Until the
|
||||
reboot the session carries on as it was; the only other thing an update
|
||||
starts at once, provisioning, does nothing on a machine that is already
|
||||
set up.
|
||||
|
||||
Input is read from the kernel's input devices, whatever the desktop is
|
||||
doing; sound from the kernel's own playback state (`/proc/asound`). Both
|
||||
measured in the lab — a mouse made with `/dev/uinput` kept it from
|
||||
rebooting, and leaving it alone let it — except the sound card, which the
|
||||
lab does not have: that is a check on real hardware.
|
||||
|
||||
The schedule is a file the package installs, not a configuration file,
|
||||
so every upgrade brings the packaged schedule. The release gate checks it
|
||||
after a fresh install and after each upgrade.
|
||||
|
||||
systemctl list-timers ekklesia-classroom-update.timer # next and last check
|
||||
journalctl -u ekklesia-classroom-update # what each check did
|
||||
journalctl -u ekklesia-classroom-reboot-when-idle # a reboot that is waiting
|
||||
|
||||
## Permanent download addresses
|
||||
|
||||
```
|
||||
@@ -110,3 +154,89 @@ curl -fsSL -o c.deb https://updates.riverofglorymn.org/canary/latest.deb
|
||||
sha256sum c.deb
|
||||
curl -fsS https://updates.riverofglorymn.org/canary/manifest.json
|
||||
```
|
||||
|
||||
## Version numbers and releasing
|
||||
|
||||
```
|
||||
0.2.6 a feature release
|
||||
0.2.6.1 the first maintenance release of 0.2.6
|
||||
0.2.6.2 the second
|
||||
0.2.7 the next feature release
|
||||
0.3.0 a larger milestone
|
||||
```
|
||||
|
||||
Three parts for a feature release, four for a maintenance release of one.
|
||||
The leading zero stays until Classroom is feature-complete; 1.0.0 belongs
|
||||
to the wider Ekklesia platform's milestone. Nothing else is a version: no
|
||||
leading zeros (dpkg reads `0.2.06` as `0.2.6`), no `0.2.6.0`, no suffixes.
|
||||
The build, both publish scripts and every classroom's updater refuse
|
||||
anything else, from one pattern held to one list of cases by
|
||||
`tests/test-release-versions.sh`.
|
||||
|
||||
Which of two versions is newer is always `dpkg --compare-versions`, never
|
||||
our own arithmetic, because dpkg is what installs them:
|
||||
`0.2.2 < 0.2.6 < 0.2.6.1 < 0.2.6.2 < 0.2.6.10 < 0.2.7`.
|
||||
|
||||
### A published version is never used again
|
||||
|
||||
0.2.6 was rebuilt with later fixes and uploaded a second time under the
|
||||
same number, replacing the first upload. A room that had installed the
|
||||
first one could never be offered the second: the updater only installs a
|
||||
version newer than its own. So now:
|
||||
|
||||
* `update_canary.sh VERSION` refuses a version that has ever been
|
||||
published — on the server or in `repo/packages` — and one that is not
|
||||
newer than every package ever published. It checks before building and
|
||||
again after the Trixie gate.
|
||||
* Packages are uploaded with `rsync --ignore-existing`, and the checksum on
|
||||
the server is compared with the one built before anything points at it.
|
||||
* A manifest is only written once the server holds exactly the package
|
||||
and checksum it names.
|
||||
* After publishing, the channel is read back over HTTPS the way a classroom
|
||||
reads it: manifest, versioned package, `latest.deb` and the version dpkg
|
||||
reads out of the file must all agree.
|
||||
|
||||
A publish that fails after the local copy into `repo/packages` has spent
|
||||
that number. Use the next one.
|
||||
|
||||
### Canary
|
||||
|
||||
```
|
||||
./update_canary.sh 0.2.6.1 # a maintenance release
|
||||
./update_canary.sh 0.2.7 # the next feature release
|
||||
```
|
||||
|
||||
The version is always given. With four parts there are two possible next
|
||||
numbers, and which one a release is depends on what is in it. Run with no
|
||||
argument it prints the current canary and both candidates, and builds
|
||||
nothing.
|
||||
|
||||
### Stable
|
||||
|
||||
```
|
||||
./update_stable.sh 0.2.6.1
|
||||
```
|
||||
|
||||
Only with the owner's written approval for that release. Stable is never
|
||||
given a package of its own: it is pointed at one canary already published,
|
||||
byte for byte, and refuses a version that is not newer than stable's
|
||||
current one or whose package the server does not hold with the same
|
||||
checksum.
|
||||
|
||||
### Checking a channel without changing it
|
||||
|
||||
```
|
||||
ROOT=$PWD bash -c 'source tools/release-lib.sh; release_verify_channel stable; release_verify_channel canary'
|
||||
```
|
||||
|
||||
### Rehearsing an upgrade
|
||||
|
||||
`tools/validate-trixie-release.sh NEW.deb [PREVIOUS.deb]` installs into a
|
||||
clean Debian 13 container. `VALIDATION_CHANNEL=stable` rehearses a stable
|
||||
room, for example the desktops' real path:
|
||||
|
||||
```
|
||||
VALIDATION_CHANNEL=stable tools/validate-trixie-release.sh \
|
||||
dist/ekklesia-classroom_0.2.6.1_amd64.deb \
|
||||
repo/packages/ekklesia-classroom_0.2.2_amd64.deb
|
||||
```
|
||||
|
||||
@@ -149,6 +149,18 @@ nothing else about the room's locks moves.
|
||||
| `notif-13pt`, `notif-15pt` | the two font sizes that were weighed for it |
|
||||
| `pc-typed-layer` | the card gone: the lab's keyboard tool closes it, which is why typing is still a bench test |
|
||||
|
||||
## Wonder Ink printing
|
||||
|
||||
Under the lab's real Plasma session, driven with the pointer. See
|
||||
[wonder-ink.md](../wonder-ink.md), *Printing*.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `pic-preview-default`, `pic-preview-half-topleft` | a picture in a tab of its own, laid out on Letter: as large as fits, then 50% top left (0.2.6.2) |
|
||||
| `print-image-menu` | right-click on a picture inside a lesson: **Print image…** and **Print page…**; on the words there is only Print page (0.2.6.3) |
|
||||
| `print-image-bw-half-topleft` | Print image on a signed-in picture, 50% top left, Black and white — the preview grey, as the job CUPS kept was (0.2.6.3) |
|
||||
| `workspace-restored` | Wonder Ink reopened after an accidental close: the lesson's WonderInk tab at its sign-in page, its slide in front, its PDF behind (0.2.6.4, the real site) |
|
||||
|
||||
## Failures, kept
|
||||
|
||||
| | |
|
||||
|
||||
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 719 KiB |
|
After Width: | Height: | Size: 726 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
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: 128 KiB |
|
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: 535 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 906 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 281 KiB |
@@ -11,6 +11,39 @@ before promoting to stable with `./update_stable.sh`.
|
||||
- [ ] Dock appears, centred
|
||||
- [ ] Clock appears, bottom right
|
||||
|
||||
### The television switched on after a night-time reboot
|
||||
|
||||
This is how Tiny Pebbles lost its wallpaper after the 0.2.2 upgrade, and
|
||||
the lab cannot do it: KWin will not start a session with no screen, and
|
||||
the lab cannot plug one in. The lab proves the same code path through a
|
||||
new activity (`tools/lab/wallpaper-check`); only a real television
|
||||
proves that switching it on takes that path. See
|
||||
[ui-scale.md](ui-scale.md#a-desktop-the-layout-never-saw-0266). Do it once, on a
|
||||
desktop whose television is switched off at the set, not the remote:
|
||||
|
||||
1. [ ] Television **off** at the set. `sudo reboot`, and wait five minutes.
|
||||
2. [ ] Television **on**. The classroom's landscape is on the screen,
|
||||
**not** Plasma's purple wallpaper. The panel is the room's own.
|
||||
3. [ ] From the maintenance terminal (Meta+Ctrl+Alt+Shift+K),
|
||||
`classroom-desktop-wallpaper --check`. Note what it says. If it
|
||||
names a desktop that "had no wallpaper configured", the television
|
||||
really did arrive after the shell, and the screen in step 2 is the
|
||||
fix working
|
||||
4. [ ] `systemctl --user restart plasma-plasmashell.service`: the panel
|
||||
blinks and comes back, open windows stay, the wallpaper is unchanged
|
||||
5. [ ] `classroom-desktop-wallpaper --check` now says "the classroom
|
||||
wallpaper already"
|
||||
6. [ ] Reboot with the television on: the same picture and the same panel
|
||||
|
||||
Tiny Pebbles can confirm the cause **before** it takes the release, because
|
||||
the update reboots it and a login rebuilds the file. If its panel was built
|
||||
with no screen, the panel is `[Containments][1]` next to the desktop's 10:
|
||||
|
||||
```
|
||||
grep -A6 '^\[Containments\]\[[0-9]*\]$' \
|
||||
~/.config/plasma-org.kde.plasma.desktop-appletsrc | grep -E '^\[|^plugin='
|
||||
```
|
||||
|
||||
## Provisioning
|
||||
|
||||
- [ ] `journalctl -u ekklesia-classroom-provision` shows a clean run
|
||||
@@ -466,7 +499,15 @@ With `LIVE_ENABLED=true`:
|
||||
- [ ] `./update_canary.sh` publishes package first, manifest last
|
||||
- [ ] The endpoint on the canary channel picks the update up
|
||||
- [ ] SHA-256 verification passes
|
||||
- [ ] Install succeeds and the endpoint reboots
|
||||
- [ ] Install succeeds; `systemctl list-timers ekklesia-classroom-update.timer`
|
||||
shows the next check at about 03:00 tomorrow
|
||||
- [ ] (0.2.6.5) Switched on in the morning after a night off, with an update
|
||||
waiting: it installs, `journalctl -u ekklesia-classroom-reboot-when-idle`
|
||||
says it is waiting, and it does **not** restart while you use it
|
||||
- [ ] Play a video with sound and leave the mouse alone for 40 minutes: no
|
||||
restart while it plays; half an hour after it stops, it restarts
|
||||
- [ ] Left untouched with nothing playing, it restarts about 30 minutes after
|
||||
the update, and comes back on the new version
|
||||
- [ ] Everything above still works after the update
|
||||
- [ ] Upgrade from 0.1.36 preserves every existing `device.conf` value
|
||||
- [ ] `LIVE_ENABLED=false` is appended exactly once on upgrade and remains
|
||||
@@ -497,21 +538,25 @@ sudo reboot
|
||||
- [ ] It does **not** appear in the library while it is still downloading
|
||||
- [ ] It appears by itself within a few seconds of the download finishing
|
||||
- [ ] The title is readable — not a hash, not a long filename
|
||||
- [ ] The original download is still in Chrome's downloads, untouched
|
||||
- [ ] The completed original moved out of Downloads into the managed library;
|
||||
there is no duplicate
|
||||
- [ ] Download the **PDF and the PPTX** of the same session: two cards,
|
||||
the second titled `(2)`, neither overwriting the other
|
||||
|
||||
### Quick Look
|
||||
|
||||
- [ ] **Quick Look** opens and shows slide 1
|
||||
- [ ] **Quick Look** opens on the currently selected cover slide
|
||||
- [ ] **Next** and **Previous** move through every slide
|
||||
- [ ] The text and layout are right — check a slide with a Bible verse
|
||||
- [ ] Go to the session-number slide, usually slide 2
|
||||
- [ ] **Use this slide as the picture** changes the card
|
||||
- [ ] **Use as cover** changes the card and shows **Current cover**
|
||||
- [ ] **Back to Presentations** returns without presenting
|
||||
- [ ] Close the library, open it again: the chosen picture is still there
|
||||
- [ ] Reboot, open it again: still there
|
||||
- [ ] `classroom-presentation-import --status` shows `presented never`
|
||||
- [ ] At 1920×1080 and 1366×768, scale 1.0 and the larger supported scale,
|
||||
the title, slide navigation, cover action, Back and Present all remain
|
||||
above the panel without manually resizing
|
||||
|
||||
That last line is the one that matters. Quick Look, browsing every slide
|
||||
and changing the picture must not have counted as a presentation.
|
||||
@@ -519,6 +564,7 @@ and changing the picture must not have counted as a presentation.
|
||||
### Present
|
||||
|
||||
- [ ] **Present** from the card fills the **external television**
|
||||
- [ ] No panel, title bar, border, desktop or other application is visible
|
||||
- [ ] It starts at slide **1**, not at the slide chosen for the picture
|
||||
- [ ] **Present** from inside Quick Look does the same
|
||||
- [ ] Space, the arrow keys and Page Up/Page Down all move slides
|
||||
@@ -546,21 +592,17 @@ Use a **test copy**. Not tomorrow's session.
|
||||
|
||||
- [ ] **Remove** asks first
|
||||
- [ ] The card goes, and its slides and pictures go with it
|
||||
- [ ] The original download in Chrome is untouched
|
||||
- [ ] Its managed original goes too; unrelated downloads and another similarly
|
||||
named presentation remain
|
||||
- [ ] It does not come back on the next scan or after a reboot
|
||||
|
||||
### The manual
|
||||
### Help is temporarily hidden
|
||||
|
||||
- [ ] **Help** is on the bar, bottom left
|
||||
- [ ] It opens with no address bar and no tabs
|
||||
- [ ] It shows Browser, Presentations, sound and the common pages
|
||||
- [ ] It does **not** mention Wonder Ink, Parent Call or the DVD
|
||||
- [ ] It works with the network unplugged
|
||||
- [ ] On a children's classroom machine it shows the opposite set
|
||||
- [ ] Print `/usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf`
|
||||
and check the conditional wording reads sensibly in a room that
|
||||
does not have every feature
|
||||
- [ ] The revision date is on the printed copy
|
||||
- [ ] No **Help** launcher is visible on the teacher-facing bar
|
||||
- [ ] The manual sources, generated HTML/PDF, launcher and feature-aware build
|
||||
pipeline remain installed for the later documentation correction pass
|
||||
- [ ] Presentations and Wonder Ink title bars show only Close, with no
|
||||
double-chevron keep-above control
|
||||
|
||||
### Wi-Fi — look, do not touch
|
||||
|
||||
@@ -573,3 +615,191 @@ ifupdown stanza is already gone. **Do not run `--adopt` on it.**
|
||||
- [ ] `nmcli -g 802-11-wireless.hidden connection show "ROGC Classroom"`
|
||||
still reports `yes`
|
||||
- [ ] The laptop still reconnects after a reboot
|
||||
|
||||
## Wonder Ink — 0.2.6.1 to 0.2.6.4, before any goes to stable
|
||||
|
||||
Everything below was done in the lab except what a lab cannot do: paper,
|
||||
a classroom's own graphics, a real keyboard, its network and its screen.
|
||||
Those are these checks. Stable stays on 0.2.2 until they pass.
|
||||
|
||||
Before starting, on each machine: `dpkg-query -W ekklesia-classroom`
|
||||
must say `0.2.6.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:
|
||||
|
||||
```
|
||||
curl -fsSL -o c.deb https://updates.riverofglorymn.org/canary/latest.deb
|
||||
sha256sum c.deb # must match .../canary/manifest.json
|
||||
sudo apt-get install -y ./c.deb && sudo reboot
|
||||
```
|
||||
|
||||
This does **not** move the machine to canary (see provisioning.md): its
|
||||
next automatic update follows stable again, which is still 0.2.2 — so
|
||||
after testing, either promote or reinstall 0.2.2 the same way.
|
||||
|
||||
### Little Waders — printing through Elmer
|
||||
|
||||
- [ ] Open Wonder Ink, sign in, open a lesson with a worksheet
|
||||
- [ ] Click the worksheet; it opens as a tab named after the file, with a
|
||||
document icon
|
||||
- [ ] Ctrl+P (or right-click → Print page…): the preview shows the worksheet's
|
||||
own pages, page count right, ‹ › turn them
|
||||
- [ ] Printer shows **Elmer**, preselected
|
||||
- [ ] Print → a notification "Sent to the printer — … — Elmer", Wonder Ink
|
||||
stays open on the lesson
|
||||
- [ ] **The worksheet comes out of Elmer**, every page, right size, in colour
|
||||
- [ ] Pages `2`, Copies `2`, Black and white → the preview turns grey,
|
||||
and exactly two grey copies of page 2 come out
|
||||
- [ ] Print a normal lesson page the same way; it comes out, Letter
|
||||
- [ ] Open the preview and press Cancel → nothing prints
|
||||
- [ ] Print three times in a row → three printouts, no crash
|
||||
- [ ] `lpstat -W completed -o` shows each job; `journalctl --user -b | grep
|
||||
"printed '"` shows Wonder Ink's line for each
|
||||
|
||||
### Pictures (0.2.6.2)
|
||||
|
||||
- [ ] Open a colouring page that is a picture on its own (a PNG or JPEG
|
||||
tab, not a PDF) and press Print: the preview shows it on white — **no
|
||||
black bars** — with a **Picture** section under Color
|
||||
- [ ] Size 50%, the top-left square: the preview shows it there, the
|
||||
rest of the page blank. Print it: **the paper matches the preview**
|
||||
- [ ] Paper *Sideways*, then *Upright*: the preview turns; print one
|
||||
- [ ] *Fill the whole page*: the preview shows the edges cut off; Cancel
|
||||
prints nothing
|
||||
- [ ] A worksheet PDF still prints with no Picture section
|
||||
- [ ] The print dialog says **Color**, not Colour
|
||||
|
||||
### Black and white, and Print image (0.2.6.3)
|
||||
|
||||
The lab measured all of this on the files CUPS kept, and under the real
|
||||
Plasma session for the clipboard. What it cannot show is Wonder Ink's own
|
||||
site (no account), real paper, and a classroom's own keyboard and mouse.
|
||||
|
||||
- [ ] A lesson page with colour on it: Ctrl+P, then **Black and white** →
|
||||
the preview says *Preparing black and white…* for a moment, then the
|
||||
page is **grey**. **Color** → colour again. Back and forth three
|
||||
times: it is instant after the first, and stays on the same page
|
||||
- [ ] Print it in black and white: **the paper matches the grey preview**
|
||||
- [ ] The same with a worksheet PDF (a few pages), in black and white
|
||||
- [ ] Choose Black and white, then Color, then Print → it comes out **in
|
||||
colour**
|
||||
- [ ] In a lesson, **right-click on a picture** (not the words): the menu
|
||||
has **Print image…** and **Print page…**. Right-click on the words:
|
||||
only **Print page…**
|
||||
- [ ] **Print image…** on a picture you cannot open in a tab by itself:
|
||||
the preview shows *that picture* on white, with the **Picture**
|
||||
section — not the lesson page
|
||||
- [ ] Size **50%**, the **top-left** square, Black and white → the preview
|
||||
shows a grey picture in the top-left quarter. Print it: **the paper
|
||||
matches the preview**
|
||||
- [ ] Print image on a picture with a transparent background (a clipart
|
||||
animal, a logo) → white around it on paper, not black
|
||||
- [ ] Print image, then **Cancel** → nothing prints
|
||||
- [ ] Copy some words in the browser, then use Print image in Wonder
|
||||
Ink, then paste in the browser → the words paste, not the picture
|
||||
- [ ] If any picture says *could not be taken from the page*: note which
|
||||
lesson and which picture. Right-click the same picture again once
|
||||
the page has finished loading. If it still says so, that picture is
|
||||
drawn in a way the engine does not treat as a picture — tell us
|
||||
where it is
|
||||
- [ ] If right-clicking an obvious picture offers **only** Print page, the
|
||||
site draws it as a background. Note which lesson — it is the one
|
||||
case Print image cannot reach
|
||||
|
||||
### Size and zoom
|
||||
|
||||
- [ ] A lesson in Wonder Ink is the same size as the same page in the
|
||||
browser on that machine (it was smaller in 0.2.2)
|
||||
- [ ] **Ctrl and +** (the real keyboard), **Ctrl and −**, **Ctrl and 0**
|
||||
change it; a "Lesson size" badge appears
|
||||
- [ ] Open a second tab: it is the same size; the − 100% + and Print
|
||||
buttons are at the top right
|
||||
- [ ] Close Wonder Ink and open it again: the size is remembered
|
||||
- [ ] A worksheet tab's own − / + (in the PDF toolbar) zoom it
|
||||
- [ ] A lesson video still goes fullscreen and comes back with the tabs
|
||||
|
||||
### Loading, and the machine's graphics
|
||||
|
||||
From the maintenance terminal (Meta+Ctrl+Alt+Shift+K), in the session:
|
||||
|
||||
```
|
||||
classroom-wonderink --diagnose
|
||||
```
|
||||
|
||||
- [ ] Note the **GPU line**. A driver's name is right. "none … drawing in
|
||||
software" means QtWebEngine has no GPU on this machine — compare
|
||||
`chrome://gpu` in the browser; if Chrome has one, that is the cause of
|
||||
slow lessons and is the next thing to fix
|
||||
- [ ] Note the sign-in time (the lab: about 2.9 s wired). Much slower here
|
||||
with a driver named above points at the network
|
||||
- [ ] `free -m`: Wonder Ink needs about 610 MB plus 115 MB per extra tab
|
||||
|
||||
### Temporary workspace restore (0.2.6.4) — Little Waders
|
||||
|
||||
Use a **Viewer** account. An Admin sees lessons in WonderInk's editor,
|
||||
where a click on a link shows its Edit/Unlink bubble — WonderInk's design,
|
||||
not Wonder Ink's; Ctrl+click opens it. See wonder-ink.md, *Links in a
|
||||
lesson*.
|
||||
|
||||
1. [ ] Sign in to the real WonderInk site
|
||||
2. [ ] Open three lesson/resource tabs: the lesson, and two of its links —
|
||||
for example *Saul Welcome Question Slide* and a video or worksheet
|
||||
3. [ ] Click the **middle** tab
|
||||
4. [ ] Close Wonder Ink (the × at the top right)
|
||||
5. [ ] Open it again straight away from the panel
|
||||
6. [ ] The WonderInk tab asks you to **sign in** — sign in normally (your
|
||||
password is never kept, so it always will)
|
||||
7. [ ] The **recent tabs are back**: the slide and the other resource
|
||||
8. [ ] **In the same order** as before
|
||||
9. [ ] With the **middle tab in front**
|
||||
10. [ ] Open a worksheet from a lesson, then close and reopen Wonder Ink:
|
||||
the worksheet tab comes back, named after the file
|
||||
11. [ ] Print from a restored tab (the slide or the worksheet) through
|
||||
Elmer: **the paper matches the preview**
|
||||
12. [ ] Links still open with one click in a new tab, as before (Viewer)
|
||||
13. [ ] With the tabs open, **reboot** the computer; within two hours of
|
||||
using Wonder Ink, open it again
|
||||
14. [ ] **The recent tabs are back** (after signing in on the WonderInk tab)
|
||||
15. [ ] **Expiry, without waiting two hours.** Close Wonder Ink. Open the
|
||||
maintenance terminal (Meta+Ctrl+Alt+Shift+K) and run
|
||||
`CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS=120 classroom-wonderink`.
|
||||
This is **test-only**: it lasts as long as that one Wonder Ink,
|
||||
can only shorten the time, and nothing in the classroom sets it
|
||||
16. [ ] Open a couple of tabs in it, click in one, then touch nothing for
|
||||
**three minutes** and close it
|
||||
17. [ ] Open Wonder Ink from the panel: it **starts fresh**, one tab on the
|
||||
WonderInk start page, none of the old tabs
|
||||
|
||||
### Presentations under memory pressure (0.2.6.2, the laptop)
|
||||
|
||||
- [ ] Download a large, photo-heavy deck with Wonder Ink and Chrome open:
|
||||
it either becomes ready, or its card says *This needed more memory …
|
||||
choose Try again* — never *Getting this ready…* for more than a few
|
||||
minutes
|
||||
- [ ] Close the other programs and choose **Try again**: it becomes ready
|
||||
- [ ] `systemctl --user status ekklesia-classroom-presentations` is
|
||||
active, and `journalctl --user -u ekklesia-classroom-presentations`
|
||||
shows no restart loop
|
||||
|
||||
### Settings the teacher must not reach (0.2.6.2)
|
||||
|
||||
- [ ] When any notification shows the sliders (Configure) button, clicking
|
||||
it opens only *The module … is disabled*; its Help button does
|
||||
nothing
|
||||
- [ ] Right-click the clock: no *Configure Digital Clock…*; *Adjust Date
|
||||
and Time…* opens an empty System Settings
|
||||
- [ ] Right-click the volume: no *Configure Audio Devices…*
|
||||
- [ ] The maintenance chord (Meta+Ctrl+Alt+Shift+K) still opens a
|
||||
terminal, and `systemsettings` started from it is complete
|
||||
|
||||
### After a reboot, and the other rooms
|
||||
|
||||
- [ ] Reboot Little Waders: it comes up as the classroom, Wonder Ink opens
|
||||
- [ ] On one other desktop, 0.2.6.1 installed: the browser, the DVD player
|
||||
where there is a drive, Parent Call where enabled, sound through the
|
||||
television, the panel order — all as on 0.2.2
|
||||
- [ ] **Presentations is not on the panel** of any desktop
|
||||
(`grep PRESENTATIONS_ENABLED /etc/ekklesia-classroom/device.conf`
|
||||
says `false`)
|
||||
|
||||
If anything here fails, leave the desktops on stable 0.2.2 and fix it in
|
||||
the next maintenance release on canary.
|
||||
|
||||
@@ -47,6 +47,69 @@ package installs it at `/usr/share/backgrounds/ekklesia/classroom-calm.png`, the
|
||||
path used by the packaged Plasma layout. The layout is rebuilt at login, so
|
||||
upgraded classrooms receive the package wallpaper without per-room copying.
|
||||
|
||||
### A desktop the layout never saw (0.2.6.6)
|
||||
|
||||
Tiny Pebbles came through the 0.2.2 → 0.2.6.4 upgrade showing Plasma's own
|
||||
purple wallpaper. Its desktop was containment 10 with no `Wallpaper` group;
|
||||
River Kids' is containment 1 with one. The package and the picture were the
|
||||
same on both.
|
||||
|
||||
The layout sets the wallpaper on the desktops that exist *while it runs*.
|
||||
Plasma makes a desktop for every screen or activity that appears later, and
|
||||
it makes those without the layout. 0.2.2's updater rebooted as soon as it
|
||||
had installed, at 03:00–03:30. A television switched off at the set is
|
||||
usually no screen at all, so the layout would have run with no desktop, and
|
||||
the desktop Plasma made when the television came on was bare. A panel built
|
||||
with no screen takes number 1 and its eight applets 2–9, which is exactly
|
||||
how a desktop ends up as 10. **The television is inferred, not observed**.
|
||||
See the hardware check in [testing.md](testing.md#the-television-switched-on-after-a-night-time-reboot).
|
||||
|
||||
Measured in the lab (Plasma 6.3.6, Debian 13):
|
||||
|
||||
| What was tried | What happened |
|
||||
| --- | --- |
|
||||
| Activity manager delayed 20 s at login | Plasma waited for it; desktop 1, wallpaper right. Not the cause |
|
||||
| A new activity created mid-session | Plasma made desktop 11 with no `Wallpaper` group, the same shape as Tiny Pebbles' 10. The screen showed Plasma's *Next*; the panel was untouched |
|
||||
| Look-and-feel default `Image=` set to a **file path** (0.2.2–0.2.6.5) | Ignored. `DefaultWallpaper` in libkworkspace6 looks the value up as a wallpaper **package** under `wallpapers/<name>`, the way Breeze says `Image=Next` |
|
||||
| The same default set to a **package name**, whose one image links to the file | Desktop 11, still with no `Image`, showed the classroom's picture after a shell restart |
|
||||
| Setting it live over D-Bus (`evaluateScript`, `plasma-apply-wallpaperimage`) | Refused: "Widgets are locked". The classroom's lockdown forbids the scripting console, and it stays forbidden |
|
||||
| Disabling the only screen to reproduce a screenless start | KWin refuses ("Disabling all outputs … is not allowed"), and the nested lab cannot hot-plug a screen |
|
||||
|
||||
So there are two parts, and they do different jobs:
|
||||
|
||||
* **The default is now a package**, `/usr/share/wallpapers/EkklesiaClassroom`.
|
||||
Its image is a link to the file above, and the look and feel names it.
|
||||
Any desktop Plasma makes without the layout shows the classroom's picture
|
||||
with nothing having to run. This is the fix for Tiny Pebbles.
|
||||
* **`classroom-desktop-wallpaper`** writes `wallpaperplugin=org.kde.image` and
|
||||
the classroom `Image` into every desktop containment already in the file,
|
||||
so the configuration says what the screen shows. It finds desktops by
|
||||
plugin (`org.kde.plasma.folder`, `org.kde.desktopcontainment`), never by
|
||||
number. It changes nothing else, and it does not write a file that is
|
||||
already right. It runs as plasmashell's `ExecStartPre`, the one moment
|
||||
the shell does not own the file: every login, every reboot, every restart
|
||||
after a crash. At a login it usually finds nothing, because
|
||||
`classroom-desktop-reset` has just removed the file for the layout to
|
||||
rebuild.
|
||||
|
||||
To apply it in a running session without logging out, from the maintenance
|
||||
terminal:
|
||||
|
||||
```
|
||||
classroom-desktop-wallpaper --check # read-only
|
||||
systemctl --user restart plasma-plasmashell.service # the panel blinks, windows stay
|
||||
```
|
||||
|
||||
Run without `--check` while the shell is up, it refuses and prints that
|
||||
second line rather than edit the file underneath the shell.
|
||||
|
||||
Tests: `tests/test-classroom-desktop-wallpaper.sh` (in the build) covers
|
||||
Tiny Pebbles' own file, a healthy River Kids file, differing numbers,
|
||||
idempotence and the guard. `tools/lab/wallpaper-check` covers real Plasma,
|
||||
reading the colour off the screen, and runs inside `tools/lab/dirty-upgrade`.
|
||||
`tools/validate-trixie-release.sh` covers the installed package when
|
||||
upgrading from a previous release.
|
||||
|
||||
## What the one number reaches
|
||||
|
||||
`classroom-ui-scale` prints it, clamped, and everything that cannot read
|
||||
@@ -55,10 +118,15 @@ the display scale for itself asks that command rather than the file:
|
||||
* **Plasma** — the panel, the clock, notifications, the volume display and
|
||||
every dialog, because `classroom-scale` sets it as the display's scale
|
||||
through `kscreen-doctor` at every login.
|
||||
* **Chrome and Wonder Ink** — `--force-device-scale-factor`, because
|
||||
Chrome on Wayland was measured not to follow the compositor's output
|
||||
scale on its own. It is the same number, not a second scaling: Chrome
|
||||
replaces its detected scale with it.
|
||||
* **Chrome** — `--force-device-scale-factor`, because Chrome on the sway
|
||||
session was measured not to follow the compositor's output scale on its
|
||||
own. **Under Plasma it does**, so this is now a second scaling: measured
|
||||
at 150%, Chrome's pages report `devicePixelRatio` 2.25, not 1.5. Left as
|
||||
it is in 0.2.6.1 because it is the size teachers know; see
|
||||
[wonder-ink.md](wonder-ink.md#how-large-a-lesson-is).
|
||||
* **Wonder Ink** — its window follows the compositor as every Qt
|
||||
application does, and its *pages* start at a zoom of `UI_SCALE`, so a
|
||||
lesson is the size the browser shows it.
|
||||
* **The disc player's own surfaces** — they are measured in the logical
|
||||
pixels the compositor is already scaling, so they follow without being
|
||||
told.
|
||||
|
||||
@@ -71,7 +71,51 @@ wrong a teacher is standing in front of a class.
|
||||
The right-click menu is built rather than filtered, so a future Qt cannot
|
||||
grow view-source or save-page back into it without somebody noticing in
|
||||
the code: back, forward, reload, open in a new tab when there is a link,
|
||||
cut/copy/paste in a text field, print.
|
||||
cut/copy/paste in a text field, print image when there is a picture under
|
||||
the pointer, print page.
|
||||
|
||||
### Links in a lesson (0.2.6.4)
|
||||
|
||||
A link in a lesson opens with one click in a new tab, and Wonder Ink shows
|
||||
that tab; the lesson stays in its own. That is what the site asks for —
|
||||
every link in a lesson's content carries `target="_blank"` — and what the
|
||||
engine does with it (`createWindow`, answered with a tab that takes the
|
||||
focus). Measured on the real site, on the real *Rise and Fall of Saul*
|
||||
lesson: "Saul Welcome Question Slide" is
|
||||
`<a target="_blank" href="…/SaulWelcomeQuestionSlide.png">`, and one click
|
||||
opened the slide in a new tab, at 100%, at 200% and in Full Screen.
|
||||
|
||||
**The URL bubble teachers saw is WonderInk's editor, shown to admins.**
|
||||
Testing 0.2.6.3 found that clicking such a link showed a small bubble with
|
||||
the address and Edit / Remove instead of following it. Found by reading
|
||||
the site's code and confirmed with the account roles:
|
||||
|
||||
* the site shows lesson text in **CKEditor 5** (35.2.1, its
|
||||
`RTE_USE_CKEDITOR_PRO` component). For a **Viewer** the content is
|
||||
read-only and a link is a link; for an **Admin** it is editable, in
|
||||
place;
|
||||
* in an editable CKEditor 5, a plain click on a link is an editing click
|
||||
by design (`LinkEditing._enableLinkOpen` opens a link only with Ctrl, ⌘
|
||||
on a Mac); the editor answers it with its link balloon — the address,
|
||||
*Edit link*, *Unlink*. It is the same code in Chrome;
|
||||
* moving the teacher's account to Viewer made the bubble go away.
|
||||
|
||||
So Wonder Ink does not override it. Doing so would mean catching clicks
|
||||
inside WonderInk's editor, which is how admins edit lessons, to fix
|
||||
something the account's role already decides. An admin who wants to
|
||||
follow a link straight away: **Ctrl+click** it.
|
||||
|
||||
What each kind of link does, as `tools/lab/wonderink-print-check links:*`
|
||||
clicks them on a signed-in lesson page:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| a text link, a linked picture, another site's link (`target="_blank"`) | a new tab, shown |
|
||||
| a worksheet PDF | a new tab in the PDF viewer; Print fetches it and prints it as a worksheet |
|
||||
| a picture | a new tab; Print lays it out as a picture, and right-click offers Print image |
|
||||
| a lesson button that opens a window | a new tab, shown |
|
||||
| a lesson button, an in-page anchor (`#…`) | what the page does; no tab |
|
||||
| a link with no target | followed in the same tab, as in a browser; Back returns |
|
||||
|
||||
Keys: Ctrl+W closes a tab, Alt+Left and Alt+Right navigate, Ctrl+P prints,
|
||||
Ctrl+Tab switches. Ctrl+T, Ctrl+N and Ctrl+L are deliberately unbound.
|
||||
@@ -140,9 +184,8 @@ Alongside it, two Qt defaults are turned off rather than left implicit:
|
||||
`LocalContentCanAccessFileUrls` (on by default, and would let a local
|
||||
document read other local documents) and `LocalContentCanAccessRemoteUrls`.
|
||||
|
||||
The print dialog's **Print to File** option is also removed. Choosing it
|
||||
enables an output path with a "..." button, which is a file chooser, in a
|
||||
room whose whole design is that there is no file chooser.
|
||||
Printing has no **Print to File** and no file chooser of any kind: the
|
||||
preview (below) offers the room's CUPS printers and nothing else.
|
||||
|
||||
### What a teacher still cannot reach
|
||||
|
||||
@@ -182,30 +225,518 @@ is Chrome's business and stays that way.
|
||||
Downloads folder. That is partly tidiness and mostly the security
|
||||
argument: it is the only place a worksheet can come from.
|
||||
|
||||
## Printing
|
||||
|
||||
### The crash (0.2.2, Little Waders)
|
||||
|
||||
Wonder Ink closed completely every time a teacher confirmed a print job:
|
||||
`SIGSEGV in QPrinter::resolution()`, twice, both through QtWebEngine's
|
||||
print-completion path.
|
||||
|
||||
`QWebEngineView.print(printer)` is asynchronous. In Qt 6.8.2
|
||||
(`qwebengineview.cpp`) it stores the raw `QPrinter*` and returns; when
|
||||
Chromium has rendered the page, `didPrintPage()` calls
|
||||
`currentPrinter->resolution()` and hands the printer to a worker thread.
|
||||
0.2.2 made the printer a local variable in `print_view()`, so PyQt deleted
|
||||
it when the function returned and Qt read freed memory. Reproduced in the
|
||||
lab with the 0.2.2 code: `QPrinter::resolution()` ←
|
||||
`QWebEnginePagePrivate::didPrintPage` ← `WebContentsAdapter::didPrintPage`,
|
||||
the Little Waders backtrace, and no job in CUPS.
|
||||
|
||||
Keeping the printer alive until `printFinished` stops the crash — that was
|
||||
built and measured first — and it is still not a working print path:
|
||||
|
||||
| Measured, QtWebEngine 6.8.2, lab queue built from Elmer's PPD | Qt `view.print()` | `printToPdf()` + `lp` |
|
||||
| --- | --- | --- |
|
||||
| A three-page lesson | 24.6 MB of 300 dpi pictures, no text, A4 on a test queue | 18 KB vector PDF with its text, on Letter, 0.43 s through Elmer's filters |
|
||||
| A worksheet PDF tab | **one blank page** | the worksheet file itself |
|
||||
| A PDF a site shows inline | — | `printToPdf` never answers (`CreatePreviewDocument got 0 page count`) — so it is downloaded and printed as a file |
|
||||
| A queue refusing jobs | reported as **printed** | "Refusing did not take the job: Destination … is not accepting jobs." |
|
||||
| Black and white on Elmer | — | IPP `print-color-mode` is **ignored** by the generic PCL 6 driver ("Unknown option", prints colour); `ColorModel=Grayscale` gives `pxlmono` |
|
||||
|
||||
Qt's path also mishandles three things its own source makes plain: a
|
||||
second `print()` on a page that is still printing is refused with no
|
||||
`printFinished`, the worker keeps the printer after the tab has gone, and
|
||||
quitting destroys the printer under that worker.
|
||||
|
||||
So 0.2.6.1 does not use Qt printing at all. **Print** makes a PDF — a
|
||||
worksheet already is one; a lesson page is rendered by the engine onto the
|
||||
printer's own paper with Chrome-like 10 mm margins — shows it, and hands it
|
||||
to CUPS with `lp`, which is how Chrome prints in the same rooms. Nothing
|
||||
asynchronous ever holds a printer, so closing a tab or Wonder Ink cannot
|
||||
break a job: once `lp` returns, the job is CUPS's.
|
||||
|
||||
### The preview
|
||||
|
||||
One dialog, for lessons and worksheets alike:
|
||||
|
||||
* the document as it will print, a whole page at a time, ‹ › to turn;
|
||||
* **Printer** — the CUPS destinations, the system default preselected
|
||||
(Elmer, in every room);
|
||||
* **Pages** — all, or some: `2`, `1-3`, `1, 3, 5-6`, checked against the
|
||||
document with the reason given if wrong;
|
||||
* **Copies** — 1 to 50;
|
||||
* **Color** — colour or black and white, only where the printer's driver
|
||||
has a grey mode, and asked for in that driver's own terms. Since 0.2.6.3
|
||||
the preview turns grey too (below);
|
||||
* **Cancel** sends nothing; **Print** returns to the lesson once CUPS has
|
||||
the job, and a notification says where it went. A refused job keeps the
|
||||
preview open with CUPS's reason.
|
||||
|
||||
Choosing a printer with different paper lays a lesson page out again. A
|
||||
page that closes its own tab while the preview is open is reported, not
|
||||
printed. Print pressed again while a preview is open does nothing, and so
|
||||
does a page calling `window.print()` meanwhile. The PDF viewer's own print
|
||||
button opens the same preview.
|
||||
|
||||
### Pictures (0.2.6.2)
|
||||
|
||||
A picture a lesson opens in a tab of its own — a colouring page as a PNG
|
||||
or JPEG — printed in 0.2.6.1 with **black bars** above and below it.
|
||||
Measured: the bars were neither in the picture nor in the page layout.
|
||||
The engine shows a lone picture on a viewer page whose background is
|
||||
rgb(14, 14, 14), scaled to the window, and Qt prints page backgrounds by
|
||||
default; the dark page went onto the paper around the artwork.
|
||||
|
||||
So a picture is no longer printed as that page. The preview takes the
|
||||
picture itself — drawn onto white, which is what transparent parts are on
|
||||
paper, at up to 3300 pixels (300 dpi across Letter) — and lays it out on
|
||||
the printer's own paper, and the teacher chooses:
|
||||
|
||||
* **Size** — as large as fits, 75%, 50%, 33% or 25% of that;
|
||||
* **Position** — nine squares, like the page itself;
|
||||
* **Paper** — automatic (landscape for a wide picture), upright or
|
||||
sideways;
|
||||
* **Fill the whole page** — covers the printable area and says it cuts the
|
||||
edges off. Nothing is cropped unless this is ticked.
|
||||
|
||||
The preview is the PDF that will print, laid out again after every change.
|
||||
`tools/lab/wonderink-print-check` measures it on the file CUPS actually
|
||||
kept (`PreserveJobFiles`), which is byte-for-byte the preview's: the
|
||||
picture within 1 point of where it was asked for, the paper turned as
|
||||
asked, and no ink anywhere else on the page. Worksheet PDFs and lesson
|
||||
pages have no picture controls; nothing about them changed.
|
||||
|
||||
Page backgrounds are also no longer printed for lesson pages, as in
|
||||
Chrome, where "Background graphics" is off unless asked for. A white-on-
|
||||
black page prints dark grey on white: Blink darkens light text when the
|
||||
background is left off, measured.
|
||||
|
||||
Temporary PDFs live in the teacher's `$XDG_RUNTIME_DIR` and are removed
|
||||
when the preview closes.
|
||||
|
||||
### Black and white is shown as it prints (0.2.6.3)
|
||||
|
||||
Printing through Elmer found that **Black and white** came out grey on
|
||||
paper while the preview stayed in colour. 0.2.6.2 only told the driver:
|
||||
the preview was the colour PDF, CUPS was given the colour PDF, and
|
||||
`ColorModel=Grayscale` turned it grey inside Elmer's filters.
|
||||
|
||||
So black and white is now made before the preview, not after it. When it
|
||||
is chosen, the preview makes a grey copy of the document with
|
||||
Ghostscript, shows that copy, and Print hands CUPS that same file — still
|
||||
with `ColorModel=Grayscale`, so Elmer's generic PCL 6 driver prints it
|
||||
with `pxlmono` as before rather than as a colour job that happens to be
|
||||
grey. Choosing **Color** shows and prints the colour document again.
|
||||
|
||||
Measured before building it (Ghostscript 10.05.1, in the lab):
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| a lesson page, a picture page, a leader guide, a fillable form | grey and only grey; same pages, page sizes and words; text still text; a filled-in form field and a drawn mark kept, in grey |
|
||||
| against what Elmer would have printed | Ghostscript's grey rendering of the colour original and of the grey copy give the **same greys**: within 1 level in 255 on every filled area and picture. Small text can land a fraction of a pixel over — on a real lesson page, 12 of 32,754 8×8 blocks, all on one line of body text, in pairs of opposite sign. Elmer's driver *is* Ghostscript. (poppler's own grey differs on 7% of a coloured page, which is why the copy is not made with poppler) |
|
||||
| how long | 0.05 s for a worksheet, 0.2 s for a text lesson page, 1.3 s for a full-page photograph, 2 s for 42 pages — and **4.4 s** for one lesson page with eleven full-size pictures, under the real Plasma session. `-dUseFastColor` saves 40% and changes the greys, so it is not used |
|
||||
| a PDF Ghostscript cannot read | exits 1 and **still leaves a file behind** |
|
||||
|
||||
From those:
|
||||
|
||||
* the copy is made in the background, the preview says *Preparing black
|
||||
and white…* and Print waits for it — four seconds is long enough to
|
||||
look like a frozen window. It has a minute before it is given up on,
|
||||
not the 20 s a page render has: a lesson of photographs on a slower
|
||||
laptop is slow, not stuck. Cancel stops it at once;
|
||||
* it is made once per document and kept, and each drawn page is kept, so
|
||||
turning Color and Black and white back and forth is instant after the
|
||||
first time, and stays on the page the teacher was looking at;
|
||||
* a conversion is judged by its exit status and its page count, never by
|
||||
whether a file appeared. If it fails the preview says so and offers
|
||||
Color. It **never prints the colour file in its place**: the teacher
|
||||
asked for black and white;
|
||||
* pages are never turned (pdfwrite otherwise rotates a page to match its
|
||||
text), and annotations are drawn into the page as they print, so no
|
||||
later filter can draw them again in colour;
|
||||
* a printer whose driver has no grey mode is not offered black and white,
|
||||
and choosing one returns the preview to colour.
|
||||
|
||||
`tools/lab/wonderink-print-check` shows a colourful lesson in colour,
|
||||
turns it to black and white and back five times on page 2, and prints
|
||||
page 2 twice: every grey preview is grey pixel for pixel, every colour
|
||||
one is not, the grey copy is made once, and the job CUPS kept is
|
||||
**byte-for-byte the file the preview showed**, grey on every page, sent
|
||||
with `ColorModel=Grayscale` through `pxlmono`. The same for a worksheet,
|
||||
a 50% top-left picture, a return to Color (which prints in colour through
|
||||
`pxlcolor`), and Cancel.
|
||||
|
||||
### Print image: a picture from inside a lesson (0.2.6.3)
|
||||
|
||||
Right-clicking a picture in a lesson and choosing Print printed the whole
|
||||
page around it. Some of Wonder Ink's pictures cannot be opened on their
|
||||
own through the site at all, so a teacher had no way to give that
|
||||
picture the picture layout 0.2.6.2 introduced.
|
||||
|
||||
The right-click menu now says what it will print. It asks the engine's
|
||||
own hit test what is under the pointer:
|
||||
|
||||
| Under the pointer | Measured `mediaType` | The menu offers |
|
||||
| --- | --- | --- |
|
||||
| a picture (`<img>`, also inside a link, a frame, from `blob:` or `data:`) | Image | **Print image…** and **Print page…** |
|
||||
| a `<canvas>` | Canvas | **Print image…** and **Print page…** |
|
||||
| words, empty space, a CSS background picture | None | **Print page…** |
|
||||
|
||||
Print page is always there. Ctrl+P and the Print button print the page,
|
||||
as before.
|
||||
|
||||
**How the picture is taken** was measured before anything was built, on
|
||||
a lab site with each kind of picture a lesson might have, a real
|
||||
right-click on each, and the site logging every request:
|
||||
|
||||
| | signed-in picture | hotlink-protected | one-time address | `blob:` / `data:` | another site's | canvas | WebP |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| engine's "download image" | ✓ | ✓ | **404** — fetched again | ✓ | ✓ | **nothing** | a file Qt cannot read |
|
||||
| `page.download(url)` | ✓ | **403** — no Referer | **404** | – | ✓ | – | the same |
|
||||
| `fetch()` from the page | ✓ | ✓ | ✓ | – | **refused** (CORS) | – | the same |
|
||||
| draw it on a canvas | ✓ | ✓ | ✓ | ✓ | **refused** (tainted) | – | ✓ |
|
||||
| **engine's "copy image"** | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
Only the engine's own **copy image** hands over the picture in every
|
||||
case, and it is what Wonder Ink uses. It is the bitmap the page has
|
||||
already decoded — full size, transparency kept, any format Chromium
|
||||
reads (Qt itself cannot read WebP here) — and **it fetches nothing**: the
|
||||
site saw no request at all. So a picture behind the teacher's sign-in
|
||||
needs no sign-in again, and a picture the site hands out once is still
|
||||
there. 0.01 to 0.05 s in the lab.
|
||||
|
||||
It goes through the clipboard, which is the teacher's. So what is on it
|
||||
is kept first and put back as soon as the picture arrives — whenever it
|
||||
arrives, even after Cancel. Measured under the real Plasma session
|
||||
(KWin, Wayland) with another program owning the clipboard: saved in
|
||||
0.002 s, the picture in 0.016 s, and that other program then pasted its
|
||||
own text again.
|
||||
|
||||
The picture then goes into exactly the 0.2.6.2 picture preview — drawn on
|
||||
white, capped at 300 dpi across Letter, Size, Position, Paper, Fill the
|
||||
whole page, Color — named *Picture from* the lesson's title.
|
||||
|
||||
**When it cannot be taken.** A picture that never loaded is still a
|
||||
picture to the hit test, and the engine then copies nothing. After three
|
||||
seconds the preview says *This picture could not be taken from the page*
|
||||
and that nothing was printed, with Print off. It does **not** fall back
|
||||
to printing the page; Print page is one right-click away and says what
|
||||
it does.
|
||||
|
||||
What it cannot do, and says nothing about because it cannot know:
|
||||
|
||||
* **a CSS background picture** is not a picture to the engine's hit
|
||||
test — Chrome's own menu has no "Save image" for one either. The menu
|
||||
offers only Print page. If Wonder Ink's site draws its artwork this
|
||||
way, that is the next thing to look at; nothing in the lab says it does;
|
||||
* **a video** frame is not offered;
|
||||
* **the teacher's clipboard is put back into Wonder Ink.** Under Wayland
|
||||
with no clipboard manager — this session has none — the clipboard
|
||||
belongs to whichever program last set it. After a Print image that is
|
||||
Wonder Ink, so text copied in another program before it can still be
|
||||
pasted while Wonder Ink is open, and is gone once Wonder Ink closes.
|
||||
Measured in the lab session. Leaving the picture there instead would
|
||||
lose it at once.
|
||||
|
||||
`tools/lab/wonderink-print-check` runs a signed-in lesson site
|
||||
(`tools/lab/wonderink-lesson-site.py`: session cookie, one-time
|
||||
addresses, a second origin) and right-clicks for real: the words (Print
|
||||
page only, and the page prints), a JPEG, a PNG, a transparent PNG, a
|
||||
one-time picture, another site's, a `blob:`, a WebP, a canvas, a picture
|
||||
inside a link, a JPEG at 50% top left in black and white, a picture that
|
||||
never loaded, and Cancel. Each is judged on the job CUPS kept: where and
|
||||
how large, no ink elsewhere, the corner mark there — and **no request to
|
||||
the site** while it printed, and the teacher's clipboard theirs again.
|
||||
|
||||
`tools/lab/wonderink-print-check` runs all of this against the installed
|
||||
command in the lab, and judges every scenario by what CUPS actually
|
||||
received. `tests/test-classroom-printing.sh` covers the CUPS side at every
|
||||
build. **Neither is paper.** The first print through the real Elmer is a
|
||||
job for somebody standing next to it.
|
||||
|
||||
### Any other error
|
||||
|
||||
An exception escaping any Qt handler makes PyQt6 abort the whole process —
|
||||
measured: a mistake in one slot and the window vanishes. Wonder Ink now
|
||||
logs it to the journal and carries on.
|
||||
|
||||
## What stayed the same
|
||||
|
||||
* **A separate profile from the ordinary browser.** A teacher's Wonder Ink
|
||||
sign-in and the room's shared YouTube account are different sessions in
|
||||
different storage that cannot reach each other. The browser is still
|
||||
Chrome and nothing in this change touches it.
|
||||
* **Cookies live only as long as the window.** This is what the Chrome
|
||||
policy's `CookiesSessionOnlyForUrls` did for wonderink.org, and it is
|
||||
the reason closing Wonder Ink ends the teacher's session rather than
|
||||
leaving the next person signed in as the last one. The whole profile
|
||||
rather than one domain now, because this profile only ever goes where a
|
||||
lesson sends it. If a room ever wants sign-ins to survive, it is the one
|
||||
line `setPersistentCookiesPolicy` in `classroom-wonderink`.
|
||||
* **Cookies live only as long as the window**, as Chrome's
|
||||
`CookiesSessionOnlyForUrls` did for wonderink.org, so closing Wonder Ink
|
||||
signs the teacher out and the next person is not signed in as the last
|
||||
one. WonderInk's sign-in is the teacher's password in a cookie
|
||||
(measured, 0.2.6.4), which is one more reason it never reaches the
|
||||
disk. What does survive a close, for two hours, is the list of tabs —
|
||||
see *Temporary workspace restore*, below.
|
||||
* **The old Chrome profile is left where it is**, at
|
||||
`~/.local/share/ekklesia-classroom/wonderink`. The new one is
|
||||
`wonderink-shell` beside it, so rolling back to 0.2.0 finds the old one
|
||||
intact.
|
||||
* **One scale for the room.** Chrome has to be handed `UI_SCALE` because
|
||||
it was measured not to follow the compositor on Wayland. Qt does follow
|
||||
it — measured at `UI_SCALE=2.0`, where a Qt client on that session
|
||||
reports `devicePixelRatio 2.0` without being told anything — so Wonder
|
||||
Ink is deliberately *not* told, or the scale would be applied twice.
|
||||
* **One scale for the room** — see *How large a lesson is*, below.
|
||||
|
||||
## Temporary workspace restore (0.2.6.4)
|
||||
|
||||
A teacher who closes Wonder Ink by accident, or whose computer crashes or
|
||||
restarts, gets their recent tabs back if they open it again within **two
|
||||
hours of last using it**. After that, Wonder Ink starts fresh and the tabs
|
||||
are forgotten: the next teacher — overnight, next week — never meets the
|
||||
last one's. It is not a sign-in and not a memory of tabs.
|
||||
|
||||
10:00 using Wonder Ink
|
||||
10:45 closed by accident 10:50 opened again: the tabs are back
|
||||
11:00 finished, closed next Sunday: a fresh Wonder Ink
|
||||
|
||||
### Why it is tabs and not the sign-in
|
||||
|
||||
The first design kept the sign-in. It was measured on the real site
|
||||
before it was built, and stopped:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `rrf-auth` | a cookie the site's JavaScript writes: **`Basic ` and the base64 of the teacher's email and password**, sent as a header on every request. Not a token the server issued |
|
||||
| `rrf-session-id`, `rrf_tsec_tok`, `auth` | session cookies alongside it; none survives the window, which is why closing Wonder Ink signs the teacher out |
|
||||
| Local Storage `mddui_local_storage`, Session Storage | screen variables and a screen cache |
|
||||
| the site's start-up | asks the server to authenticate again every time it loads; a reload in the lab ended signed out even with the cookie present |
|
||||
|
||||
Keeping the sign-in would have meant keeping the teacher's password, in a
|
||||
form anyone at the maintenance terminal could reverse, on a shared
|
||||
computer. It is not done. **Closing Wonder Ink still signs the teacher
|
||||
out**, exactly as before; the engine writes no cookie to disk for any site.
|
||||
|
||||
### What comes back
|
||||
|
||||
* **Resource tabs** — a slide, a picture, a worksheet or video the lesson
|
||||
linked to — exactly as they were. WonderInk's files are public (the
|
||||
lesson's slide loads with no sign-in, measured), so they need none.
|
||||
* **WonderInk's own tabs as one.** Every WonderInk page has the same
|
||||
address (`https://app.wonderink.org/?`) whichever lesson it shows — the
|
||||
site draws each screen itself — so a restored WonderInk tab opens the
|
||||
site's start page and asks the teacher to sign in, as it would anyway.
|
||||
Several of them become one, where the first was, so a teacher meets one
|
||||
sign-in page, not five.
|
||||
* **Worksheet tabs**, named and where they were — if the file is still in
|
||||
`~/Downloads/Wonder Ink` and still a PDF. A worksheet that has gone is
|
||||
left out, silently. Nothing outside that folder is ever opened from a
|
||||
kept workspace, whatever the file says.
|
||||
* **The order, and the tab that was in front.** The tab in front loads at
|
||||
once; the others load the first time they are clicked. Measured: a PDF
|
||||
loaded in a tab nobody was looking at was fitted to a window of no size,
|
||||
and showed at 10% when clicked. Until then a tab is kept by the address
|
||||
it will load, so closing Wonder Ink again loses nothing.
|
||||
|
||||
Measured on the real site, with a real Viewer account, under the lab's
|
||||
Plasma session: the *Rise and Fall of Saul* lesson, its welcome slide and
|
||||
its WONDER@HOME PDF, the slide in front; closed and reopened — the three
|
||||
tabs in that order, the slide in front, the WonderInk tab at its sign-in
|
||||
page; and again after rebooting the machine with Wonder Ink open. The kept
|
||||
file held the three addresses and titles, and none of the password, the
|
||||
Basic-auth value or the email address.
|
||||
|
||||
Never: the sign-in, a cookie, a header, a page's storage, a form, the
|
||||
clipboard, a print preview or any of its temporary files.
|
||||
|
||||
### What is kept, and when
|
||||
|
||||
`~/.local/share/ekklesia-classroom/wonderink-workspace.json`, readable only
|
||||
by the classroom account:
|
||||
|
||||
format, last_activity, active, attempts,
|
||||
tabs: [{kind: "web" | "worksheet", where: address or file, title}]
|
||||
|
||||
An address is kept only if it is `http` or `https` and carries no
|
||||
credential — no user and password in it, no query parameter named like a
|
||||
token, key, password, session or signature. `tests/` writes a workspace
|
||||
with such addresses and a Basic-auth value and checks the file holds none
|
||||
of them; the lab signs in on a stand-in whose sign-in is Basic auth, as
|
||||
WonderInk's is, and checks the same.
|
||||
|
||||
It is written a second after the tabs change — opened, closed, dragged
|
||||
into another order, brought to the front, navigated — and the time of
|
||||
last use at most every 30 seconds, whole, to a temporary file renamed
|
||||
into place. A crash or a power cut loses at most half a minute of use and
|
||||
never leaves half a file.
|
||||
|
||||
### What counts as using it
|
||||
|
||||
The teacher's input reaching Wonder Ink: a click, a key, the wheel, a
|
||||
touch — so following a link, scrolling, switching, opening and closing
|
||||
tabs, opening a worksheet, printing and zooming all count. Nothing a page
|
||||
does by itself does: timers, animation, background requests, analytics,
|
||||
advertising, video buffering, service workers — measured with a page
|
||||
making a request every second and animating for 20 s: not activity. A
|
||||
print preview left open untouched is not activity either.
|
||||
|
||||
**Opening Wonder Ink and having the tabs restored is not using it.** The
|
||||
time kept is the teacher's last use, carried over, so closing and
|
||||
reopening cannot keep a workspace alive by itself; the teacher clicking
|
||||
in the restored tabs is using it, and the two hours start again.
|
||||
|
||||
### When it is not restored
|
||||
|
||||
At start, and only then: more than two hours since the last use, a file
|
||||
that cannot be read or is from another version, a clock gone backwards by
|
||||
more than a minute — Wonder Ink starts fresh, removes the file and says
|
||||
why in the journal. No question is asked.
|
||||
|
||||
A restore that crashes Wonder Ink does not make it unusable. Starting from
|
||||
a workspace is counted in the file before any tab loads, and cleared once
|
||||
Wonder Ink has run 20 seconds. After two starts that did not get that
|
||||
far, the workspace is abandoned and Wonder Ink starts fresh, with a line
|
||||
in the journal.
|
||||
|
||||
Two hours is `IDLE_LIMIT_SECONDS` in
|
||||
`lib/classroom_wonderink_workspace.py`, the one place it is decided.
|
||||
|
||||
**Testing without waiting two hours.** From the maintenance terminal in
|
||||
the session (Meta+Ctrl+Alt+Shift+K), with Wonder Ink closed:
|
||||
|
||||
CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS=120 classroom-wonderink
|
||||
|
||||
That one Wonder Ink keeps the workspace for two minutes after the last
|
||||
use, and says `TEST ONLY` in the journal. The variable can only shorten
|
||||
the time, nothing in the classroom sets it (a test checks), and it lasts
|
||||
as long as that process.
|
||||
|
||||
### What it cannot do
|
||||
|
||||
* **Reopen a lesson where it was.** WonderInk's screens have no address of
|
||||
their own, so its tab comes back at its start page; after signing in
|
||||
the teacher opens the lesson again. The slides, pictures and worksheets
|
||||
they had open alongside it are already there.
|
||||
* **Know who is at the keyboard.** Two hours is two hours of nobody using
|
||||
Wonder Ink. A teacher who leaves and whose room is used within two hours
|
||||
leaves their tabs — not their sign-in — to the next person.
|
||||
|
||||
`tools/lab/wonderink-workspace-check` runs each of these against the
|
||||
installed command: restored after 5 and 90 minutes; fresh after two hours
|
||||
and a minute, the next day and the next week; reopened twice untouched;
|
||||
activity after a restore; a busy page; an untouched print preview;
|
||||
pressing Print; a closed tab; tabs reordered; a worksheet, and one whose
|
||||
file has gone; killed outright; a restore that crashes twice; a corrupt
|
||||
file, a later format, a half-written file; the privacy of the file; and
|
||||
restored tabs printing. `tests/test-classroom-wonderink-workspace.sh`
|
||||
covers the rules at every build.
|
||||
|
||||
## How large a lesson is
|
||||
|
||||
Teachers found lessons in Wonder Ink too small next to the same site in
|
||||
the ordinary browser. Measured in the lab (Plasma 6 on KWin, Debian 13) by
|
||||
asking each page for `window.devicePixelRatio` and its width in CSS pixels,
|
||||
with the display scale set the way a room sets it (`kscreen-doctor` and
|
||||
`UI_SCALE`):
|
||||
|
||||
| Display scale | Wonder Ink 0.2.6 | Chrome, as `classroom-browser` starts it |
|
||||
| --- | --- | --- |
|
||||
| 100% | dpr 1, 1920 px wide | dpr 1, 1920 px wide |
|
||||
| 150% | dpr 1.5, 1280 px wide | **dpr 2.25**, 854 px wide |
|
||||
| 200% | dpr 2, 960 px wide | **dpr 4**, 500 px wide |
|
||||
|
||||
Wonder Ink was right by the desktop's measure: it follows the display
|
||||
scale exactly, like the panel and every dialog. **Chrome is scaled twice.**
|
||||
Under Plasma it follows the compositor's scale itself, and
|
||||
`classroom-browser` also forces `--force-device-scale-factor=UI_SCALE` — a
|
||||
flag from the sway session, where Chrome was measured *not* following the
|
||||
compositor. So in any room above 100%, every page in the browser is
|
||||
`UI_SCALE` times larger than the desktop around it, and that is the size
|
||||
teachers read lessons at.
|
||||
|
||||
0.2.6.1 does not change the browser — it is the size the rooms know, and
|
||||
changing it everywhere is its own decision. Wonder Ink's pages start at
|
||||
the browser's size instead: **page zoom = `UI_SCALE`**, the room's own
|
||||
clamped number, which is exactly 1.0 in a room at 100%. Wonder Ink's
|
||||
window, tabs and dialogs are not zoomed; Qt already scales them once,
|
||||
correctly. Measured after the change: at 150% the lesson reports dpr 2.25
|
||||
and 853 px, Chrome dpr 2.25 and 854 px.
|
||||
|
||||
**The teacher's own size** goes on top: Ctrl+Plus (and Ctrl+=, and the
|
||||
keypad's), Ctrl+Minus, Ctrl+0 for normal, Ctrl+wheel, or the right-click
|
||||
menu. Chrome's steps, 50% to 300% of the room's normal size, one level for
|
||||
every tab including new ones. It is remembered in
|
||||
`~/.config/ekklesia-classroom/wonderink.conf` as a single `LEVEL=` line —
|
||||
its own file, nowhere near the engine profile, so remembering a size can
|
||||
never mean remembering a sign-in — and relative to the room, so an
|
||||
administrator changing `UI_SCALE` later still gets the right result.
|
||||
|
||||
Measured along the way:
|
||||
|
||||
* the engine keeps a tab's zoom across every navigation, same site or not,
|
||||
so it is set once when the tab is made and never re-applied;
|
||||
* zoom does not change what prints: the same lesson is 3 pages at 100%,
|
||||
200% and 400%;
|
||||
* **page zoom does not reach a worksheet tab.** Chromium's PDF viewer
|
||||
follows the display scale like the window, fits the page to its width,
|
||||
and has its own − / + zoom in its toolbar, which is what a teacher uses
|
||||
there;
|
||||
* two Qt actions on one key are ambiguous and Qt does *neither* — Ctrl+Minus
|
||||
did nothing while it was bound once by name and again as KDE's standard
|
||||
zoom-out key. One action per command now carries all its keys, which
|
||||
also fixed Ctrl+Shift+Tab (bound twice the same way since 0.2.1).
|
||||
|
||||
`tools/lab/wonderink-zoom-check` asks the installed command all of this.
|
||||
The lab's injected keyboard does not reach its nested session, so the
|
||||
check delivers keys through Qt's own key path to the focused page; a
|
||||
physical Ctrl+Plus on a classroom keyboard is a hardware check.
|
||||
|
||||
## Why lessons load slowly
|
||||
|
||||
Measured before anything was changed, and nothing about loading was
|
||||
changed, because the evidence does not point at Wonder Ink.
|
||||
|
||||
In the lab, on the same connection, the same page, timed by each
|
||||
engine's own Navigation Timing until the sign-in form exists (the site
|
||||
is a 3.7 KB shell that builds itself in JavaScript, so its load event is
|
||||
not the moment a teacher can use it):
|
||||
|
||||
| Until the sign-in page is usable | Wonder Ink | Chrome |
|
||||
| --- | --- | --- |
|
||||
| cold cache, 3 runs | 2.86 – 3.00 s | 2.79 – 3.48 s |
|
||||
| warm cache, 3 runs | 2.20 – 2.49 s | 1.96 – 2.15 s |
|
||||
| window on screen after launch | 0.33 s | 0.42 s |
|
||||
|
||||
* **The site is the weight:** 119 – 160 requests and 3.2 MB for a sign-in
|
||||
form, about 430 KB warm. Its console says it loads the Google Maps
|
||||
JavaScript API *twice*, synchronously. That is Wonder Ink's to fix.
|
||||
* Wonder Ink is about a quarter of a second slower warm: it revalidates
|
||||
the page shell (62 – 87 ms to first byte) where Chrome serves it from
|
||||
its cache. Not the "very long time" teachers describe.
|
||||
* Memory: about 610 MB for one tab and 115 MB more for each further tab —
|
||||
eight tabs, 1.4 GB — one renderer each. Idle CPU about 1%: nothing
|
||||
spins in the background.
|
||||
* The network: from the lab, 0.14 – 0.22 s to first byte; from the
|
||||
development host, 0.42 – 0.44 s. Cloudflare in front, uncached.
|
||||
|
||||
What the lab **cannot** show is the machines: it draws in software for
|
||||
both engines. A classroom whose graphics fail in QtWebEngine but work in
|
||||
Chrome would look exactly like the complaint. So there is a command for a
|
||||
technician, run from the maintenance terminal inside the teacher's
|
||||
session (Meta+Ctrl+Alt+Shift+K):
|
||||
|
||||
classroom-wonderink --diagnose
|
||||
|
||||
It prints the engine's versions, the scale, **what WebGL reports as the
|
||||
renderer** — a real driver's name, or "none … drawing in software" — and
|
||||
times the sign-in page from a cold cache in a throwaway profile, beside a
|
||||
running Wonder Ink without touching it. Compare its time with the lab's
|
||||
2.9 s, and its renderer with Chrome's on the same machine (open
|
||||
`chrome://gpu` there).
|
||||
|
||||
|
||||
## Turning it off
|
||||
|
||||
One future device is a laptop that will not have Wonder Ink at all.
|
||||
|
||||
@@ -362,3 +893,7 @@ For completeness, so nobody re-audits them: the managed policy in
|
||||
`/etc/opt/chrome/policies/managed/` sets first-run, sign-in, password
|
||||
and homepage behaviour only. None of it touches media, autoplay,
|
||||
iframes, fullscreen or site permissions.
|
||||
|
||||
## Later
|
||||
|
||||
What testing of 0.2.6.1 asked for next is in [backlog.md](backlog.md).
|
||||
|
||||
@@ -5,15 +5,14 @@ underneath it — where the files live, what is known about each one, and
|
||||
the two things that have to be right whoever is asking: when a download
|
||||
has really finished, and when a presentation has really been presented.
|
||||
|
||||
WHY THERE IS A MANAGED COPY AT ALL.
|
||||
WHY THE ORIGINAL LIVES IN THE MANAGED LIBRARY.
|
||||
|
||||
A teacher downloads a session through ordinary Chrome, into ordinary
|
||||
~/Downloads. That file stays exactly where Chrome put it and is never
|
||||
touched again — not renamed, not moved, not deleted, not by the library
|
||||
and not by the cleanup. The library keeps its OWN copy, and everything
|
||||
after the import happens to that copy. So a teacher who tidies their
|
||||
downloads cannot break a presentation, and a cleanup that removes a
|
||||
presentation cannot take a teacher's file with it.
|
||||
A teacher downloads a session into ordinary ~/Downloads. Once the download
|
||||
is complete, it is moved into this managed library. These locked-down
|
||||
machines have no general file manager, so leaving another copy in Downloads
|
||||
only wastes space and makes Remove misleading. The moved original is kept
|
||||
beside the generated PDF and page images, which means conversion failure is
|
||||
recoverable and cleanup has one contained directory to remove.
|
||||
|
||||
WHY EVERY FORMAT BECOMES A PDF.
|
||||
|
||||
@@ -289,6 +288,44 @@ class Presentation:
|
||||
def failed(self):
|
||||
return self.state == "failed"
|
||||
|
||||
@property
|
||||
def failure(self):
|
||||
"""Why it failed, as one word a teacher's card can be chosen by.
|
||||
|
||||
"memory": the conversion was killed for want of memory, or was
|
||||
interrupted too often to keep trying — worth trying again once
|
||||
other programs are closed. "unreadable": the file itself would not
|
||||
convert, which trying again will not change.
|
||||
"""
|
||||
if self.stalled:
|
||||
return "memory"
|
||||
|
||||
if not self.failed:
|
||||
return None
|
||||
|
||||
return self.data.get("error_kind") or "unreadable"
|
||||
|
||||
@property
|
||||
def stalled(self):
|
||||
"""Still "getting ready" long after any import could have finished.
|
||||
|
||||
The importer's own limits end every attempt within
|
||||
CONVERT_TIMEOUT + RENDER_TIMEOUT. Past that, with no answer, the
|
||||
importer is not running at all — and a card that says "Getting
|
||||
this ready" for ever is the thing a teacher must never be left
|
||||
looking at.
|
||||
"""
|
||||
if self.state not in ("importing", "retry"):
|
||||
return False
|
||||
|
||||
started = parse_stamp(self.data.get("attempt_started_at")
|
||||
or self.data.get("imported_at"))
|
||||
|
||||
if started is None:
|
||||
return False
|
||||
|
||||
return (now() - started).total_seconds() > STALLED_SECONDS
|
||||
|
||||
@property
|
||||
def error(self):
|
||||
return self.data.get("error") or ""
|
||||
@@ -459,6 +496,39 @@ def library(root=None):
|
||||
return found
|
||||
|
||||
|
||||
def request_retry(presentation):
|
||||
"""A teacher's Try again: the importer takes it up on its next pass.
|
||||
|
||||
Only a presentation that failed for want of memory, or stalled; a file
|
||||
that will not convert is not going to convert on the second try.
|
||||
"""
|
||||
if presentation.failure != "memory":
|
||||
return False
|
||||
|
||||
presentation.data["state"] = "retry"
|
||||
presentation.data["error"] = None
|
||||
presentation.data["error_kind"] = None
|
||||
presentation.data["attempts"] = 0
|
||||
presentation.data["attempt_started_at"] = stamp()
|
||||
presentation.save()
|
||||
return True
|
||||
|
||||
|
||||
def remove(presentation):
|
||||
"""Delete exactly one managed entry, unless it is on screen now."""
|
||||
try:
|
||||
showing = presenting_lock().read_text().strip()
|
||||
except OSError:
|
||||
showing = ""
|
||||
|
||||
if presentation.id == showing:
|
||||
return False
|
||||
|
||||
shutil.rmtree(presentation.directory, ignore_errors=True)
|
||||
|
||||
return not presentation.directory.exists()
|
||||
|
||||
|
||||
def titles_in_use(root=None, exclude=None):
|
||||
"""Every title already on a card, optionally ignoring one presentation.
|
||||
|
||||
@@ -601,10 +671,10 @@ def ledger(root=None):
|
||||
def remember(mark, value, root=None):
|
||||
"""Record that this download has been dealt with.
|
||||
|
||||
Kept even after a teacher removes the presentation, so that Remove
|
||||
means removed rather than removed-until-the-next-scan. The original
|
||||
download is still in Downloads — untouched, as always — and without
|
||||
this the importer would put it straight back.
|
||||
The normal successful import moves the source out of Downloads, so it
|
||||
cannot be found by a later scan. The ledger is still the transaction
|
||||
record that makes an interrupted same- or cross-filesystem move safe:
|
||||
startup can tell which half-built directory belongs to which source.
|
||||
"""
|
||||
data = ledger(root)
|
||||
data[mark] = value
|
||||
@@ -628,6 +698,21 @@ PDFTOTEXT = "pdftotext"
|
||||
CONVERT_TIMEOUT = 300
|
||||
RENDER_TIMEOUT = 600
|
||||
|
||||
# Past this, a presentation still "getting ready" has no importer working
|
||||
# on it; see Presentation.stalled.
|
||||
STALLED_SECONDS = CONVERT_TIMEOUT + RENDER_TIMEOUT + 300
|
||||
|
||||
# What convert_to_pdf and render_pages say when the kernel killed the
|
||||
# program doing the work. Measured: a deck of 30 different photographs
|
||||
# takes LibreOffice 1.4 GB for about twenty seconds, 20 larger ones 2.1 GB,
|
||||
# and the importer's service is capped at 40% of the machine's memory.
|
||||
KILLED = "killed for want of memory"
|
||||
|
||||
|
||||
def killed(returncode):
|
||||
"""Did the kernel end this process? SIGKILL, directly or via a shell."""
|
||||
return returncode in (-9, 137)
|
||||
|
||||
|
||||
def _run(command, timeout, **kwargs):
|
||||
import subprocess
|
||||
@@ -690,6 +775,9 @@ def convert_to_pdf(source, destination):
|
||||
shutil.rmtree(profile, ignore_errors=True)
|
||||
shutil.rmtree(workspace, ignore_errors=True)
|
||||
|
||||
if killed(result.returncode):
|
||||
return False, KILLED
|
||||
|
||||
return False, (result.stderr or result.stdout or
|
||||
"the file could not be opened").strip()
|
||||
|
||||
@@ -761,6 +849,9 @@ def render_pages(pdf, directory, width=SLIDE_WIDTH, progress=None):
|
||||
time.sleep(0.4)
|
||||
|
||||
if process.returncode != 0:
|
||||
if killed(process.returncode):
|
||||
return 0, KILLED
|
||||
|
||||
return 0, (process.stderr.read() if process.stderr else
|
||||
"the slides could not be rendered")
|
||||
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Printing a PDF to the room's printer, and showing what will be printed.
|
||||
|
||||
Wonder Ink's printing, without Qt's printing in it. Every document Wonder
|
||||
Ink prints is a PDF by the time it gets here — a worksheet already is
|
||||
one, and a lesson page is turned into one by the engine — so what is left
|
||||
is the part the rest of the classroom already trusts: CUPS.
|
||||
|
||||
WHY NOT QPRINTER.
|
||||
|
||||
Measured in the lab on Debian 13 (QtWebEngine 6.8.2), against a queue
|
||||
built from Elmer's own PPD with a file in place of the printer:
|
||||
|
||||
QWebEngineView.print() a lesson page arrived as 24.6 MB of
|
||||
300 dpi pictures, no text left in it;
|
||||
a worksheet PDF tab arrived as ONE BLANK
|
||||
PAGE; a queue rejecting jobs was reported
|
||||
as printed successfully; and on 0.2.2 it
|
||||
crashed, because the printer was deleted
|
||||
before Qt had finished with it.
|
||||
|
||||
printToPdf() + lp the same lesson was an 18 KB PDF with its
|
||||
text, on Letter, through Elmer's filters
|
||||
in 0.43 s; a rejected job is an error
|
||||
with CUPS's own words in it.
|
||||
|
||||
The second is what Chrome does, and Chrome is what the rooms already
|
||||
print from without trouble.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
# lp hands the file to the scheduler and returns; it does not wait for
|
||||
# the printer. A scheduler that has not answered in this long is not
|
||||
# going to, and a teacher is standing at the dialog.
|
||||
SUBMIT_TIMEOUT = 30
|
||||
QUERY_TIMEOUT = 10
|
||||
|
||||
# What a worksheet can reasonably be asked for. A spin box that goes to
|
||||
# 999 is one slip of a finger away from a ream of paper.
|
||||
MAX_COPIES = 50
|
||||
|
||||
# Paper names a driver uses, in the terms Qt's page sizes are named by.
|
||||
# Anything else is Letter, which is what the church's printer is.
|
||||
PAPER = {"Letter": "Letter", "A4": "A4", "Legal": "Legal"}
|
||||
|
||||
# The driver's own words for "no colour", in the order they are tried.
|
||||
# Elmer's generic PCL 6 driver ignores the IPP print-color-mode attribute
|
||||
# altogether — measured, it logs "Unknown option" and prints in colour —
|
||||
# so black and white has to be asked for in the PPD's terms.
|
||||
GRAY_CHOICES = ("Grayscale", "Gray", "Mono", "Monochrome", "BlackWhite")
|
||||
|
||||
|
||||
def _run(command, timeout=QUERY_TIMEOUT):
|
||||
try:
|
||||
return subprocess.run(
|
||||
command, capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
return subprocess.CompletedProcess(command, 1, "", str(error))
|
||||
|
||||
|
||||
def printers():
|
||||
"""(names, default) for every printer CUPS will accept a job for.
|
||||
|
||||
lpstat -e is the list a teacher can print to: in a classroom that is
|
||||
Elmer and nothing else, because discovery is switched off.
|
||||
"""
|
||||
names = [
|
||||
line.strip()
|
||||
for line in _run(["lpstat", "-e"]).stdout.splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
|
||||
default = None
|
||||
match = re.search(
|
||||
r"system default destination:\s*(\S+)", _run(["lpstat", "-d"]).stdout
|
||||
)
|
||||
|
||||
if match and match.group(1) in names:
|
||||
default = match.group(1)
|
||||
elif names:
|
||||
default = names[0]
|
||||
|
||||
return names, default
|
||||
|
||||
|
||||
def options(printer):
|
||||
"""What this printer's driver offers, parsed from lpoptions -l.
|
||||
|
||||
{"paper": "Letter", "gray": ("ColorModel", "Grayscale") or None}
|
||||
"""
|
||||
found = {"paper": "Letter", "gray": None}
|
||||
|
||||
for line in _run(["lpoptions", "-p", printer, "-l"]).stdout.splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
|
||||
key, choices = line.split(":", 1)
|
||||
key = key.split("/", 1)[0].strip()
|
||||
choices = choices.split()
|
||||
default = next((c[1:] for c in choices if c.startswith("*")), None)
|
||||
plain = [c.lstrip("*") for c in choices]
|
||||
|
||||
if key == "PageSize" and default in PAPER:
|
||||
found["paper"] = PAPER[default]
|
||||
|
||||
if key in ("ColorModel", "ColorMode", "OutputMode"):
|
||||
gray = next((c for c in GRAY_CHOICES if c in plain), None)
|
||||
|
||||
if gray is not None and found["gray"] is None:
|
||||
found["gray"] = (key, gray)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def page_count(pdf):
|
||||
"""How many pages the PDF has, or 0 if it cannot be read."""
|
||||
result = _run(["pdfinfo", str(pdf)])
|
||||
match = re.search(r"^Pages:\s+(\d+)", result.stdout, re.MULTILINE)
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
def render_page(pdf, number, width):
|
||||
"""PNG bytes of one page, WIDTH pixels wide, or None.
|
||||
|
||||
Rendered into a directory of its own and read back. Measured on
|
||||
poppler 25.03: asked for "-" as its output, pdftoppm does NOT write to
|
||||
stdout — it writes a file called "-.png" (or "--1.png") into whatever
|
||||
directory it was started in, which for Wonder Ink is the teacher's
|
||||
home, and the preview showed nothing at all.
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="wonderink-page-") as directory:
|
||||
root = Path(directory) / "page"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"pdftoppm", "-f", str(number), "-l", str(number),
|
||||
"-scale-to-x", str(int(width)), "-scale-to-y", "-1",
|
||||
"-png", "-singlefile", "--", str(pdf), str(root),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=QUERY_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
image = root.with_suffix(".png")
|
||||
|
||||
if result.returncode != 0 or not image.is_file():
|
||||
return None
|
||||
|
||||
data = image.read_bytes()
|
||||
return data or None
|
||||
|
||||
|
||||
def parse_pages(text, count):
|
||||
"""A teacher's page list, as CUPS wants it, or ValueError saying why.
|
||||
|
||||
"" or "all" is every page (None). Otherwise numbers and ranges
|
||||
separated by commas — "2", "1-3", "1, 3, 5-6" — each inside the
|
||||
document. Returned normalised: "1-3,5".
|
||||
"""
|
||||
text = (text or "").strip().lower()
|
||||
|
||||
if text in ("", "all"):
|
||||
return None
|
||||
|
||||
parts = []
|
||||
|
||||
for piece in re.split(r"\s*,\s*", text):
|
||||
match = re.fullmatch(r"(\d+)(?:\s*-\s*(\d+))?", piece)
|
||||
|
||||
if not match:
|
||||
raise ValueError(
|
||||
f"“{piece}” is not a page number. "
|
||||
"Use numbers like 2 or 1-3, separated by commas."
|
||||
)
|
||||
|
||||
first = int(match.group(1))
|
||||
last = int(match.group(2) or first)
|
||||
|
||||
if first < 1 or last < first:
|
||||
raise ValueError(f"“{piece}” is not a range of pages.")
|
||||
|
||||
if last > count:
|
||||
raise ValueError(
|
||||
f"This document has {count} page{'s' if count != 1 else ''}."
|
||||
)
|
||||
|
||||
parts.append(str(first) if first == last else f"{first}-{last}")
|
||||
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
def grayscale_command(source, target):
|
||||
"""The command that writes SOURCE, in black and white, to TARGET.
|
||||
|
||||
Black and white is made here, before the preview, so the preview shows
|
||||
it and CUPS is given the same file — 0.2.6.2 only told the driver, and
|
||||
the preview stayed in colour while the paper came out grey. Measured in
|
||||
the lab (Ghostscript 10.05.1) on a lesson page, a picture page, a
|
||||
worksheet and a fillable form:
|
||||
|
||||
* the result is grey and nothing else, with the same pages, page
|
||||
sizes and words, and the text still text;
|
||||
* Ghostscript's own grey rendering of the colour original and of
|
||||
this file give the same greys — within 1 level in 255 on every
|
||||
filled area and picture; small text can land a fraction of a
|
||||
pixel over. Elmer's driver is Ghostscript, so this is what Elmer
|
||||
would have made of the colour file anyway — poppler's grey is
|
||||
not, and differs on 7% of a coloured page;
|
||||
* 0.05 s for a worksheet, 1.3 s for a full-page photograph, 2 s for
|
||||
42 pages, and 4.4 s for one lesson page with eleven full-size
|
||||
pictures, which is why the preview does it in the background.
|
||||
|
||||
Pages are never turned (pdfwrite otherwise rotates a page by its text)
|
||||
and annotations — a form field a teacher filled in, a mark on a
|
||||
worksheet — are drawn into the page as they print, so nothing is left
|
||||
for a later filter to draw again in colour.
|
||||
"""
|
||||
return [
|
||||
"gs", "-q", "-dSAFER", "-dBATCH", "-dNOPAUSE",
|
||||
"-sDEVICE=pdfwrite",
|
||||
"-sColorConversionStrategy=Gray", "-dProcessColorModel=/DeviceGray",
|
||||
"-dAutoRotatePages=/None",
|
||||
"-dPreserveAnnots=false", "-dPrinted",
|
||||
f"-sOutputFile={target}", "--", str(source),
|
||||
]
|
||||
|
||||
|
||||
def submit(pdf, printer, copies=1, pages=None, gray=None, title="Wonder Ink"):
|
||||
"""Hand a PDF to CUPS. (True, job id) or (False, what went wrong).
|
||||
|
||||
Success means the scheduler has the job and its id — which is the
|
||||
thing Qt's own print path could not say: it reported a rejected job
|
||||
as printed.
|
||||
"""
|
||||
copies = max(1, min(int(copies), MAX_COPIES))
|
||||
command = ["lp", "-d", printer, "-n", str(copies), "-t", title]
|
||||
|
||||
if pages:
|
||||
command += ["-P", pages]
|
||||
|
||||
if gray:
|
||||
command += ["-o", f"{gray[0]}={gray[1]}"]
|
||||
|
||||
command += ["--", str(pdf)]
|
||||
result = _run(command, timeout=SUBMIT_TIMEOUT)
|
||||
match = re.search(r"request id is (\S+)", result.stdout)
|
||||
|
||||
if result.returncode == 0 and match:
|
||||
return True, match.group(1)
|
||||
|
||||
message = (result.stderr or result.stdout).strip()
|
||||
# "lp: The printer or class is not accepting jobs." reads better
|
||||
# without the program's name in front of it.
|
||||
message = re.sub(r"^lp:\s*", "", message) or "The printer did not accept the job."
|
||||
return False, message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pictures
|
||||
#
|
||||
# A picture a lesson opens in a tab of its own is not a page: the engine
|
||||
# shows it on a near-black viewer page (body background rgb(14, 14, 14),
|
||||
# measured) scaled to the window, and printing that page printed the black
|
||||
# around the picture — the bars teachers saw above and below the artwork.
|
||||
# So a picture is laid out on the paper here instead, from the picture
|
||||
# itself, and the teacher chooses how large and where.
|
||||
|
||||
# How large, as a share of the largest size that fits the page.
|
||||
PICTURE_SIZES = (100, 75, 50, 33, 25)
|
||||
|
||||
# Where, as (column, row): 0 left or top, 1 centre, 2 right or bottom.
|
||||
PICTURE_POSITIONS = {
|
||||
"top left": (0, 0), "top": (1, 0), "top right": (2, 0),
|
||||
"left": (0, 1), "centre": (1, 1), "right": (2, 1),
|
||||
"bottom left": (0, 2), "bottom": (1, 2), "bottom right": (2, 2),
|
||||
}
|
||||
|
||||
# Portrait or landscape: "auto" follows the picture's own shape.
|
||||
ORIENTATIONS = ("auto", "portrait", "landscape")
|
||||
|
||||
|
||||
def picture_landscape(orientation, width, height):
|
||||
"""Should the page be landscape for this picture?"""
|
||||
if orientation == "landscape":
|
||||
return True
|
||||
|
||||
if orientation == "portrait":
|
||||
return False
|
||||
|
||||
return width > height
|
||||
|
||||
|
||||
def picture_rect(page, margin, picture, size=100, position="centre", fill=False):
|
||||
"""Where a picture goes on a page, as (x, y, width, height).
|
||||
|
||||
PAGE is (width, height) and MARGIN the blank edge, both in the same
|
||||
units; PICTURE is the picture's (width, height) in pixels. Never
|
||||
stretched: the picture keeps its own proportions.
|
||||
|
||||
Fit (the default) is the largest size that shows ALL of the picture
|
||||
inside the margins, times SIZE percent, placed at POSITION. Fill covers
|
||||
the whole printable area and so cuts off whatever overhangs — which a
|
||||
teacher asks for by name, never by default; SIZE and POSITION do not
|
||||
apply to it.
|
||||
"""
|
||||
page_width, page_height = page
|
||||
width, height = picture
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("the picture has no size")
|
||||
|
||||
area_x, area_y = margin, margin
|
||||
area_width = max(1.0, page_width - 2 * margin)
|
||||
area_height = max(1.0, page_height - 2 * margin)
|
||||
|
||||
if fill:
|
||||
scale = max(area_width / width, area_height / height)
|
||||
drawn_width, drawn_height = width * scale, height * scale
|
||||
return (area_x + (area_width - drawn_width) / 2,
|
||||
area_y + (area_height - drawn_height) / 2,
|
||||
drawn_width, drawn_height)
|
||||
|
||||
size = max(1, min(100, int(size)))
|
||||
scale = min(area_width / width, area_height / height) * size / 100
|
||||
drawn_width, drawn_height = width * scale, height * scale
|
||||
column, row = PICTURE_POSITIONS.get(position, (1, 1))
|
||||
|
||||
return (area_x + (area_width - drawn_width) * column / 2,
|
||||
area_y + (area_height - drawn_height) * row / 2,
|
||||
drawn_width, drawn_height)
|
||||
@@ -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
|
||||
Architecture: amd64
|
||||
Maintainer: Ekklesia
|
||||
Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6, libreoffice-impress, libreoffice-core, poppler-utils, fonts-liberation, fonts-liberation-sans-narrow, fonts-croscore, fonts-crosextra-carlito, fonts-crosextra-caladea, fonts-urw-base35, fonts-roboto, fonts-open-sans, fonts-lato, fonts-montserrat, fonts-noto-extra
|
||||
Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6, libreoffice-impress, libreoffice-core, poppler-utils, ghostscript, fonts-liberation, fonts-liberation-sans-narrow, fonts-croscore, fonts-crosextra-carlito, fonts-crosextra-caladea, fonts-urw-base35, fonts-roboto, fonts-open-sans, fonts-lato, fonts-montserrat, fonts-noto-extra
|
||||
Recommends: vlc
|
||||
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup
|
||||
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup, khelpcenter
|
||||
Description: Ekklesia managed classroom environment
|
||||
A classroom appliance built on Debian's own Plasma desktop. The room's
|
||||
panel, window behaviour, notifications, palette and lockdown are
|
||||
@@ -48,6 +48,13 @@ Description: Ekklesia managed classroom environment
|
||||
import costs 0.34s for a ten-slide deck and nothing at all afterwards.
|
||||
libreoffice-gtk3 is deliberately NOT here: nothing interactive runs.
|
||||
.
|
||||
ghostscript is Wonder Ink's black and white. It was already on every
|
||||
machine, because cups-filters needs it; it is named here because Wonder
|
||||
Ink now runs it itself, to make the grey copy of a page that the print
|
||||
preview shows and CUPS is given. Measured: it is the same engine Elmer's
|
||||
driver prints with, and its grey differs from what Elmer would have
|
||||
made of the colour page by at most 1 level in 255.
|
||||
.
|
||||
The fonts are what a Google Slides export asks for. The metric
|
||||
compatible families cover the Microsoft names — Liberation for Arial,
|
||||
Times New Roman and Courier New, Carlito for Calibri, Caladea for
|
||||
@@ -61,3 +68,9 @@ Description: Ekklesia managed classroom environment
|
||||
The Conflicts line is deliberate. A welcome tour, a software centre and
|
||||
a phone pairing daemon are all things plasma-desktop recommends and a
|
||||
room a teacher walks into should not have.
|
||||
.
|
||||
khelpcenter is there for the same reason, found the hard way: a
|
||||
settings window's Help button opens it directly, whatever the Kiosk
|
||||
restrictions say, and it is a browser of the machine's manual pages with
|
||||
file dialogs of its own. plasma-desktop only recommends it, and nothing
|
||||
else installed needs it.
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Put the classroom's wallpaper on every desktop the shell has.
|
||||
|
||||
Tiny Pebbles came up after the 0.2.2 -> 0.2.6.4 upgrade on Plasma's own
|
||||
purple wallpaper, with the right panel and the right package installed.
|
||||
Its desktop was containment 10, with no Wallpaper group at all:
|
||||
|
||||
[Containments][10]
|
||||
plugin=org.kde.plasma.folder
|
||||
wallpaperplugin=org.kde.image
|
||||
[Containments][10][General]
|
||||
positions={"1920x1080":[]}
|
||||
|
||||
The layout script writes the wallpaper, but only onto the desktops that
|
||||
exist while it runs. Plasma makes a desktop for every screen, and every
|
||||
activity, that appears afterwards — and it makes them without the layout.
|
||||
0.2.2's updater rebooted at three in the morning with the television
|
||||
switched off at the set, so the layout ran with no screen and no desktop
|
||||
at all, and the desktop Plasma made when the television came on was
|
||||
bare. Reproduced in the lab through a new activity, which takes the same
|
||||
path through the shell: containment 11, the same four lines, Plasma's
|
||||
wallpaper.
|
||||
|
||||
Two things now close that. The look and feel's default wallpaper is a
|
||||
real wallpaper package, so a desktop Plasma makes on its own comes up
|
||||
with the classroom's picture — that is the fix, and it needs nothing to
|
||||
run. And this, which writes the picture into every desktop the file
|
||||
already has, so that the configuration says what the screen shows.
|
||||
|
||||
WHICH CONTAINMENT IS THE DESKTOP is answered by what it is, never by its
|
||||
number: a top-level containment whose plugin is one of Plasma's desktop
|
||||
containments. Tiny Pebbles' desktop was 10 and River Kids' is 1, and the
|
||||
panel's number is whatever was free when it was built.
|
||||
|
||||
ONLY THE DESKTOPS ARE TOUCHED. Every panel, applet and line that is not a
|
||||
desktop's wallpaper is written back byte for byte, and a file that is
|
||||
already right is not written at all.
|
||||
|
||||
WHEN IT RUNS. Before plasmashell starts, as the shell's own ExecStartPre,
|
||||
because the shell owns this file: editing it underneath a live shell was
|
||||
measured to destroy the panel. That is every start — a login, a reboot,
|
||||
and the restart after a crash — and at a login it usually finds nothing,
|
||||
because classroom-desktop-reset has just removed the file for the layout
|
||||
to rebuild. Run by hand, it refuses while the shell is running and says
|
||||
how to apply it. Live D-Bus scripting is not an option: the classroom
|
||||
locks the desktop and forbids the scripting console, and Plasma answers
|
||||
"Widgets are locked" — measured.
|
||||
|
||||
classroom-desktop-wallpaper repair, if the shell is stopped
|
||||
classroom-desktop-wallpaper --check say what is wrong, change nothing
|
||||
classroom-desktop-wallpaper FILE work on FILE instead
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png"
|
||||
|
||||
WALLPAPER_PLUGIN = "org.kde.image"
|
||||
|
||||
# Plasma's two desktop containments: Folder View, which is what the
|
||||
# classroom's desktops are, and the plain Desktop. Panels, the system tray
|
||||
# a 0.2.2-era profile may still carry and anything else are not desktops.
|
||||
DESKTOP_PLUGINS = ("org.kde.plasma.folder", "org.kde.desktopcontainment")
|
||||
|
||||
# What the layout writes onto a desktop, so a repaired desktop and a
|
||||
# freshly built one say the same thing.
|
||||
WALLPAPER_KEYS = (
|
||||
("FillMode", "2"),
|
||||
("Image", f"file://{WALLPAPER}"),
|
||||
)
|
||||
|
||||
APPLETSRC = "plasma-org.kde.plasma.desktop-appletsrc"
|
||||
|
||||
CONTAINMENT = re.compile(r"^\[Containments\]\[(\d+)\]$")
|
||||
|
||||
# key=value, with KConfig's optional [$i]-style flags or [locale] after
|
||||
# the key. The flags are kept when a value is replaced.
|
||||
ENTRY = re.compile(r"^([^=\[\s]+)((?:\[[^\]]*\])*)=(.*)$")
|
||||
|
||||
|
||||
def say(message):
|
||||
print(f"classroom-desktop-wallpaper: {message}", flush=True)
|
||||
|
||||
|
||||
def groups(lines):
|
||||
"""[(name, header, end)]: lines[header] is the group's header line, or
|
||||
-1 for the lines before the first group, and end is exclusive."""
|
||||
found = []
|
||||
name, header = None, -1
|
||||
|
||||
for index, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
|
||||
if stripped.startswith("[") and stripped.endswith("]"):
|
||||
found.append((name, header, index))
|
||||
name, header = stripped, index
|
||||
|
||||
found.append((name, header, len(lines)))
|
||||
return found
|
||||
|
||||
|
||||
def find_group(lines, name):
|
||||
for group, header, end in groups(lines):
|
||||
if group == name:
|
||||
return header, end
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def entry(line):
|
||||
match = ENTRY.match(line.rstrip("\n"))
|
||||
return (match.group(1), match.group(2), match.group(3)) if match else None
|
||||
|
||||
|
||||
def desktops(lines):
|
||||
"""The ids of the desktop containments, whatever they are numbered."""
|
||||
ids = []
|
||||
|
||||
for name, header, end in groups(lines):
|
||||
match = CONTAINMENT.match(name or "")
|
||||
|
||||
if not match:
|
||||
continue
|
||||
|
||||
for line in lines[header + 1:end]:
|
||||
parsed = entry(line)
|
||||
|
||||
if parsed and parsed[0] == "plugin" and parsed[2] in DESKTOP_PLUGINS:
|
||||
ids.append(match.group(1))
|
||||
break
|
||||
|
||||
return ids
|
||||
|
||||
|
||||
def set_entry(lines, group, key, value):
|
||||
"""Make `key=value` true in an existing group. Returns what changed,
|
||||
or None when it already was."""
|
||||
header, end = find_group(lines, group)
|
||||
last = header
|
||||
|
||||
for index in range(header + 1, end):
|
||||
parsed = entry(lines[index])
|
||||
|
||||
if lines[index].strip():
|
||||
last = index
|
||||
|
||||
if parsed and parsed[0] == key:
|
||||
if parsed[2] == value:
|
||||
return None
|
||||
|
||||
lines[index] = f"{key}{parsed[1]}={value}\n"
|
||||
return f"{key} was [{parsed[2]}]"
|
||||
|
||||
lines.insert(last + 1, f"{key}={value}\n")
|
||||
return f"{key} was missing"
|
||||
|
||||
|
||||
def add_group(lines, containment, group):
|
||||
"""A new group, placed after the last of its containment's own."""
|
||||
prefix = f"[Containments][{containment}]"
|
||||
after = None
|
||||
|
||||
for name, header, end in groups(lines):
|
||||
if name == prefix or (name or "").startswith(prefix + "["):
|
||||
after = end
|
||||
|
||||
# Back over the blank lines that separate it from the next group, so
|
||||
# the new one sits with its containment and the spacing stays as
|
||||
# KConfig writes it.
|
||||
while after > 0 and not lines[after - 1].strip():
|
||||
after -= 1
|
||||
|
||||
if after > 0 and not lines[after - 1].endswith("\n"):
|
||||
lines[after - 1] += "\n"
|
||||
|
||||
block = ["\n", f"{group}\n"]
|
||||
block += [f"{key}={value}\n" for key, value in WALLPAPER_KEYS]
|
||||
lines[after:after] = block
|
||||
|
||||
|
||||
def converge(text):
|
||||
"""The file as it should be, and what was changed to make it so."""
|
||||
lines = text.splitlines(keepends=True)
|
||||
changes = []
|
||||
|
||||
for containment in desktops(lines):
|
||||
top = f"[Containments][{containment}]"
|
||||
change = set_entry(lines, top, "wallpaperplugin", WALLPAPER_PLUGIN)
|
||||
|
||||
if change:
|
||||
changes.append(f"desktop {containment}: {change}")
|
||||
|
||||
group = f"{top}[Wallpaper][{WALLPAPER_PLUGIN}][General]"
|
||||
|
||||
if find_group(lines, group) is None:
|
||||
add_group(lines, containment, group)
|
||||
changes.append(f"desktop {containment}: had no wallpaper configured")
|
||||
continue
|
||||
|
||||
for key, value in WALLPAPER_KEYS:
|
||||
change = set_entry(lines, group, key, value)
|
||||
|
||||
if change:
|
||||
changes.append(f"desktop {containment}: {change}")
|
||||
|
||||
return "".join(lines), changes
|
||||
|
||||
|
||||
def default_path():
|
||||
home = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
||||
return os.path.join(home, APPLETSRC)
|
||||
|
||||
|
||||
def shell_running(proc="/proc"):
|
||||
"""plasmashell running as this user. Read from /proc rather than asked
|
||||
of systemd, so a shell started any way at all counts, and rather than
|
||||
of pgrep, which nothing guarantees is installed."""
|
||||
uid = os.getuid()
|
||||
|
||||
for pid in os.listdir(proc):
|
||||
if not pid.isdigit():
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(os.path.join(proc, pid, "comm"), encoding="utf-8") as handle:
|
||||
name = handle.read().strip()
|
||||
|
||||
if name == "plasmashell" and os.stat(os.path.join(proc, pid)).st_uid == uid:
|
||||
return True
|
||||
except OSError:
|
||||
# Gone while it was being looked at.
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def write(path, text):
|
||||
"""Replace the file whole, so a full disk or a power cut leaves the old
|
||||
one rather than half of a new one."""
|
||||
temporary = f"{path}.classroom-new"
|
||||
mode = os.stat(path).st_mode & 0o7777
|
||||
|
||||
with open(temporary, "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
os.chmod(temporary, mode)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def main(argv):
|
||||
check = "--check" in argv
|
||||
paths = [arg for arg in argv if not arg.startswith("--")]
|
||||
path = paths[0] if paths else default_path()
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
before = handle.read()
|
||||
except FileNotFoundError:
|
||||
say("no desktop configuration yet; the layout writes the wallpaper")
|
||||
return 0
|
||||
except OSError as error:
|
||||
say(f"cannot read {path}: {error}")
|
||||
return 0
|
||||
|
||||
after, changes = converge(before)
|
||||
found = desktops(before.splitlines(keepends=True))
|
||||
|
||||
if not found:
|
||||
say("no desktop in the configuration yet; Plasma's default is the "
|
||||
"classroom's wallpaper package")
|
||||
return 0
|
||||
|
||||
if not changes:
|
||||
say(f"desktop {', '.join(found)}: the classroom wallpaper already")
|
||||
return 0
|
||||
|
||||
for change in changes:
|
||||
say(change)
|
||||
|
||||
if check:
|
||||
say("not changed (--check)")
|
||||
return 1
|
||||
|
||||
# The shell owns the live file. Anything else is just a file.
|
||||
if not paths and shell_running():
|
||||
say("plasmashell is running, and editing its file underneath it")
|
||||
say("destroys the panel. Apply it with:")
|
||||
say(" systemctl --user restart plasma-plasmashell.service")
|
||||
say("which repairs the file before the shell starts again.")
|
||||
return 1
|
||||
|
||||
try:
|
||||
write(path, after)
|
||||
except OSError as error:
|
||||
say(f"cannot write {path}: {error}")
|
||||
return 1
|
||||
|
||||
say(f"the wallpaper is now configured on desktop {', '.join(found)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -12,10 +12,9 @@ import button, no "where did you save it". This is the in between.
|
||||
|
||||
WHAT IT WILL NOT DO.
|
||||
|
||||
It never touches the teacher's download. The file Chrome wrote stays
|
||||
where Chrome wrote it, with the name Chrome gave it, for as long as
|
||||
the teacher leaves it there. Everything the library does is done to
|
||||
its own copy.
|
||||
It never leaves a duplicate in Downloads. Once a supported download is
|
||||
complete, its one original is moved into the managed library. Generated
|
||||
PDFs and page images live beside it and can always be rebuilt from it.
|
||||
|
||||
It never imports a Wonder Ink worksheet. Wonder Ink downloads into a
|
||||
directory of its own inside Downloads and those are worksheets, not
|
||||
@@ -31,11 +30,13 @@ WHAT IT WILL NOT DO.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import errno
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
||||
sys.path.insert(
|
||||
@@ -71,14 +72,184 @@ def new_directory(root):
|
||||
return directory
|
||||
|
||||
|
||||
def sync_directory(directory):
|
||||
"""Best-effort persistence for a rename or unlink transaction."""
|
||||
try:
|
||||
descriptor = os.open(directory, os.O_RDONLY)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def move_download(source, destination):
|
||||
"""Move one completed download without risking it on another filesystem.
|
||||
|
||||
rename is atomic on one filesystem. Across filesystems, copy to a hidden
|
||||
temporary file, flush it, publish it atomically, and only then unlink the
|
||||
source. If unlinking fails, the published copy is removed and the source
|
||||
remains the one original for the next scan.
|
||||
"""
|
||||
source = os.fspath(source)
|
||||
destination = os.fspath(destination)
|
||||
temporary = destination + ".moving"
|
||||
|
||||
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
||||
|
||||
if os.path.exists(destination) or os.path.exists(temporary):
|
||||
raise FileExistsError(destination)
|
||||
|
||||
try:
|
||||
os.replace(source, destination)
|
||||
sync_directory(os.path.dirname(destination))
|
||||
sync_directory(os.path.dirname(source))
|
||||
return
|
||||
except OSError as error:
|
||||
if error.errno != errno.EXDEV:
|
||||
raise
|
||||
|
||||
try:
|
||||
with open(source, "rb") as incoming, open(temporary, "xb") as outgoing:
|
||||
shutil.copyfileobj(incoming, outgoing, length=1024 * 1024)
|
||||
outgoing.flush()
|
||||
os.fsync(outgoing.fileno())
|
||||
|
||||
shutil.copystat(source, temporary, follow_symlinks=True)
|
||||
os.replace(temporary, destination)
|
||||
sync_directory(os.path.dirname(destination))
|
||||
|
||||
try:
|
||||
os.unlink(source)
|
||||
except OSError:
|
||||
# Roll back to the still-complete source rather than leave two
|
||||
# originals or make a failed move look successful.
|
||||
try:
|
||||
os.unlink(destination)
|
||||
sync_directory(os.path.dirname(destination))
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
sync_directory(os.path.dirname(source))
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(temporary)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def forget_presentation(presentation_id, root):
|
||||
"""Remove transaction records for one interrupted presentation."""
|
||||
data = library.ledger(root)
|
||||
changed = False
|
||||
|
||||
for mark, value in list(data.items()):
|
||||
if value == presentation_id:
|
||||
data.pop(mark, None)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
library.write_json(library.ledger_path(root), data)
|
||||
|
||||
|
||||
class NeedsMemory(RuntimeError):
|
||||
"""The kernel killed the converter or renderer for want of memory."""
|
||||
|
||||
|
||||
# An import the service was killed in the middle of is resumed at the next
|
||||
# start — once. Measured in the lab: a deck that needs more memory than
|
||||
# the machine has free is killed again every time, and with no limit the
|
||||
# service converted, died and restarted in a loop while the library said
|
||||
# "Getting this ready…" for ever.
|
||||
MAX_ATTEMPTS = 2
|
||||
|
||||
|
||||
def prepare(presentation):
|
||||
"""Build the derived PDF, pages and cover from a managed original."""
|
||||
data = presentation.data
|
||||
managed = presentation.managed_copy
|
||||
|
||||
# Counted and written BEFORE the work, so that a kill in the middle of
|
||||
# it is still counted when the service comes back.
|
||||
data["attempts"] = int(data.get("attempts") or 0) + 1
|
||||
data["attempt_started_at"] = library.stamp()
|
||||
presentation.save()
|
||||
|
||||
if managed is None or not managed.is_file():
|
||||
raise RuntimeError("the managed original is missing")
|
||||
|
||||
# A restart may find conversion or rendering output from the interrupted
|
||||
# attempt. The managed original is the recovery point; everything else is
|
||||
# derived and is rebuilt cleanly.
|
||||
if presentation.slides != managed:
|
||||
try:
|
||||
presentation.slides.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
shutil.rmtree(presentation.pages_directory, ignore_errors=True)
|
||||
|
||||
try:
|
||||
presentation.thumbnail.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if data["format"] == "pdf":
|
||||
shutil.copy2(managed, presentation.slides)
|
||||
else:
|
||||
converted, error = library.convert_to_pdf(
|
||||
managed, presentation.slides)
|
||||
|
||||
if not converted:
|
||||
if error == library.KILLED:
|
||||
raise NeedsMemory(error)
|
||||
|
||||
raise RuntimeError(error or "the file could not be converted")
|
||||
|
||||
pages, error = library.render_pages(
|
||||
presentation.slides, presentation.pages_directory)
|
||||
|
||||
if not pages:
|
||||
if error == library.KILLED:
|
||||
raise NeedsMemory(error)
|
||||
|
||||
raise RuntimeError(error or "the slides could not be rendered")
|
||||
|
||||
data["pages"] = pages
|
||||
data["title"] = library.unique_title(
|
||||
library.readable_title(
|
||||
data.get("original_name", ""),
|
||||
library.first_text(presentation.slides)),
|
||||
library.titles_in_use(presentation.directory.parent,
|
||||
exclude=presentation.id),
|
||||
)
|
||||
|
||||
library.render_thumbnail(presentation.page_image(1),
|
||||
presentation.thumbnail)
|
||||
|
||||
data["state"] = "ready"
|
||||
data["error"] = None
|
||||
data["error_kind"] = None
|
||||
presentation.save()
|
||||
|
||||
say(f"imported {data['title']!r} ({pages} slides)")
|
||||
|
||||
return presentation
|
||||
|
||||
|
||||
def import_one(source, root=None):
|
||||
"""Take one finished download into the library.
|
||||
|
||||
Ordered so that an interruption is always recoverable. The metadata
|
||||
is written first, saying "importing"; the sweep at startup throws
|
||||
away anything still saying that and lets it be imported again. A
|
||||
presentation only becomes visible to a teacher once every file it
|
||||
needs is on disk.
|
||||
Ordered so that an interruption is always recoverable. Metadata and the
|
||||
transaction record are written before the move. Startup either retries
|
||||
from Downloads or resumes from the moved managed original. A presentation
|
||||
becomes ready only once every derived file it needs is on disk.
|
||||
"""
|
||||
root = root if root is not None else library.library_root()
|
||||
mark = library.fingerprint(source)
|
||||
@@ -87,7 +258,7 @@ def import_one(source, root=None):
|
||||
return None
|
||||
|
||||
directory = new_directory(root)
|
||||
managed = directory / source.name
|
||||
managed = directory / "original" / source.name
|
||||
|
||||
data = {
|
||||
"id": directory.name,
|
||||
@@ -95,7 +266,7 @@ def import_one(source, root=None):
|
||||
"title": library.readable_title(source.name),
|
||||
"original_name": source.name,
|
||||
"source_path": str(source),
|
||||
"managed_copy": source.name,
|
||||
"managed_copy": str(managed.relative_to(directory)),
|
||||
"format": library.SUPPORTED.get(source.suffix.lower(), ""),
|
||||
"imported_at": library.stamp(),
|
||||
"last_presented_at": None,
|
||||
@@ -116,87 +287,116 @@ def import_one(source, root=None):
|
||||
say(f"importing {source.name}")
|
||||
|
||||
try:
|
||||
# THE TEACHER'S OWN DOWNLOAD IS COPIED, NEVER MOVED.
|
||||
shutil.copy2(source, managed)
|
||||
|
||||
if data["format"] == "pdf":
|
||||
# Already the thing everything else is converted into. A
|
||||
# session exported from Google Slides as a PDF keeps exactly
|
||||
# the appearance Google gave it, with no conversion at all.
|
||||
shutil.copy2(managed, presentation.slides)
|
||||
else:
|
||||
converted, error = library.convert_to_pdf(
|
||||
managed, presentation.slides)
|
||||
|
||||
if not converted:
|
||||
raise RuntimeError(error or "the file could not be converted")
|
||||
|
||||
pages, error = library.render_pages(
|
||||
presentation.slides, presentation.pages_directory)
|
||||
|
||||
if not pages:
|
||||
raise RuntimeError(error or "the slides could not be rendered")
|
||||
|
||||
data["pages"] = pages
|
||||
data["title"] = library.unique_title(
|
||||
library.readable_title(
|
||||
source.name, library.first_text(presentation.slides)),
|
||||
library.titles_in_use(root, exclude=presentation.id),
|
||||
)
|
||||
|
||||
library.render_thumbnail(presentation.page_image(1),
|
||||
presentation.thumbnail)
|
||||
|
||||
data["state"] = "ready"
|
||||
presentation.save()
|
||||
|
||||
say(f"imported {data['title']!r} ({pages} slides)")
|
||||
|
||||
return presentation
|
||||
move_download(source, managed)
|
||||
except Exception as error: # noqa: BLE001
|
||||
# A card that says it could not be opened, rather than a card
|
||||
# that opens onto nothing. The teacher's download is untouched
|
||||
# either way, which is what the message says.
|
||||
data["state"] = "failed"
|
||||
data["error"] = str(error)[:300]
|
||||
presentation.save()
|
||||
# A failed move leaves the source in Downloads. Remove the transaction
|
||||
# and retry on the next scan; there is no half-owned file to show.
|
||||
forget_presentation(presentation.id, root)
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
say(f"could not move {source.name}: {error}")
|
||||
return None
|
||||
|
||||
try:
|
||||
return prepare(presentation)
|
||||
except Exception as error: # noqa: BLE001
|
||||
# Conversion and rendering failures keep the managed original. The
|
||||
# teacher can remove it, and a technician can recover or inspect it;
|
||||
# no successfully downloaded file is lost because a derived asset
|
||||
# could not be made.
|
||||
fail(presentation, error)
|
||||
say(f"could not import {source.name}: {error}")
|
||||
|
||||
return presentation
|
||||
|
||||
|
||||
def sweep(root=None):
|
||||
"""Throw away imports that were interrupted, so they can happen again.
|
||||
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()
|
||||
|
||||
A reboot in the middle of a conversion leaves a directory that says
|
||||
"importing" and a ledger entry claiming the download is dealt with.
|
||||
Both go, and the next scan imports it properly.
|
||||
|
||||
def attempt(presentation):
|
||||
"""prepare(), with any failure recorded rather than raised."""
|
||||
try:
|
||||
prepare(presentation)
|
||||
except Exception as error: # noqa: BLE001
|
||||
fail(presentation, error)
|
||||
say(f"could not import {presentation.title!r}: {error}")
|
||||
|
||||
|
||||
def retry_requested(root=None):
|
||||
"""Presentations a teacher pressed Try again on, taken up now."""
|
||||
root = root if root is not None else library.library_root()
|
||||
retried = 0
|
||||
|
||||
if not root.is_dir():
|
||||
return 0
|
||||
|
||||
for presentation in library.library(root):
|
||||
if presentation.state != "retry":
|
||||
continue
|
||||
|
||||
managed = presentation.managed_copy
|
||||
|
||||
if managed is None or not managed.is_file():
|
||||
fail(presentation, RuntimeError("the managed original is missing"), "unreadable")
|
||||
continue
|
||||
|
||||
say(f"trying again: {presentation.title!r}")
|
||||
attempt(presentation)
|
||||
retried += 1
|
||||
|
||||
return retried
|
||||
|
||||
|
||||
def sweep(root=None):
|
||||
"""Recover imports interrupted during a move, conversion or rendering.
|
||||
|
||||
If Downloads still has the source, discard only the incomplete managed
|
||||
copy and let the next scan retry. If the source has already been moved,
|
||||
the managed original is the recovery point and its derived assets are
|
||||
rebuilt in place. This distinction prevents a crash after the move from
|
||||
deleting the only copy of a successful download.
|
||||
"""
|
||||
root = root if root is not None else library.library_root()
|
||||
|
||||
if not root.is_dir():
|
||||
return 0
|
||||
|
||||
ledger = library.ledger(root)
|
||||
stale = 0
|
||||
|
||||
for presentation in library.library(root):
|
||||
if presentation.state != "importing":
|
||||
continue
|
||||
|
||||
say(f"clearing an import that did not finish: {presentation.title!r}")
|
||||
source_text = presentation.data.get("source_path")
|
||||
source = Path(source_text) if source_text else None
|
||||
managed = presentation.managed_copy
|
||||
|
||||
for mark, value in list(ledger.items()):
|
||||
if value == presentation.id:
|
||||
ledger.pop(mark, None)
|
||||
if (managed is not None and managed.is_file()
|
||||
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}")
|
||||
attempt(presentation)
|
||||
|
||||
stale += 1
|
||||
continue
|
||||
|
||||
say(f"retrying an interrupted move: {presentation.title!r}")
|
||||
forget_presentation(presentation.id, root)
|
||||
shutil.rmtree(presentation.directory, ignore_errors=True)
|
||||
stale += 1
|
||||
|
||||
if stale:
|
||||
library.write_json(library.ledger_path(root), ledger)
|
||||
|
||||
return stale
|
||||
|
||||
|
||||
@@ -222,9 +422,8 @@ def scan(root=None, downloads=None):
|
||||
def cleanup(root=None, moment=None):
|
||||
"""Remove presentations whose time is up.
|
||||
|
||||
Only ever the library's own directory, and never the presentation on
|
||||
the screen. The teacher's download is not part of this and is not
|
||||
looked at.
|
||||
Only ever the selected managed directory, including its one original and
|
||||
derived assets, and never the presentation on the screen.
|
||||
"""
|
||||
root = root if root is not None else library.library_root()
|
||||
lock = library.presenting_lock()
|
||||
@@ -254,7 +453,7 @@ def cleanup(root=None, moment=None):
|
||||
|
||||
say(f"removing {presentation.title!r} — {why}")
|
||||
|
||||
shutil.rmtree(presentation.directory, ignore_errors=True)
|
||||
if library.remove(presentation):
|
||||
removed += 1
|
||||
|
||||
return removed
|
||||
@@ -283,6 +482,23 @@ def status(root=None):
|
||||
return 0
|
||||
|
||||
|
||||
def hold_lock(root=None):
|
||||
"""The importer's lock, held for as long as this process lives."""
|
||||
import fcntl
|
||||
|
||||
root = root if root is not None else library.library_root()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
handle = open(root / ".importer.lock", "a")
|
||||
|
||||
try:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
handle.close()
|
||||
return None
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bring finished downloads into the Presentations library.")
|
||||
@@ -308,7 +524,17 @@ def main():
|
||||
say(f"removed {removed} expired presentation(s)")
|
||||
return 0
|
||||
|
||||
# One importer at a time, whoever started it. Conversions are the
|
||||
# memory the machine runs short of, so two at once — the service and a
|
||||
# technician's --once, say — must not happen.
|
||||
held = hold_lock()
|
||||
|
||||
if held is None:
|
||||
say("another importer is already running; not starting a second")
|
||||
return 0
|
||||
|
||||
sweep()
|
||||
retry_requested()
|
||||
|
||||
if arguments.once:
|
||||
imported = scan()
|
||||
@@ -322,6 +548,7 @@ def main():
|
||||
|
||||
while True:
|
||||
try:
|
||||
retry_requested()
|
||||
scan()
|
||||
|
||||
if time.monotonic() - last_cleanup > CLEANUP_SECONDS:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Three buttons on a card — Present, Quick Look, Remove — and nothing else.
|
||||
No storage locations, no file names, no import controls, no settings, no
|
||||
retention. A teacher who has downloaded a session in Chrome opens this,
|
||||
retention. A teacher who has downloaded a session opens this,
|
||||
recognises tonight's by its picture, and presses Present.
|
||||
|
||||
WHY THE PRESENTER IS IN THIS WINDOW AND NOT A SEPARATE PROGRAM.
|
||||
@@ -31,7 +31,6 @@ LibreOffice and poppler, and a picture cannot open a file dialog.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import gi
|
||||
@@ -106,17 +105,73 @@ button.danger:hover {{ background-color: {negative}; color: {on_accent}; }}
|
||||
padding: 6px 12px;
|
||||
font-size: 16px;
|
||||
}}
|
||||
.current-cover {{
|
||||
color: {on_accent};
|
||||
background-color: {accent};
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def scaled_pixbuf(path, width):
|
||||
def scaled_pixbuf(path, width, height=-1):
|
||||
try:
|
||||
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
str(path), width, -1, True)
|
||||
str(path), width, height, True)
|
||||
except (GLib.Error, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def monitor_workarea(window=None):
|
||||
"""The usable logical pixels on the monitor, excluding Plasma's panel."""
|
||||
display = Gdk.Display.get_default()
|
||||
|
||||
if display is None:
|
||||
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
|
||||
|
||||
monitor = None
|
||||
|
||||
if window is not None and window.get_window() is not None:
|
||||
monitor = display.get_monitor_at_window(window.get_window())
|
||||
|
||||
if monitor is None:
|
||||
monitor = display.get_primary_monitor()
|
||||
|
||||
if monitor is None and display.get_n_monitors() > 0:
|
||||
monitor = display.get_monitor(0)
|
||||
|
||||
if monitor is None:
|
||||
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
|
||||
|
||||
return monitor.get_workarea()
|
||||
|
||||
|
||||
def monitor_geometry(window=None):
|
||||
"""The full logical monitor rectangle, including the panel area."""
|
||||
display = Gdk.Display.get_default()
|
||||
|
||||
if display is None:
|
||||
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
|
||||
|
||||
monitor = None
|
||||
|
||||
if window is not None and window.get_window() is not None:
|
||||
monitor = display.get_monitor_at_window(window.get_window())
|
||||
|
||||
if monitor is None:
|
||||
monitor = display.get_primary_monitor()
|
||||
|
||||
if monitor is None and display.get_n_monitors() > 0:
|
||||
monitor = display.get_monitor(0)
|
||||
|
||||
if monitor is None:
|
||||
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
|
||||
|
||||
return monitor.get_geometry()
|
||||
|
||||
|
||||
class Presenter(Gtk.Window):
|
||||
"""The slideshow. A fullscreen window showing one picture at a time.
|
||||
|
||||
@@ -130,19 +185,30 @@ class Presenter(Gtk.Window):
|
||||
press and nowhere else to end up.
|
||||
"""
|
||||
|
||||
def __init__(self, presentation, start_page=1, on_finish=None):
|
||||
super().__init__(title=presentation.title)
|
||||
def __init__(self, presentation, start_page=1, on_finish=None,
|
||||
display_geometry=None):
|
||||
# A stable title gives KWin a presenter-only match. The library and
|
||||
# Quick Look share this application's Wayland app id, so matching the
|
||||
# app id alone would fullscreen every window.
|
||||
super().__init__(title="Presenting")
|
||||
|
||||
self.presentation = presentation
|
||||
self.on_finish = on_finish
|
||||
self.page = min(max(start_page, 1), presentation.pages or 1)
|
||||
self.counted = False
|
||||
self.finished = False
|
||||
self.display_geometry = display_geometry or monitor_geometry()
|
||||
# How many times fullscreen has been asked for without it
|
||||
# sticking. See _state.
|
||||
self.refused = 0
|
||||
|
||||
self.set_app_paintable(True)
|
||||
self.set_decorated(False)
|
||||
self.set_skip_taskbar_hint(True)
|
||||
self.set_role("classroom-presenter")
|
||||
self.set_default_size(
|
||||
self.display_geometry.width, self.display_geometry.height)
|
||||
self.set_size_request(
|
||||
self.display_geometry.width, self.display_geometry.height)
|
||||
self.fullscreen()
|
||||
# A slideshow is the only thing on the screen while it is running.
|
||||
self.set_keep_above(True)
|
||||
@@ -151,16 +217,48 @@ class Presenter(Gtk.Window):
|
||||
black.parse("#000000")
|
||||
self.override_background_color(Gtk.StateFlags.NORMAL, black)
|
||||
|
||||
# Paint an explicit full-window layer behind the image. The old
|
||||
# app-paintable Gtk.Window never drew its own background, so KWin
|
||||
# correctly made it fullscreen while the panel and desktop remained
|
||||
# visible through the transparent space around a portrait slide.
|
||||
self.image = Gtk.Image()
|
||||
self.add(self.image)
|
||||
self.image.set_halign(Gtk.Align.CENTER)
|
||||
self.image.set_valign(Gtk.Align.CENTER)
|
||||
stage = Gtk.Overlay()
|
||||
stage.set_size_request(
|
||||
self.display_geometry.width, self.display_geometry.height)
|
||||
background = Gtk.DrawingArea()
|
||||
background.connect("draw", self._paint_black)
|
||||
stage.add(background)
|
||||
stage.add_overlay(self.image)
|
||||
self.add(stage)
|
||||
|
||||
self.connect("key-press-event", self._key)
|
||||
self.connect("delete-event", lambda *_: self.finish())
|
||||
self.connect("size-allocate", self._resized)
|
||||
self.connect("window-state-event", self._state)
|
||||
self.connect("map-event", self._mapped)
|
||||
|
||||
self._drawn_at = (0, 0, 0)
|
||||
|
||||
def _paint_black(self, _widget, context):
|
||||
context.set_source_rgb(0, 0, 0)
|
||||
context.paint()
|
||||
return False
|
||||
|
||||
def _mapped(self, *_):
|
||||
# GTK can make the first request before the Wayland surface exists.
|
||||
# Repeat it after mapping; KWin's matching rule is the compositor-side
|
||||
# guarantee, while this keeps the window correct on other desktops.
|
||||
self.fullscreen()
|
||||
GLib.idle_add(self._mapped_fullscreen)
|
||||
return False
|
||||
|
||||
def _mapped_fullscreen(self):
|
||||
if not self.finished:
|
||||
self.fullscreen()
|
||||
return False
|
||||
|
||||
def _state(self, _widget, event):
|
||||
"""Put the slideshow back on the whole screen if it is taken off it.
|
||||
|
||||
@@ -204,7 +302,9 @@ class Presenter(Gtk.Window):
|
||||
def show_page(self):
|
||||
path = self.presentation.page_image(self.page)
|
||||
allocation = self.get_allocation()
|
||||
want = (self.page, allocation.width, allocation.height)
|
||||
width = max(allocation.width, self.display_geometry.width)
|
||||
height = max(allocation.height, self.display_geometry.height)
|
||||
want = (self.page, width, height)
|
||||
|
||||
if want == self._drawn_at:
|
||||
return
|
||||
@@ -212,7 +312,7 @@ class Presenter(Gtk.Window):
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
pixbuf = self._fitted(path, allocation.width, allocation.height)
|
||||
pixbuf = self._fitted(path, width, height)
|
||||
|
||||
if pixbuf is None:
|
||||
return
|
||||
@@ -244,12 +344,30 @@ class Presenter(Gtk.Window):
|
||||
ratio = min(width / original.get_width(),
|
||||
height / original.get_height())
|
||||
|
||||
return original.scale_simple(
|
||||
max(1, int(original.get_width() * ratio)),
|
||||
max(1, int(original.get_height() * ratio)),
|
||||
fitted_width = max(1, int(original.get_width() * ratio))
|
||||
fitted_height = max(1, int(original.get_height() * ratio))
|
||||
fitted = original.scale_simple(
|
||||
fitted_width,
|
||||
fitted_height,
|
||||
GdkPixbuf.InterpType.BILINEAR,
|
||||
)
|
||||
|
||||
# Make the entire client buffer opaque. On Plasma Wayland, painting a
|
||||
# container behind a smaller Gtk.Image still left the surface outside
|
||||
# the image transparent: KWin reported a correct 1920x1080 fullscreen
|
||||
# window, but the panel and desktop showed through the letterbox. A
|
||||
# full-size RGB pixbuf makes black part of the presented content.
|
||||
canvas = GdkPixbuf.Pixbuf.new(
|
||||
GdkPixbuf.Colorspace.RGB, False, 8, width, height)
|
||||
canvas.fill(0x000000ff)
|
||||
fitted.copy_area(
|
||||
0, 0, fitted_width, fitted_height, canvas,
|
||||
(width - fitted_width) // 2,
|
||||
(height - fitted_height) // 2,
|
||||
)
|
||||
|
||||
return canvas
|
||||
|
||||
# -- moving about ---------------------------------------------
|
||||
|
||||
def go(self, delta):
|
||||
@@ -309,11 +427,15 @@ class Presenter(Gtk.Window):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if self.on_finish is not None:
|
||||
self.on_finish()
|
||||
|
||||
on_finish = self.on_finish
|
||||
self.destroy()
|
||||
|
||||
# Map and raise the library only after the fullscreen surface is gone.
|
||||
# Showing it underneath a still-live fullscreen window could leave the
|
||||
# nested KWin session with no newly damaged return surface on Escape.
|
||||
if on_finish is not None:
|
||||
on_finish()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -324,7 +446,7 @@ class QuickLook(Gtk.Window):
|
||||
purpose because conflating any two of them would be wrong:
|
||||
|
||||
browse which slide is on this screen right now;
|
||||
Use this slide which slide the library card shows;
|
||||
Use as cover which slide the library card shows;
|
||||
Present start the slideshow, from the beginning.
|
||||
|
||||
Browsing is not presenting: coming in here, looking through every
|
||||
@@ -340,11 +462,21 @@ class QuickLook(Gtk.Window):
|
||||
self.presentation = presentation
|
||||
self.on_close = on_close
|
||||
self.on_present = on_present
|
||||
self.page = 1
|
||||
# Open on the familiar card image. Teachers can immediately see what
|
||||
# "cover" means, while Present still starts independently at slide 1.
|
||||
self.page = presentation.thumbnail_page
|
||||
|
||||
self.set_transient_for(parent)
|
||||
self.set_modal(True)
|
||||
self.set_default_size(1000, 760)
|
||||
workarea = monitor_workarea(parent)
|
||||
window_width = min(1000, max(560, int(workarea.width * 0.86)))
|
||||
# Leave enough room for decorations and a panel even on compositors
|
||||
# that report the full monitor as the work area (the nested Plasma lab
|
||||
# does). At 1366x768 this remains comfortably usable at larger scale.
|
||||
window_height = min(760, max(420, int(workarea.height * 0.75)))
|
||||
self.set_default_size(window_width, window_height)
|
||||
self.preview_width = max(320, window_width - 32)
|
||||
self.preview_height = max(180, window_height - 210)
|
||||
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
outer.set_border_width(16)
|
||||
@@ -365,7 +497,7 @@ class QuickLook(Gtk.Window):
|
||||
browse = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
browse.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
self.back = Gtk.Button(label="◀ Previous")
|
||||
self.back = Gtk.Button(label="Previous")
|
||||
self.back.connect("clicked", lambda *_: self.go(-1))
|
||||
browse.pack_start(self.back, False, False, 0)
|
||||
|
||||
@@ -373,7 +505,7 @@ class QuickLook(Gtk.Window):
|
||||
self.counter.get_style_context().add_class("subtle")
|
||||
browse.pack_start(self.counter, False, False, 12)
|
||||
|
||||
self.forward = Gtk.Button(label="Next ▶")
|
||||
self.forward = Gtk.Button(label="Next")
|
||||
self.forward.connect("clicked", lambda *_: self.go(1))
|
||||
browse.pack_start(self.forward, False, False, 0)
|
||||
|
||||
@@ -382,9 +514,15 @@ class QuickLook(Gtk.Window):
|
||||
# The three things this window is for.
|
||||
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
|
||||
self.choose = Gtk.Button(label="Use this slide as the picture")
|
||||
self.choose = Gtk.Button(label="Use as cover")
|
||||
self.choose.connect("clicked", self._choose)
|
||||
actions.pack_start(self.choose, False, False, 0)
|
||||
|
||||
self.cover = Gtk.Label(label="Current cover")
|
||||
self.cover.get_style_context().add_class("current-cover")
|
||||
self.cover_action = Gtk.Stack()
|
||||
self.cover_action.add_named(self.choose, "choose")
|
||||
self.cover_action.add_named(self.cover, "current")
|
||||
actions.pack_start(self.cover_action, False, False, 0)
|
||||
|
||||
actions.pack_start(Gtk.Box(), True, True, 0)
|
||||
|
||||
@@ -408,14 +546,18 @@ class QuickLook(Gtk.Window):
|
||||
|
||||
def show_page(self, force=True):
|
||||
allocation = self.slide.get_allocation()
|
||||
width = max(320, allocation.width or 900)
|
||||
want = (self.page, width, 0)
|
||||
width = min(self.preview_width,
|
||||
max(1, allocation.width or self.preview_width))
|
||||
height = min(self.preview_height,
|
||||
max(1, allocation.height or self.preview_height))
|
||||
want = (self.page, width, height)
|
||||
|
||||
if want == self._drawn_at and not force:
|
||||
return
|
||||
|
||||
path = self.presentation.page_image(self.page)
|
||||
pixbuf = scaled_pixbuf(path, width) if path.exists() else None
|
||||
pixbuf = (scaled_pixbuf(path, width, height)
|
||||
if path.exists() else None)
|
||||
|
||||
if pixbuf is not None:
|
||||
self.slide.set_from_pixbuf(pixbuf)
|
||||
@@ -423,12 +565,12 @@ class QuickLook(Gtk.Window):
|
||||
|
||||
total = self.presentation.pages
|
||||
chosen = self.presentation.thumbnail_page
|
||||
note = " · this is the library picture" if self.page == chosen else ""
|
||||
self.counter.set_text(f"Slide {self.page} of {total}{note}")
|
||||
self.counter.set_text(f"Slide {self.page} of {total}")
|
||||
|
||||
self.back.set_sensitive(self.page > 1)
|
||||
self.forward.set_sensitive(self.page < total)
|
||||
self.choose.set_sensitive(self.page != chosen)
|
||||
self.cover_action.set_visible_child_name(
|
||||
"current" if self.page == chosen else "choose")
|
||||
|
||||
def go(self, delta):
|
||||
target = self.page + delta
|
||||
@@ -483,7 +625,7 @@ class Library(Gtk.Window):
|
||||
def __init__(self):
|
||||
super().__init__(title="Presentations")
|
||||
|
||||
self.set_default_size(1180, 820)
|
||||
self.set_default_size(680, 420)
|
||||
self.presenter = None
|
||||
self._signature = None
|
||||
|
||||
@@ -512,7 +654,7 @@ class Library(Gtk.Window):
|
||||
self.grid.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||
self.grid.set_row_spacing(16)
|
||||
self.grid.set_column_spacing(16)
|
||||
self.grid.set_homogeneous(True)
|
||||
self.grid.set_homogeneous(False)
|
||||
scroller.add(self.grid)
|
||||
|
||||
self.connect("key-press-event", self._key)
|
||||
@@ -540,10 +682,36 @@ class Library(Gtk.Window):
|
||||
would make the buttons unclickable.
|
||||
"""
|
||||
return tuple(
|
||||
(p.id, p.state, p.pages, p.thumbnail_page, p.title)
|
||||
(p.id, p.state, p.pages, p.thumbnail_page, p.title, p.failure)
|
||||
for p in presentations
|
||||
)
|
||||
|
||||
def _fit_window(self, count, tall=False):
|
||||
"""Size to the cards while staying inside the panel's work area."""
|
||||
workarea = monitor_workarea(self)
|
||||
maximum_width = max(420, int(workarea.width * 0.92))
|
||||
maximum_height = max(360, int(workarea.height * 0.90))
|
||||
|
||||
if count == 0:
|
||||
width = min(maximum_width, 640)
|
||||
height = min(maximum_height, 300)
|
||||
else:
|
||||
# FlowBox includes the card's CSS padding and the three buttons'
|
||||
# natural widths when deciding whether another child fits. Budget
|
||||
# that chrome here too, or two cards can wrap into two rows while
|
||||
# the window is sized for one.
|
||||
card_with_gap = CARD_WIDTH + 40
|
||||
available = max(1, (maximum_width - 40) // card_with_gap)
|
||||
columns = min(count, available, 4)
|
||||
rows = (count + columns - 1) // columns
|
||||
width = min(maximum_width, max(420, 40 + columns * card_with_gap))
|
||||
# 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)
|
||||
|
||||
def refresh(self):
|
||||
presentations = library.library()
|
||||
signature = self.signature(presentations)
|
||||
@@ -557,12 +725,10 @@ class Library(Gtk.Window):
|
||||
self.grid.remove(child)
|
||||
|
||||
if not presentations:
|
||||
self.note.set_text(
|
||||
"Download a presentation in Chrome and it will appear here.")
|
||||
self.note.set_text("")
|
||||
empty = Gtk.Label(
|
||||
label="Nothing here yet.\n\n"
|
||||
"Download a PowerPoint or PDF in Chrome, and it will "
|
||||
"appear on this screen by itself.")
|
||||
label="No presentations yet\n\n"
|
||||
"Download a PowerPoint or PDF to get started.")
|
||||
empty.get_style_context().add_class("empty")
|
||||
empty.set_justify(Gtk.Justification.CENTER)
|
||||
self.grid.add(empty)
|
||||
@@ -572,14 +738,19 @@ class Library(Gtk.Window):
|
||||
# something is "being prepared" when it never will be is how
|
||||
# somebody ends up waiting through a Bible study for it.
|
||||
ready = sum(1 for p in presentations if p.ready)
|
||||
broken = sum(1 for p in presentations if p.failed)
|
||||
working = len(presentations) - ready - broken
|
||||
again = sum(1 for p in presentations if p.failure == "memory")
|
||||
broken = sum(1 for p in presentations
|
||||
if p.failed and p.failure != "memory")
|
||||
working = len(presentations) - ready - broken - again
|
||||
|
||||
parts = [f"{ready} ready to present"]
|
||||
|
||||
if working:
|
||||
parts.append(f"{working} still being prepared")
|
||||
|
||||
if again:
|
||||
parts.append(f"{again} to try again")
|
||||
|
||||
if broken:
|
||||
parts.append(f"{broken} could not be opened")
|
||||
|
||||
@@ -589,31 +760,37 @@ class Library(Gtk.Window):
|
||||
self.grid.add(self.card(presentation))
|
||||
|
||||
self.grid.show_all()
|
||||
self._fit_window(len(presentations),
|
||||
tall=any(p.failure == "memory" for p in presentations))
|
||||
|
||||
def card(self, presentation):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
box.get_style_context().add_class("card")
|
||||
box.set_size_request(CARD_WIDTH, -1)
|
||||
|
||||
picture = Gtk.Image()
|
||||
picture.get_style_context().add_class("thumb")
|
||||
pixbuf = None
|
||||
picture_frame = Gtk.EventBox()
|
||||
picture_frame.get_style_context().add_class("thumb")
|
||||
picture_frame.set_size_request(CARD_WIDTH - 28, 180)
|
||||
|
||||
if presentation.thumbnail.exists():
|
||||
pixbuf = scaled_pixbuf(presentation.thumbnail, CARD_WIDTH - 28)
|
||||
|
||||
picture = Gtk.Image()
|
||||
pixbuf = scaled_pixbuf(
|
||||
presentation.thumbnail, CARD_WIDTH - 28, 180)
|
||||
if pixbuf is not None:
|
||||
picture.set_from_pixbuf(pixbuf)
|
||||
else:
|
||||
picture = Gtk.Image()
|
||||
picture.set_from_icon_name("view-presentation", Gtk.IconSize.DIALOG)
|
||||
picture.set_pixel_size(96)
|
||||
|
||||
box.pack_start(picture, False, False, 0)
|
||||
picture_frame.add(picture)
|
||||
box.pack_start(picture_frame, False, False, 0)
|
||||
|
||||
title = Gtk.Label(label=presentation.title, xalign=0)
|
||||
title.get_style_context().add_class("card-title")
|
||||
title.set_line_wrap(True)
|
||||
title.set_max_width_chars(26)
|
||||
title.set_max_width_chars(32)
|
||||
title.set_size_request(CARD_WIDTH - 28, -1)
|
||||
box.pack_start(title, False, False, 0)
|
||||
|
||||
note = Gtk.Label(xalign=0)
|
||||
@@ -634,14 +811,26 @@ class Library(Gtk.Window):
|
||||
look = Gtk.Button(label="Quick Look")
|
||||
look.connect("clicked", lambda *_: self.quick_look(presentation))
|
||||
buttons.pack_start(look, True, True, 0)
|
||||
elif presentation.failure == "memory":
|
||||
# The computer ran short of memory getting it ready, or the
|
||||
# importer stopped part way. Worth another go with less open;
|
||||
# never a converter's message, a path or a terminal.
|
||||
note.set_text("This needed more memory than the computer had "
|
||||
"free. Close other programs, then choose "
|
||||
"Try again.")
|
||||
note.set_line_wrap(True)
|
||||
note.set_max_width_chars(34)
|
||||
|
||||
again = Gtk.Button(label="Try again")
|
||||
again.get_style_context().add_class("primary")
|
||||
again.connect("clicked", lambda *_: self.try_again(presentation))
|
||||
buttons.pack_start(again, True, True, 0)
|
||||
elif presentation.failed:
|
||||
# SHORT, AND IN WORDS A TEACHER CAN ACT ON.
|
||||
#
|
||||
# Never the converter's error text, never a path, never a
|
||||
# terminal. The one useful fact is that their own download is
|
||||
# still where they left it.
|
||||
note.set_text("This file could not be opened.\n"
|
||||
"Your download in Chrome is still there.")
|
||||
# Never the converter's error text, a path or a terminal.
|
||||
# The managed original remains recoverable until Remove.
|
||||
note.set_text("This file could not be opened.")
|
||||
note.set_line_wrap(True)
|
||||
else:
|
||||
note.set_text("Getting this ready…")
|
||||
@@ -678,9 +867,12 @@ class Library(Gtk.Window):
|
||||
pass
|
||||
|
||||
self.presenter = Presenter(
|
||||
presentation, start_page, on_finish=self._presented)
|
||||
presentation, start_page, on_finish=self._presented,
|
||||
display_geometry=monitor_geometry(self))
|
||||
self.hide()
|
||||
self.presenter.show_all()
|
||||
self.presenter.present()
|
||||
self.presenter.fullscreen()
|
||||
|
||||
def _presented(self):
|
||||
self.presenter = None
|
||||
@@ -709,18 +901,24 @@ class Library(Gtk.Window):
|
||||
self._signature = None
|
||||
self.refresh()
|
||||
|
||||
def try_again(self, presentation):
|
||||
# The importer takes it up within a few seconds; the card says
|
||||
# "Getting this ready" again at the next refresh.
|
||||
if library.request_retry(presentation):
|
||||
self.refresh()
|
||||
|
||||
def remove(self, presentation):
|
||||
dialog = Gtk.MessageDialog(
|
||||
transient_for=self,
|
||||
modal=True,
|
||||
message_type=Gtk.MessageType.QUESTION,
|
||||
buttons=Gtk.ButtonsType.NONE,
|
||||
text=f"Remove “{presentation.title}”?",
|
||||
text="Remove this presentation?",
|
||||
)
|
||||
dialog.format_secondary_text(
|
||||
"It will be taken off this screen.\n"
|
||||
"Your download in Chrome is not affected.")
|
||||
dialog.add_button("Keep it", Gtk.ResponseType.CANCEL)
|
||||
f"“{presentation.title}”\n\n"
|
||||
"This will delete it from this computer.")
|
||||
dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
|
||||
dialog.add_button("Remove", Gtk.ResponseType.OK)
|
||||
dialog.set_default_response(Gtk.ResponseType.CANCEL)
|
||||
|
||||
@@ -730,7 +928,11 @@ class Library(Gtk.Window):
|
||||
if answer != Gtk.ResponseType.OK:
|
||||
return
|
||||
|
||||
shutil.rmtree(presentation.directory, ignore_errors=True)
|
||||
if not library.remove(presentation):
|
||||
self.trouble(
|
||||
presentation,
|
||||
"Finish presenting before removing this presentation.")
|
||||
return
|
||||
self._signature = None
|
||||
self.refresh()
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reboot after an update, but only once nobody is using the computer.
|
||||
|
||||
The nightly update check (ekklesia-classroom-update.timer, 03:00, and
|
||||
Persistent so that a computer switched off at 03:00 checks when it is next
|
||||
switched on) used to reboot the moment an update was installed. At 03:00
|
||||
that is harmless. Switched on in the morning after a night off, it meant a
|
||||
classroom computer restarting itself half an hour into the day, in front
|
||||
of a teacher.
|
||||
|
||||
So the updater installs, then starts this, and this reboots only when:
|
||||
|
||||
nothing has touched any keyboard, mouse, touchpad or touchscreen for
|
||||
IDLE_SECONDS — seen here as kernel input events, which is what every
|
||||
one of them produces, whatever the desktop is doing; and
|
||||
|
||||
no sound has played for as long — a video to the room has no input for
|
||||
an hour and is not an idle computer, and the end of it is not the
|
||||
moment to restart either. Seen as the kernel's own playback state
|
||||
(/proc/asound/.../status says RUNNING while a stream plays).
|
||||
|
||||
At 03:00 that is about 03:30. In the middle of a morning it is half an
|
||||
hour after the teacher last touched it. If the computer is switched off
|
||||
first, nothing is lost: the update is installed, and the next start is on
|
||||
it. Until then the running session carries on as it was.
|
||||
|
||||
The time starts when this starts: input from before cannot be seen, so a
|
||||
computer is only called idle after this has watched it be idle.
|
||||
|
||||
classroom-reboot-when-idle [--idle-seconds N] [--reboot-command CMD...]
|
||||
|
||||
The options are for the lab; the service runs it with none.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
IDLE_SECONDS = 30 * 60
|
||||
|
||||
# How often it looks: input is noticed at once, the rest once a minute is
|
||||
# plenty for a half-hour decision.
|
||||
CHECK_SECONDS = 60
|
||||
|
||||
INPUT_GLOB = "/dev/input/event*"
|
||||
ASOUND_ROOT = "/proc/asound"
|
||||
|
||||
|
||||
def log(message):
|
||||
print(f"[classroom-reboot-when-idle] {message}", flush=True)
|
||||
|
||||
|
||||
def audio_playing(root=ASOUND_ROOT):
|
||||
"""Is any sound card playing right now?"""
|
||||
for status in glob.glob(os.path.join(root, "card*", "pcm*p", "sub*", "status")):
|
||||
try:
|
||||
with open(status, encoding="utf-8", errors="replace") as handle:
|
||||
if handle.readline().strip() == "state: RUNNING":
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def should_reboot(now, last_input, idle_seconds, playing):
|
||||
"""The decision: long enough without input, and nothing playing."""
|
||||
return not playing and now - last_input >= idle_seconds
|
||||
|
||||
|
||||
class Inputs:
|
||||
"""Every input device the kernel has, reopened as they come and go."""
|
||||
|
||||
def __init__(self, pattern=INPUT_GLOB):
|
||||
self.pattern = pattern
|
||||
self.open = {}
|
||||
|
||||
def rescan(self):
|
||||
present = set(glob.glob(self.pattern))
|
||||
|
||||
for fd, path in list(self.open.items()):
|
||||
if path not in present:
|
||||
os.close(fd)
|
||||
del self.open[fd]
|
||||
|
||||
known = set(self.open.values())
|
||||
|
||||
for path in sorted(present - known):
|
||||
try:
|
||||
self.open[os.open(path, os.O_RDONLY | os.O_NONBLOCK)] = path
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
def wait(self, seconds):
|
||||
"""True if anything happened on any device within SECONDS."""
|
||||
if not self.open:
|
||||
time.sleep(seconds)
|
||||
return False
|
||||
|
||||
ready, _, _ = select.select(list(self.open), [], [], seconds)
|
||||
seen = False
|
||||
|
||||
for fd in ready:
|
||||
try:
|
||||
while os.read(fd, 4096):
|
||||
seen = True
|
||||
except BlockingIOError:
|
||||
pass
|
||||
except OSError:
|
||||
# A device that went away mid-read; the next rescan drops it.
|
||||
pass
|
||||
|
||||
return seen
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--idle-seconds", type=float, default=IDLE_SECONDS)
|
||||
parser.add_argument("--check-seconds", type=float, default=CHECK_SECONDS)
|
||||
parser.add_argument("--input-glob", default=INPUT_GLOB)
|
||||
parser.add_argument("--asound-root", default=ASOUND_ROOT)
|
||||
parser.add_argument("--reboot-command", nargs="+", default=["systemctl", "reboot"])
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
inputs = Inputs(args.input_glob)
|
||||
inputs.rescan()
|
||||
last_input = time.monotonic()
|
||||
log(f"an update is installed; rebooting once nobody has used this computer for "
|
||||
f"{round(args.idle_seconds / 60)} minutes and nothing is playing "
|
||||
f"(watching {len(inputs.open)} input devices)")
|
||||
|
||||
while True:
|
||||
deadline = time.monotonic() + args.check_seconds
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
if inputs.wait(max(0.0, min(5.0, deadline - time.monotonic()))):
|
||||
last_input = time.monotonic()
|
||||
|
||||
inputs.rescan()
|
||||
playing = audio_playing(args.asound_root)
|
||||
|
||||
if playing:
|
||||
# Sound counts as someone there: a 40-minute video ending is
|
||||
# not the moment to restart, so the half hour starts after it.
|
||||
last_input = time.monotonic()
|
||||
|
||||
if should_reboot(time.monotonic(), last_input, args.idle_seconds, playing):
|
||||
log("nobody has used it, and nothing is playing: rebooting")
|
||||
return subprocess.run(args.reboot_command).returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -5,6 +5,7 @@ import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -13,6 +14,22 @@ from urllib.parse import urljoin
|
||||
|
||||
CONFIG = "/etc/ekklesia-classroom/device.conf"
|
||||
LOCKFILE = "/run/ekklesia-classroom-update.lock"
|
||||
REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"
|
||||
CACHE_DIR = "/var/cache/ekklesia-classroom"
|
||||
PACKAGE = "ekklesia-classroom"
|
||||
|
||||
# What a published version looks like: 0.2.6 for a feature release,
|
||||
# 0.2.6.1 for a maintenance release of it. Anything else in a manifest is
|
||||
# a mistake on the server, and a classroom does nothing with it. The same
|
||||
# pattern is in tools/release-lib.sh; tests/test-release-versions.sh holds
|
||||
# the two to one list of good and bad versions.
|
||||
#
|
||||
# Only the SHAPE is decided here. Which of two versions is newer is always
|
||||
# dpkg's answer, because dpkg is what installs them.
|
||||
VERSION_PATTERN = re.compile(
|
||||
r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.[1-9][0-9]*)?$"
|
||||
)
|
||||
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def log(message):
|
||||
@@ -50,13 +67,70 @@ def installed_version():
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def valid_version(version):
|
||||
return isinstance(version, str) and bool(VERSION_PATTERN.match(version))
|
||||
|
||||
|
||||
def version_is_newer(candidate, current):
|
||||
result = subprocess.run(
|
||||
["dpkg", "--compare-versions", candidate, "gt", current]
|
||||
["dpkg", "--compare-versions", candidate, "gt", current],
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def manifest_problem(manifest):
|
||||
"""Why this manifest must not be acted on, or None if it may be.
|
||||
|
||||
A manifest names a version, the package file for exactly that
|
||||
version, and that file's checksum. The package name is checked
|
||||
against the version, not merely read, so a manifest that says 0.2.6.1
|
||||
and points at the 0.2.6 file is refused rather than installed and
|
||||
then offered again at every check.
|
||||
"""
|
||||
if not isinstance(manifest, dict):
|
||||
return "the manifest is not an object"
|
||||
|
||||
version = manifest.get("version")
|
||||
|
||||
if not valid_version(version):
|
||||
return f"the manifest names an invalid version: {version!r}"
|
||||
|
||||
package = manifest.get("package")
|
||||
expected = f"/packages/{PACKAGE}_{version}_amd64.deb"
|
||||
|
||||
if package != expected:
|
||||
return f"the manifest names {package!r} for version {version}"
|
||||
|
||||
checksum = manifest.get("sha256")
|
||||
|
||||
if not isinstance(checksum, str) or not SHA256_PATTERN.match(checksum.lower()):
|
||||
return f"the manifest's sha256 is not a checksum: {checksum!r}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def package_identity(path):
|
||||
"""(Package, Version) as dpkg reads them out of a downloaded file."""
|
||||
result = subprocess.run(
|
||||
["dpkg-deb", "--field", path, "Package", "Version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return None, None
|
||||
|
||||
fields = {}
|
||||
|
||||
for line in result.stdout.splitlines():
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
fields[key.strip()] = value.strip()
|
||||
|
||||
return fields.get("Package"), fields.get("Version")
|
||||
|
||||
|
||||
def fetch_json(url):
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
@@ -134,6 +208,12 @@ def main():
|
||||
log(f"Unable to fetch update manifest: {exc}")
|
||||
return 1
|
||||
|
||||
problem = manifest_problem(manifest)
|
||||
|
||||
if problem:
|
||||
log(f"Ignoring the {channel} manifest: {problem}.")
|
||||
return 1
|
||||
|
||||
candidate = manifest["version"]
|
||||
|
||||
log(f"Available version: {candidate}")
|
||||
@@ -149,10 +229,10 @@ def main():
|
||||
package_url = urljoin(base_url, manifest["package"].lstrip("/"))
|
||||
expected_hash = manifest["sha256"].lower()
|
||||
|
||||
os.makedirs("/var/cache/ekklesia-classroom", exist_ok=True)
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
|
||||
package_path = os.path.join(
|
||||
"/var/cache/ekklesia-classroom",
|
||||
CACHE_DIR,
|
||||
os.path.basename(manifest["package"]),
|
||||
)
|
||||
|
||||
@@ -177,6 +257,18 @@ def main():
|
||||
|
||||
log("SHA-256 verification passed.")
|
||||
|
||||
# The checksum proves the file is the one the manifest describes;
|
||||
# this proves the manifest described the right file. A package
|
||||
# that installs as some other version would be offered again at
|
||||
# every check, and installed again, and the room rebooted, for
|
||||
# ever.
|
||||
name, version = package_identity(temporary_path)
|
||||
|
||||
if name != PACKAGE or version != candidate:
|
||||
log(f"The package is {name} {version}, not {PACKAGE} {candidate}.")
|
||||
os.unlink(temporary_path)
|
||||
return 1
|
||||
|
||||
os.replace(temporary_path, package_path)
|
||||
|
||||
log(f"Installing {candidate}")
|
||||
@@ -200,8 +292,12 @@ def main():
|
||||
if args.no_reboot:
|
||||
log("Update requires reboot, but --no-reboot was specified.")
|
||||
else:
|
||||
log("Update requires reboot. Rebooting now.")
|
||||
subprocess.run(["systemctl", "reboot"])
|
||||
# Not now. At 03:00 nobody is there; after a missed 03:00,
|
||||
# checked when the computer is next switched on, somebody
|
||||
# usually is. classroom-reboot-when-idle waits until nobody
|
||||
# has touched it for half an hour and nothing is playing.
|
||||
log("Update requires reboot. Rebooting once nobody is using this computer.")
|
||||
subprocess.run(["systemctl", "start", "--no-block", REBOOT_WHEN_IDLE])
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Reboot after an Ekklesia Classroom update, once nobody is using the computer
|
||||
Documentation=file:///usr/local/bin/classroom-reboot-when-idle
|
||||
# Started by ekklesia-classroom-update after it installs an update that
|
||||
# needs a reboot; never at boot. A computer switched off before it
|
||||
# reboots simply starts on the new version.
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/classroom-reboot-when-idle
|
||||
@@ -24,5 +24,26 @@ Slice=session.slice
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
|
||||
# MEMORY. Measured in the lab, LibreOffice converting one deck: 203 MB
|
||||
# for ten slides of text, 738 MB for thirty slides sharing twelve photos,
|
||||
# 1.4 GB for thirty different photos and 2.1 GB for twenty very large
|
||||
# ones, for twenty seconds or so. A 4 GB classroom running Plasma and
|
||||
# Wonder Ink does not have 2 GB spare, and in testing the kernel killed
|
||||
# the whole service mid-conversion — which then restarted and tried the
|
||||
# same deck again, while the library said "Getting this ready" for ever.
|
||||
#
|
||||
# At most 40% of this machine's memory: 1.6 GB on 4 GB, 3.2 GB on 8 GB.
|
||||
# A deck that needs more is stopped HERE, inside this service, rather
|
||||
# than pushing the teacher's own windows out of memory.
|
||||
MemoryMax=40%
|
||||
# If memory runs short everywhere, this is what goes first: never the
|
||||
# lesson on the screen, the browser or the panel.
|
||||
OOMScoreAdjust=800
|
||||
# And a killed LibreOffice is a failed import, not a dead service. The
|
||||
# watcher records it — "needed more memory", with Try again — and carries
|
||||
# on. systemd's default stops the whole unit when any one process in it
|
||||
# is killed for memory.
|
||||
OOMPolicy=continue
|
||||
|
||||
[Install]
|
||||
WantedBy=plasma-workspace.target
|
||||
|
||||
@@ -16,3 +16,9 @@ StartLimitIntervalSec=0
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
# Every desktop the configuration already has gets the classroom's
|
||||
# wallpaper written into it, in the one moment the shell is not running to
|
||||
# own the file — at a login, and at every restart after a crash. See
|
||||
# classroom-desktop-wallpaper. The "-" is because a panel matters more than
|
||||
# a wallpaper: nothing that script can do is allowed to stop the shell.
|
||||
ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper
|
||||
|
||||
@@ -191,8 +191,8 @@ else:
|
||||
check("and Parent Call in the corner",
|
||||
every and CALL in every["launchers"],
|
||||
str((every or {}).get("launchers")))
|
||||
check("and Help beside it",
|
||||
every and HELP in every["launchers"],
|
||||
check("and the unfinished Help launcher is hidden",
|
||||
every and HELP not in every["launchers"],
|
||||
str((every or {}).get("launchers")))
|
||||
|
||||
no_ink, why = bar({"WONDERINK_ENABLED": "false"})
|
||||
@@ -210,11 +210,10 @@ else:
|
||||
check("with no Parent Call button on it",
|
||||
bible and CALL not in bible["launchers"],
|
||||
str((bible or {}).get("launchers")))
|
||||
# Help is not optional. A teacher who needs the manual is by
|
||||
# definition one who does not know where to look, so every room has
|
||||
# it — what differs is which pages it opens on.
|
||||
check("but Help is still there, because every room has a manual",
|
||||
bible and HELP in bible["launchers"],
|
||||
# The source and launcher remain installed, but the unfinished digital
|
||||
# Help entry point is deliberately absent from the teacher-facing bar.
|
||||
check("and unfinished Help stays hidden",
|
||||
bible and HELP not in bible["launchers"],
|
||||
str((bible or {}).get("launchers")))
|
||||
SLIDES = "applications:ekklesia-presentations.desktop"
|
||||
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/bin/bash
|
||||
# Every desktop shows the classroom's wallpaper, whatever it is numbered.
|
||||
#
|
||||
# Tiny Pebbles came through the 0.2.2 upgrade with its desktop as
|
||||
# containment 10 and no wallpaper configured on it, and showed Plasma's own
|
||||
# purple one. These run the real repair against that file, against a
|
||||
# machine that was already right, and against the shapes in between, and
|
||||
# then check that every place naming the wallpaper names the same one.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
python3 - "$ROOT" <<'PY'
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from importlib.machinery import SourceFileLoader
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
loader = SourceFileLoader("wallpaper", str(root / "scripts/classroom-desktop-wallpaper"))
|
||||
spec = importlib.util.spec_from_loader("wallpaper", loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
|
||||
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
|
||||
|
||||
|
||||
IMAGE = "Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png"
|
||||
|
||||
|
||||
def group_texts(text):
|
||||
"""{group name: its lines}, for saying what did and did not change."""
|
||||
lines = text.splitlines(keepends=True)
|
||||
return {
|
||||
name: "".join(lines[header:end]).rstrip("\n")
|
||||
for name, header, end in module.groups(lines)
|
||||
}
|
||||
|
||||
|
||||
def containment_ids(text):
|
||||
return sorted(re.findall(r"^\[Containments\]\[(\d+)\]$", text, re.M))
|
||||
|
||||
|
||||
# A panel built with no screen present takes id 1 and its applets 2 to 9,
|
||||
# which is how Tiny Pebbles' desktop came to be 10. Its containment is as
|
||||
# it was read off the machine.
|
||||
PANEL_1 = """[ActionPlugins][0]
|
||||
MiddleButton;NoModifier=org.kde.paste
|
||||
RightButton;NoModifier=org.kde.contextmenu
|
||||
|
||||
[ActionPlugins][1]
|
||||
RightButton;NoModifier=org.kde.contextmenu
|
||||
|
||||
[Containments][1]
|
||||
activityId=
|
||||
formfactor=2
|
||||
immutability=1
|
||||
lastScreen=0
|
||||
location=4
|
||||
plugin=org.kde.panel
|
||||
wallpaperplugin=org.kde.image
|
||||
|
||||
[Containments][1][Applets][2]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.quicklaunch
|
||||
|
||||
[Containments][1][Applets][2][Configuration][General]
|
||||
launcherUrls=file:///usr/share/applications/ekklesia-parent-call.desktop
|
||||
|
||||
[Containments][1][Applets][3]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.panelspacer
|
||||
|
||||
[Containments][1][Applets][4]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.icontasks
|
||||
|
||||
[Containments][1][Applets][4][Configuration][General]
|
||||
launchers=applications:ekklesia-wonderink.desktop,applications:google-chrome.desktop,applications:ekklesia-dvd.desktop
|
||||
sortingStrategy=1
|
||||
|
||||
[Containments][1][Applets][5]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.panelspacer
|
||||
|
||||
[Containments][1][Applets][6]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.networkmanagement
|
||||
|
||||
[Containments][1][Applets][7]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.volume
|
||||
|
||||
[Containments][1][Applets][8]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.notifications
|
||||
|
||||
[Containments][1][Applets][9]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.digitalclock
|
||||
|
||||
[Containments][1][General]
|
||||
AppletOrder=2;3;4;5;6;7;8;9
|
||||
"""
|
||||
|
||||
TINY_PEBBLES = PANEL_1 + """
|
||||
[Containments][10]
|
||||
activityId=62d3d55d-bd31-4fc8-9a7b-a5b9b2b99b60
|
||||
formfactor=0
|
||||
immutability=1
|
||||
lastScreen=0
|
||||
location=0
|
||||
plugin=org.kde.plasma.folder
|
||||
wallpaperplugin=org.kde.image
|
||||
|
||||
[Containments][10][General]
|
||||
positions={"1920x1080":[]}
|
||||
|
||||
[ScreenMapping]
|
||||
itemsOnDisabledScreens=
|
||||
"""
|
||||
|
||||
# The healthy machine, as the layout builds it with a screen present and
|
||||
# as the lab's own session wrote it: desktop 1, panel 2, applets 3 to 10.
|
||||
# The clock is applet 10 — the number that is Tiny Pebbles' desktop.
|
||||
RIVER_KIDS = """[ActionPlugins][0]
|
||||
MiddleButton;NoModifier=org.kde.paste
|
||||
RightButton;NoModifier=org.kde.contextmenu
|
||||
|
||||
[Containments][1]
|
||||
ItemGeometries-1920x1080=
|
||||
ItemGeometriesHorizontal=
|
||||
activityId=a9db02f6-926f-4650-b05f-de3a9f0b73fe
|
||||
formfactor=0
|
||||
immutability=1
|
||||
lastScreen=0
|
||||
location=0
|
||||
plugin=org.kde.plasma.folder
|
||||
wallpaperplugin=org.kde.image
|
||||
|
||||
[Containments][1][Wallpaper][org.kde.image][General]
|
||||
FillMode=2
|
||||
Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png
|
||||
SlidePaths=
|
||||
|
||||
[Containments][2]
|
||||
activityId=
|
||||
formfactor=2
|
||||
immutability=1
|
||||
lastScreen=0
|
||||
location=4
|
||||
plugin=org.kde.panel
|
||||
wallpaperplugin=org.kde.image
|
||||
|
||||
[Containments][2][Applets][10]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.digitalclock
|
||||
|
||||
[Containments][2][Applets][10][Configuration][Appearance]
|
||||
showDate=true
|
||||
|
||||
[Containments][2][Applets][3]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.quicklaunch
|
||||
|
||||
[Containments][2][Applets][5]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.icontasks
|
||||
|
||||
[Containments][2][General]
|
||||
AppletOrder=3;4;5;6;7;8;9;10
|
||||
|
||||
[ScreenMapping]
|
||||
itemsOnDisabledScreens=
|
||||
"""
|
||||
|
||||
# What the lab produced when Plasma made a desktop mid-session: the good
|
||||
# one kept its picture, and the new one, 11, had none.
|
||||
LATE_DESKTOP = RIVER_KIDS.replace("[ScreenMapping]", """[Containments][11]
|
||||
activityId=f992593c-2347-4e8d-be82-f80d02e71158
|
||||
formfactor=0
|
||||
immutability=1
|
||||
lastScreen=0
|
||||
location=0
|
||||
plugin=org.kde.plasma.folder
|
||||
wallpaperplugin=org.kde.image
|
||||
|
||||
[Containments][11][General]
|
||||
positions={"1920x1080":[]}
|
||||
|
||||
[ScreenMapping]""")
|
||||
|
||||
|
||||
print("1. Tiny Pebbles: a desktop numbered 10, with no wallpaper at all")
|
||||
check("the desktop is found by what it is, not its number",
|
||||
module.desktops(TINY_PEBBLES.splitlines(True)) == ["10"],
|
||||
str(module.desktops(TINY_PEBBLES.splitlines(True))))
|
||||
after, changes = module.converge(TINY_PEBBLES)
|
||||
groups_before, groups_after = group_texts(TINY_PEBBLES), group_texts(after)
|
||||
wallpaper = groups_after.get("[Containments][10][Wallpaper][org.kde.image][General]", "")
|
||||
check("it gets the classroom's image", IMAGE in wallpaper, wallpaper)
|
||||
check("scaled and cropped, as the layout writes it", "FillMode=2" in wallpaper)
|
||||
check("and it says what it did", changes == ["desktop 10: had no wallpaper configured"],
|
||||
str(changes))
|
||||
check("the wallpaper plugin is still the image one",
|
||||
"wallpaperplugin=org.kde.image" in groups_after["[Containments][10]"])
|
||||
check("one group added and nothing else changed",
|
||||
set(groups_after) - set(groups_before)
|
||||
== {"[Containments][10][Wallpaper][org.kde.image][General]"}
|
||||
and all(groups_after[name] == text for name, text in groups_before.items()))
|
||||
check("every containment keeps its number", containment_ids(after) == ["1", "10"])
|
||||
check("the panel is still containment 1, applets 2 to 9, in order",
|
||||
groups_after["[Containments][1][General]"].endswith("AppletOrder=2;3;4;5;6;7;8;9")
|
||||
and groups_after["[Containments][1]"] == groups_before["[Containments][1]"])
|
||||
check("the new group sits with its own containment",
|
||||
after.index("[Containments][10][General]")
|
||||
< after.index("[Containments][10][Wallpaper]")
|
||||
< after.index("[ScreenMapping]"))
|
||||
again, changes_again = module.converge(after)
|
||||
check("a second run changes nothing", again == after and changes_again == [], str(changes_again))
|
||||
|
||||
|
||||
print("2. River Kids: already right")
|
||||
after, changes = module.converge(RIVER_KIDS)
|
||||
check("the file comes back byte for byte", after == RIVER_KIDS)
|
||||
check("and nothing is reported as changed", changes == [], str(changes))
|
||||
check("applet 10 on the panel is not mistaken for a desktop",
|
||||
module.desktops(RIVER_KIDS.splitlines(True)) == ["1"])
|
||||
|
||||
|
||||
print("3. two desktops, one bare, as the lab made them")
|
||||
after, changes = module.converge(LATE_DESKTOP)
|
||||
groups_before, groups_after = group_texts(LATE_DESKTOP), group_texts(after)
|
||||
check("only the bare one is written",
|
||||
changes == ["desktop 11: had no wallpaper configured"], str(changes))
|
||||
check("the good one is untouched",
|
||||
groups_after["[Containments][1][Wallpaper][org.kde.image][General]"]
|
||||
== groups_before["[Containments][1][Wallpaper][org.kde.image][General]"])
|
||||
check("and the panel, numbered 2, is untouched",
|
||||
all(groups_after[name] == text for name, text in groups_before.items()
|
||||
if name and name.startswith("[Containments][2]")))
|
||||
check("the new one has the image",
|
||||
IMAGE in groups_after.get("[Containments][11][Wallpaper][org.kde.image][General]", ""))
|
||||
|
||||
|
||||
print("4. a desktop some other profile left with another picture")
|
||||
OTHER = """[Containments][4]
|
||||
formfactor=0
|
||||
location=0
|
||||
plugin=org.kde.desktopcontainment
|
||||
wallpaperplugin=org.kde.color
|
||||
|
||||
[Containments][4][Wallpaper][org.kde.image][General]
|
||||
Image[$e]=file:///usr/share/wallpapers/Next/contents/images/1920x1080.png
|
||||
SlidePaths=/usr/share/wallpapers/
|
||||
|
||||
[Containments][7]
|
||||
formfactor=2
|
||||
location=4
|
||||
plugin=org.kde.plasma.private.systemtray
|
||||
|
||||
[Containments][9]
|
||||
formfactor=2
|
||||
location=4
|
||||
plugin=org.kde.panel
|
||||
"""
|
||||
after, changes = module.converge(OTHER)
|
||||
groups_after = group_texts(after)
|
||||
check("the plain Desktop containment counts as a desktop",
|
||||
module.desktops(OTHER.splitlines(True)) == ["4"])
|
||||
check("its plugin becomes the image one",
|
||||
"wallpaperplugin=org.kde.image" in groups_after["[Containments][4]"])
|
||||
wallpaper = groups_after["[Containments][4][Wallpaper][org.kde.image][General]"]
|
||||
check("its image becomes the classroom's, keeping the key's flags",
|
||||
"Image[$e]=file:///usr/share/backgrounds/ekklesia/classroom-calm.png" in wallpaper,
|
||||
wallpaper)
|
||||
check("a missing key is added to the existing group", "FillMode=2" in wallpaper)
|
||||
check("a key the classroom does not set is left alone",
|
||||
"SlidePaths=/usr/share/wallpapers/" in wallpaper)
|
||||
check("a system tray is not a desktop",
|
||||
groups_after["[Containments][7]"] == group_texts(OTHER)["[Containments][7]"])
|
||||
check("nor is a panel",
|
||||
groups_after["[Containments][9]"] == group_texts(OTHER)["[Containments][9]"])
|
||||
check("and it settles", module.converge(after) == (after, []))
|
||||
|
||||
|
||||
print("5. the command, on files")
|
||||
work = Path(tempfile.mkdtemp())
|
||||
|
||||
|
||||
def run(*args):
|
||||
return module.main([str(arg) for arg in args])
|
||||
|
||||
|
||||
target = work / "tiny-pebbles"
|
||||
target.write_text(TINY_PEBBLES)
|
||||
os.chmod(target, 0o600)
|
||||
check("--check says it is wrong", run("--check", target) == 1)
|
||||
check("and changes nothing", target.read_text() == TINY_PEBBLES)
|
||||
check("the repair succeeds", run(target) == 0)
|
||||
check("the file is repaired", IMAGE in target.read_text())
|
||||
check("the file keeps its permissions", (target.stat().st_mode & 0o777) == 0o600)
|
||||
check("no temporary file is left behind", sorted(p.name for p in work.iterdir()) == ["tiny-pebbles"])
|
||||
stamp = (target.stat().st_ino, target.stat().st_mtime_ns)
|
||||
check("a second run succeeds", run(target) == 0)
|
||||
check("and does not write the file at all",
|
||||
(target.stat().st_ino, target.stat().st_mtime_ns) == stamp)
|
||||
check("--check then says it is right", run("--check", target) == 0)
|
||||
|
||||
healthy = work / "river-kids"
|
||||
healthy.write_text(RIVER_KIDS)
|
||||
stamp = (healthy.stat().st_ino, healthy.stat().st_mtime_ns)
|
||||
check("a machine that is right is left alone", run(healthy) == 0
|
||||
and (healthy.stat().st_ino, healthy.stat().st_mtime_ns) == stamp)
|
||||
|
||||
check("no file at all is not an error", run(work / "absent") == 0)
|
||||
check("and none is created", not (work / "absent").exists())
|
||||
|
||||
empty = work / "no-desktop"
|
||||
empty.write_text(PANEL_1)
|
||||
check("a file with only a panel is not an error", run(empty) == 0)
|
||||
check("and is not changed", empty.read_text() == PANEL_1)
|
||||
|
||||
|
||||
print("6. never underneath a running shell")
|
||||
config_home = work / "config"
|
||||
config_home.mkdir()
|
||||
live = config_home / module.APPLETSRC
|
||||
live.write_text(TINY_PEBBLES)
|
||||
os.environ["XDG_CONFIG_HOME"] = str(config_home)
|
||||
check("the default file is the one in XDG_CONFIG_HOME", module.default_path() == str(live))
|
||||
|
||||
proc = work / "proc"
|
||||
for pid, name in (("1", "systemd"), ("812", "kwin_wayland"), ("self", "python3")):
|
||||
(proc / pid).mkdir(parents=True)
|
||||
(proc / pid / "comm").write_text(name + "\n")
|
||||
check("no plasmashell in the process table is no shell",
|
||||
module.shell_running(str(proc)) is False)
|
||||
(proc / "1044").mkdir()
|
||||
(proc / "1044" / "comm").write_text("plasmashell\n")
|
||||
check("this user's plasmashell is found", module.shell_running(str(proc)) is True)
|
||||
check("and the real process table can be read",
|
||||
module.shell_running() in (True, False))
|
||||
|
||||
module.shell_running = lambda: True
|
||||
check("with plasmashell running it refuses", run() == 1)
|
||||
check("and the shell's file is untouched", live.read_text() == TINY_PEBBLES)
|
||||
check("--check still answers while it runs", run("--check") == 1)
|
||||
|
||||
module.shell_running = lambda: False
|
||||
check("with the shell stopped it repairs", run() == 0 and IMAGE in live.read_text())
|
||||
|
||||
|
||||
print("7. one wallpaper, named the same everywhere")
|
||||
layout = (root / "config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js").read_text()
|
||||
defaults = (root / "config/plasma/lookandfeel/contents/defaults").read_text()
|
||||
build = (root / "tools/build-release.sh").read_text()
|
||||
dropin = (root / "systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf").read_text()
|
||||
package = root / "config/wallpapers/EkklesiaClassroom"
|
||||
metadata = json.loads((package / "metadata.json").read_text())
|
||||
|
||||
check("the layout and the repair name the same file",
|
||||
f'var WALLPAPER = "{module.WALLPAPER}";' in layout)
|
||||
check("and the same fill mode", 'desktop.writeConfig("FillMode", 2);' in layout
|
||||
and ("FillMode", "2") in module.WALLPAPER_KEYS)
|
||||
check("the packaged file is where they say", f'"$STAGE{module.WALLPAPER}"' in build)
|
||||
|
||||
default = re.search(r"^\[Wallpaper\]\nImage=(.*)$", defaults, re.M)
|
||||
check("the look and feel's default is a package name, not a path",
|
||||
default is not None and "/" not in default.group(1),
|
||||
default.group(1) if default else "no [Wallpaper] Image")
|
||||
check("the package it names is the classroom's",
|
||||
default is not None and default.group(1) == metadata["KPlugin"]["Id"] == package.name)
|
||||
check("the build installs that package's metadata",
|
||||
f'"$ROOT/config/wallpapers/{package.name}/metadata.json"' in build
|
||||
and f'WALLPAPER_PACKAGE="$STAGE/usr/share/wallpapers/{package.name}"' in build)
|
||||
check("its image is a link to the one file, not a copy",
|
||||
f"ln -s {module.WALLPAPER}" in build)
|
||||
|
||||
check("the repair is packaged",
|
||||
'"$STAGE/usr/local/bin/classroom-desktop-wallpaper"' in build)
|
||||
check("it runs before every shell start",
|
||||
"ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper" in dropin)
|
||||
check("and a failure in it cannot stop the shell",
|
||||
not re.search(r"^ExecStartPre=/", dropin, re.M))
|
||||
check("the layout still writes the wallpaper for a fresh desktop",
|
||||
'desktop.writeConfig("Image", "file://" + WALLPAPER);' in layout)
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||
@@ -14,11 +14,14 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
|
||||
import os
|
||||
import errno
|
||||
import importlib.util
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from importlib.machinery import SourceFileLoader
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
@@ -26,6 +29,14 @@ sys.path.insert(0, str(root / "lib"))
|
||||
|
||||
import classroom_presentations as library
|
||||
|
||||
loader = SourceFileLoader(
|
||||
"classroom_presentation_import",
|
||||
str(root / "scripts" / "classroom-presentation-import"),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
importer = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(importer)
|
||||
|
||||
passed = failed = 0
|
||||
|
||||
|
||||
@@ -242,19 +253,274 @@ check("it lives in the runtime directory",
|
||||
or str(library.presenting_lock()).startswith("/tmp"),
|
||||
str(library.presenting_lock()))
|
||||
|
||||
print("10. the ledger remembers a download after its presentation is gone")
|
||||
print("10. the ledger identifies a download transaction")
|
||||
mark = library.fingerprint(done)
|
||||
library.remember(mark, "some-id", shelf)
|
||||
check("a fingerprint is path, size and time",
|
||||
mark is not None and str(done.resolve()) in mark, str(mark))
|
||||
check("it is written down", library.ledger(shelf).get(mark) == "some-id")
|
||||
check("so Remove means removed, not removed until the next scan",
|
||||
check("so an interrupted move can be matched after a restart",
|
||||
mark in library.ledger(shelf))
|
||||
touched = download("Session 7 - Romans.pptx", contents="y" * 900)
|
||||
check("but re-downloading the same session is a new presentation",
|
||||
library.fingerprint(touched) != mark,
|
||||
"a different size or time is a different download")
|
||||
|
||||
print("11. a completed download is moved into the managed library")
|
||||
original_render_pages = library.render_pages
|
||||
original_render_thumbnail = library.render_thumbnail
|
||||
original_first_text = library.first_text
|
||||
original_convert = library.convert_to_pdf
|
||||
|
||||
|
||||
def fake_render_pages(_pdf, directory, width=library.SLIDE_WIDTH,
|
||||
progress=None):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / "page-001.png").write_bytes(b"png")
|
||||
return 1, ""
|
||||
|
||||
|
||||
def fake_render_thumbnail(_source, destination,
|
||||
width=library.THUMBNAIL_WIDTH):
|
||||
destination.write_bytes(b"thumbnail")
|
||||
return True
|
||||
|
||||
|
||||
library.render_pages = fake_render_pages
|
||||
library.render_thumbnail = fake_render_thumbnail
|
||||
library.first_text = lambda _pdf, pages=2: ""
|
||||
|
||||
moved = download("Move me.pdf", contents="%PDF-1.4 moved")
|
||||
moved_contents = moved.read_bytes()
|
||||
managed = importer.import_one(moved, shelf)
|
||||
check("the source leaves Downloads after import", not moved.exists())
|
||||
check("the one original is kept in the managed entry",
|
||||
managed is not None and managed.managed_copy.read_bytes() == moved_contents,
|
||||
str(managed.managed_copy if managed else None))
|
||||
check("its PDF and page images are derived assets",
|
||||
managed is not None and managed.ready and managed.slides.exists()
|
||||
and managed.page_image(1).exists())
|
||||
|
||||
print("12. conversion failure does not lose the downloaded original")
|
||||
library.convert_to_pdf = lambda _source, _destination: (False, "broken deck")
|
||||
broken_source = download("Broken deck.pptx", contents="not really a deck")
|
||||
failed_import = importer.import_one(broken_source, shelf)
|
||||
check("the failed source was still moved out of Downloads",
|
||||
not broken_source.exists())
|
||||
check("a failed card retains the managed original",
|
||||
failed_import is not None and failed_import.failed
|
||||
and failed_import.managed_copy.read_text() == "not really a deck")
|
||||
check("the failure did not create a second original",
|
||||
failed_import is not None
|
||||
and list((failed_import.directory / "original").iterdir())
|
||||
== [failed_import.managed_copy])
|
||||
|
||||
print("13. a cross-filesystem move publishes before deleting")
|
||||
cross_source = download("Across filesystems.pdf", contents="cross-device")
|
||||
cross_destination = work / "other-device" / cross_source.name
|
||||
real_replace = importer.os.replace
|
||||
source_text = str(cross_source)
|
||||
destination_text = str(cross_destination)
|
||||
|
||||
|
||||
def cross_replace(source, destination):
|
||||
if os.fspath(source) == source_text and os.fspath(destination) == destination_text:
|
||||
raise OSError(errno.EXDEV, "different filesystems")
|
||||
return real_replace(source, destination)
|
||||
|
||||
|
||||
importer.os.replace = cross_replace
|
||||
try:
|
||||
importer.move_download(cross_source, cross_destination)
|
||||
finally:
|
||||
importer.os.replace = real_replace
|
||||
|
||||
check("the cross-filesystem destination is complete",
|
||||
cross_destination.read_text() == "cross-device")
|
||||
check("only the destination remains after it is durable",
|
||||
not cross_source.exists()
|
||||
and not cross_destination.with_name(cross_destination.name + ".moving").exists())
|
||||
|
||||
print("14. restart recovery never deletes the only moved original")
|
||||
resume_dir = shelf / "resume"
|
||||
resume_original = resume_dir / "original" / "Resume.pdf"
|
||||
resume_original.parent.mkdir(parents=True)
|
||||
resume_original.write_text("%PDF-1.4 resume")
|
||||
resume_data = {
|
||||
"id": "resume",
|
||||
"state": "importing",
|
||||
"title": "Resume",
|
||||
"original_name": "Resume.pdf",
|
||||
"source_path": str(downloads / "Resume.pdf"),
|
||||
"managed_copy": "original/Resume.pdf",
|
||||
"format": "pdf",
|
||||
"imported_at": library.stamp(),
|
||||
"last_presented_at": None,
|
||||
"times_presented": 0,
|
||||
"thumbnail_page": 1,
|
||||
"pages": 0,
|
||||
"error": None,
|
||||
}
|
||||
library.Presentation(resume_dir, resume_data).save()
|
||||
importer.sweep(shelf)
|
||||
resumed = library.load(resume_dir)
|
||||
check("an import interrupted after the move resumes in place",
|
||||
resumed is not None and resumed.ready)
|
||||
check("and its original survives recovery", resume_original.exists())
|
||||
|
||||
print("15. removal is isolated and refuses an active presentation")
|
||||
remove_one = entry(id="remove-one", title="Similar title")
|
||||
keep_one = entry(id="keep-one", title="Similar title (2)")
|
||||
(remove_one.directory / "original").mkdir()
|
||||
(remove_one.directory / "original" / "one.pdf").write_text("one")
|
||||
(keep_one.directory / "original").mkdir()
|
||||
(keep_one.directory / "original" / "two.pdf").write_text("two")
|
||||
check("removing one managed entry succeeds", library.remove(remove_one))
|
||||
check("a similarly named presentation and its files are untouched",
|
||||
keep_one.directory.exists()
|
||||
and (keep_one.directory / "original" / "two.pdf").exists())
|
||||
|
||||
runtime = work / "runtime"
|
||||
runtime.mkdir()
|
||||
old_runtime = os.environ.get("XDG_RUNTIME_DIR")
|
||||
os.environ["XDG_RUNTIME_DIR"] = str(runtime)
|
||||
library.presenting_lock().write_text(keep_one.id)
|
||||
check("an actively presented entry cannot be removed",
|
||||
not library.remove(keep_one) and keep_one.directory.exists())
|
||||
library.presenting_lock().unlink()
|
||||
if old_runtime is None:
|
||||
os.environ.pop("XDG_RUNTIME_DIR", None)
|
||||
else:
|
||||
os.environ["XDG_RUNTIME_DIR"] = old_runtime
|
||||
|
||||
library.render_pages = original_render_pages
|
||||
library.render_thumbnail = original_render_thumbnail
|
||||
library.first_text = original_first_text
|
||||
library.convert_to_pdf = original_convert
|
||||
|
||||
print("16. teacher-facing wording and compositor policy")
|
||||
ui = (root / "scripts" / "classroom-presentations").read_text()
|
||||
layout = (root / "config" / "plasma" / "lookandfeel" / "contents"
|
||||
/ "layouts" / "org.kde.plasma.desktop-layout.js").read_text()
|
||||
kwin = (root / "config" / "plasma" / "xdg" / "kwinrc").read_text()
|
||||
rules = (root / "config" / "plasma" / "xdg" / "kwinrulesrc").read_text()
|
||||
check("the empty state has one concise browser-neutral instruction",
|
||||
'label="No presentations yet\\n\\n"' in ui
|
||||
and "Download a PowerPoint or PDF to get started." in ui
|
||||
and "Download a presentation in Chrome" not in ui)
|
||||
check("cover selection uses the requested plain wording",
|
||||
'label="Use as cover"' in ui and 'label="Current cover"' in ui
|
||||
and "this is the library picture" not in ui)
|
||||
check("Remove no longer promises a copy in Chrome",
|
||||
"This will delete it from this computer." in ui
|
||||
and "Your download in Chrome" not in ui)
|
||||
check("the presenter has a compositor-enforced fullscreen rule",
|
||||
"title=Presenting" in rules and "fullscreen=true" in rules
|
||||
and "fullscreenrule=2" in rules)
|
||||
check("normal title bars contain only Close",
|
||||
"ButtonsOnRight=X" in kwin and "ButtonsOnRight=FX" not in kwin)
|
||||
check("the unfinished Help launcher is absent from the panel",
|
||||
"ekklesia-manual.desktop" not in layout)
|
||||
|
||||
print("17. running out of memory is a state a teacher can act on, never a loop")
|
||||
library.render_pages = fake_render_pages
|
||||
library.render_thumbnail = fake_render_thumbnail
|
||||
library.first_text = lambda _pdf, pages=2: ""
|
||||
# The kernel killing LibreOffice: SIGKILL directly, or 137 through the
|
||||
# soffice shell wrapper.
|
||||
check("a SIGKILL is recognised either way",
|
||||
library.killed(-9) and library.killed(137)
|
||||
and not library.killed(1) and not library.killed(0))
|
||||
|
||||
library.convert_to_pdf = lambda _source, _destination: (False, library.KILLED)
|
||||
heavy = download("Heavy deck.pptx", contents="photographs")
|
||||
starved = importer.import_one(heavy, shelf)
|
||||
check("a conversion killed for memory is a failed import",
|
||||
starved is not None and starved.failed, starved.data if starved else None)
|
||||
check("of the kind a teacher can try again",
|
||||
starved.failure == "memory" and starved.data["error_kind"] == "memory")
|
||||
check("and the downloaded original is kept",
|
||||
starved.managed_copy.read_text() == "photographs")
|
||||
check("the attempt was counted before the work began",
|
||||
starved.data.get("attempts") == 1 and starved.data.get("attempt_started_at"))
|
||||
check("a file that simply will not convert is a different failure",
|
||||
failed_import.failure == "unreadable")
|
||||
check("and cannot be tried again", not library.request_retry(failed_import))
|
||||
|
||||
# A service killed in the middle of an import, found by the next start.
|
||||
library.convert_to_pdf = original_convert
|
||||
|
||||
|
||||
def interrupted(name, attempts):
|
||||
directory = shelf / name
|
||||
(directory / "original").mkdir(parents=True)
|
||||
(directory / "original" / f"{name}.pdf").write_text("%PDF-1.4 " + name)
|
||||
library.Presentation(directory, {
|
||||
"id": name, "state": "importing", "title": name,
|
||||
"original_name": f"{name}.pdf", "source_path": str(downloads / f"{name}.pdf"),
|
||||
"managed_copy": f"original/{name}.pdf", "format": "pdf",
|
||||
"imported_at": library.stamp(), "last_presented_at": None,
|
||||
"times_presented": 0, "thumbnail_page": 1, "pages": 0, "error": None,
|
||||
"attempts": attempts,
|
||||
}).save()
|
||||
return directory
|
||||
|
||||
|
||||
once = interrupted("once", 1)
|
||||
twice = interrupted("twice", 2)
|
||||
importer.sweep(shelf)
|
||||
check("an import interrupted once is resumed",
|
||||
library.load(once).ready)
|
||||
gave_up = library.load(twice)
|
||||
check("an import interrupted twice is not tried a third time",
|
||||
gave_up.failed and gave_up.failure == "memory", gave_up.data)
|
||||
check("and its original is still there to try again from",
|
||||
(twice / "original" / "twice.pdf").exists())
|
||||
|
||||
check("Try again puts it back in the importer's hands",
|
||||
library.request_retry(gave_up) and library.load(twice).state == "retry")
|
||||
check("which counts afresh", library.load(twice).data.get("attempts") == 0)
|
||||
check("and the importer takes it up on its next pass",
|
||||
importer.retry_requested(shelf) == 1 and library.load(twice).ready)
|
||||
|
||||
# The importer not running at all: nothing will ever finish this one.
|
||||
stuck = interrupted("stuck", 0)
|
||||
entry_data = library.load(stuck).data
|
||||
long_ago = library.now() - timedelta(seconds=library.STALLED_SECONDS + 60)
|
||||
entry_data["attempt_started_at"] = library.stamp(long_ago)
|
||||
library.Presentation(stuck, entry_data).save()
|
||||
stuck_card = library.load(stuck)
|
||||
check("an import nobody is working on stops saying Getting this ready",
|
||||
stuck_card.stalled and stuck_card.failure == "memory")
|
||||
check("and offers Try again", library.request_retry(stuck_card))
|
||||
check("a fresh import is not mistaken for a stalled one",
|
||||
not library.load(once).stalled and library.load(once).failure is None)
|
||||
|
||||
held = importer.hold_lock(shelf)
|
||||
check("one importer holds the lock", held is not None)
|
||||
check("a second importer is refused rather than run beside it",
|
||||
importer.hold_lock(shelf) is None)
|
||||
held.close()
|
||||
check("and it is free again once the first has gone",
|
||||
importer.hold_lock(shelf) is not None)
|
||||
check("the lock file is not a presentation",
|
||||
all(p.id != ".importer.lock" for p in library.library(shelf)))
|
||||
|
||||
unit = (root / "systemd" / "user" / "ekklesia-classroom-presentations.service").read_text()
|
||||
check("the service may use at most 40% of the machine's memory",
|
||||
"MemoryMax=40%" in unit)
|
||||
check("a killed converter does not take the watcher with it",
|
||||
"OOMPolicy=continue" in unit)
|
||||
check("and under pressure it goes before the teacher's windows",
|
||||
"OOMScoreAdjust=800" in unit)
|
||||
check("the card says what to do, in a teacher's words",
|
||||
"Close other programs, then choose" in ui and 'label="Try again"' in ui)
|
||||
|
||||
library.render_pages = original_render_pages
|
||||
library.render_thumbnail = original_render_thumbnail
|
||||
library.first_text = original_first_text
|
||||
library.convert_to_pdf = original_convert
|
||||
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
print()
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/bin/bash
|
||||
# Wonder Ink's printing: which printers, which options, what is sent.
|
||||
#
|
||||
# CUPS is not on the development host, so lpstat, lpoptions, lp and
|
||||
# pdfinfo are stand-ins that answer with the output the real ones gave in
|
||||
# the lab — Elmer's own PPD options included. Whether a job really goes
|
||||
# through Elmer's filters is tools/lab/wonderink-print-check's question.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
mkdir -p "$WORK/bin"
|
||||
|
||||
cat > "$WORK/bin/lpstat" <<EOF
|
||||
#!/bin/bash
|
||||
[[ -f "$WORK/cups-down" ]] && { echo "lpstat: Scheduler is not running." >&2; exit 1; }
|
||||
case "\$1" in
|
||||
-e) cat "$WORK/printers" 2>/dev/null ;;
|
||||
-d) cat "$WORK/default" 2>/dev/null || echo "no system default destination" ;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
# lpoptions -p NAME -l, as the real one printed it for Elmer's PPD.
|
||||
cat > "$WORK/bin/lpoptions" <<EOF
|
||||
#!/bin/bash
|
||||
cat "$WORK/options-\$2" 2>/dev/null
|
||||
EOF
|
||||
|
||||
cat > "$WORK/bin/lp" <<EOF
|
||||
#!/bin/bash
|
||||
printf '%s\n' "\$@" > "$WORK/lp-args"
|
||||
if [[ -f "$WORK/lp-refuses" ]]; then
|
||||
echo 'lp: Destination "Elmer" is not accepting jobs.' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "request id is Elmer-42 (1 file(s))"
|
||||
EOF
|
||||
|
||||
cat > "$WORK/bin/pdfinfo" <<'EOF'
|
||||
#!/bin/bash
|
||||
[[ "$(head -c 5 "$1" 2>/dev/null)" == "%PDF-" ]] || { echo "Syntax Error" >&2; exit 1; }
|
||||
echo "Producer: test"
|
||||
echo "Pages: $(grep -c '^page' "$1")"
|
||||
EOF
|
||||
chmod +x "$WORK"/bin/*
|
||||
|
||||
printf 'Elmer\n' > "$WORK/printers"
|
||||
echo "system default destination: Elmer" > "$WORK/default"
|
||||
cat > "$WORK/options-Elmer" <<'EOF'
|
||||
PrintoutMode/Print Quality: Draft Draft.Gray *Normal Normal.Gray High High.Gray
|
||||
PageSize/Page Size: Custom.WIDTHxHEIGHT *Letter A4 11x17 A3 A5 B5 Env10 EnvC5 EnvDL EnvISOB5 EnvMonarch Executive Legal
|
||||
Duplex/Double-Sided Printing: DuplexNoTumble DuplexTumble *None
|
||||
ColorModel/Color Mode: FromPrintoutMode *Color Grayscale
|
||||
PrinterResolution/Resolution: *FromPrintoutMode 300x300dpi 600x600dpi 1200x600dpi 1200x1200dpi
|
||||
EOF
|
||||
cat > "$WORK/options-Office" <<'EOF'
|
||||
PageSize/Media Size: Letter *A4 Legal
|
||||
EOF
|
||||
|
||||
PATH="$WORK/bin:$PATH" WORK="$WORK" PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import classroom_printing as printing
|
||||
|
||||
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
|
||||
|
||||
|
||||
def lp_args():
|
||||
return (work / "lp-args").read_text().splitlines()
|
||||
|
||||
|
||||
print("1. which printers a teacher is offered")
|
||||
check("Elmer, and Elmer is the default", printing.printers() == (["Elmer"], "Elmer"))
|
||||
(work / "printers").write_text("Elmer\nOffice\n")
|
||||
(work / "default").write_text("system default destination: Office\n")
|
||||
check("the system default is preselected, whichever it is",
|
||||
printing.printers() == (["Elmer", "Office"], "Office"))
|
||||
(work / "default").write_text("no system default destination\n")
|
||||
check("with no default, the first printer",
|
||||
printing.printers() == (["Elmer", "Office"], "Elmer"))
|
||||
(work / "default").write_text("system default destination: Gone\n")
|
||||
check("a default that is not in the list is not offered",
|
||||
printing.printers() == (["Elmer", "Office"], "Elmer"))
|
||||
(work / "printers").write_text("")
|
||||
check("no printers at all is an empty list, not an error",
|
||||
printing.printers() == ([], None))
|
||||
(work / "cups-down").touch()
|
||||
check("and so is CUPS not running", printing.printers() == ([], None))
|
||||
(work / "cups-down").unlink()
|
||||
(work / "printers").write_text("Elmer\n")
|
||||
(work / "default").write_text("system default destination: Elmer\n")
|
||||
|
||||
print()
|
||||
print("2. what each printer's driver offers")
|
||||
elmer = printing.options("Elmer")
|
||||
check("Elmer's paper is Letter", elmer["paper"] == "Letter", elmer)
|
||||
# Measured: this driver ignores print-color-mode and prints in colour.
|
||||
check("black and white is asked for in the driver's own terms",
|
||||
elmer["gray"] == ("ColorModel", "Grayscale"), elmer)
|
||||
office = printing.options("Office")
|
||||
check("an A4 printer is A4", office["paper"] == "A4", office)
|
||||
check("a printer with no grey choice offers none", office["gray"] is None, office)
|
||||
unknown = printing.options("Nobody")
|
||||
check("a printer that says nothing is Letter, colour only",
|
||||
unknown == {"paper": "Letter", "gray": None}, unknown)
|
||||
|
||||
print()
|
||||
print("3. a teacher's page list")
|
||||
for text, expected in (("", None), ("all", None), (" ", None), ("2", "2"),
|
||||
("1-3", "1-3"), ("1, 3, 5-6", "1,3,5-6"), ("3-3", "3"),
|
||||
("1 - 2", "1-2"), ("6", "6")):
|
||||
try:
|
||||
got = printing.parse_pages(text, 6)
|
||||
except ValueError as error:
|
||||
got = f"refused: {error}"
|
||||
check(f"{text!r} is {expected!r}", got == expected, got)
|
||||
|
||||
for text in ("7", "0", "3-1", "1-9", "one", "1;lp -d x", "-2", "1--2", "2,", "1.5"):
|
||||
try:
|
||||
printing.parse_pages(text, 6)
|
||||
check(f"{text!r} is refused", False)
|
||||
except ValueError as error:
|
||||
check(f"{text!r} is refused, and says why: {error}", bool(str(error)))
|
||||
|
||||
print()
|
||||
print("4. what is handed to CUPS")
|
||||
pdf = work / "Colour the Ark.pdf"
|
||||
pdf.write_text("%PDF-1.4\npage\npage\n")
|
||||
check("a PDF with two pages has two", printing.page_count(pdf) == 2)
|
||||
check("something that is not a PDF has none",
|
||||
printing.page_count(work / "printers") == 0)
|
||||
|
||||
ok, answer = printing.submit(pdf, "Elmer")
|
||||
args = lp_args()
|
||||
check("a job is accepted, with its id", (ok, answer) == (True, "Elmer-42"), answer)
|
||||
check("to Elmer, one copy, named for the lesson",
|
||||
args[:6] == ["-d", "Elmer", "-n", "1", "-t", "Wonder Ink"], args)
|
||||
check("the file comes after --, so no name can be read as an option",
|
||||
args[-2:] == ["--", str(pdf)], args)
|
||||
check("and nothing else is asked for", "-P" not in args and "-o" not in args, args)
|
||||
|
||||
printing.submit(pdf, "Elmer", copies=3, pages="2-3", gray=("ColorModel", "Grayscale"),
|
||||
title="Noah")
|
||||
args = lp_args()
|
||||
check("copies, pages and black and white arrive as lp options",
|
||||
args[args.index("-n") + 1] == "3"
|
||||
and args[args.index("-P") + 1] == "2-3"
|
||||
and args[args.index("-o") + 1] == "ColorModel=Grayscale"
|
||||
and args[args.index("-t") + 1] == "Noah", args)
|
||||
|
||||
printing.submit(pdf, "Elmer", copies=500)
|
||||
check(f"copies are capped at {printing.MAX_COPIES}",
|
||||
lp_args()[lp_args().index("-n") + 1] == str(printing.MAX_COPIES))
|
||||
printing.submit(pdf, "Elmer", copies=0)
|
||||
check("and never fewer than one", lp_args()[lp_args().index("-n") + 1] == "1")
|
||||
|
||||
(work / "lp-refuses").touch()
|
||||
ok, answer = printing.submit(pdf, "Elmer")
|
||||
check("a refused job is a failure, in CUPS's own words",
|
||||
not ok and answer == 'Destination "Elmer" is not accepting jobs.', answer)
|
||||
|
||||
print()
|
||||
print("4b. where a picture goes on the paper")
|
||||
LETTER, MARGIN = (612, 792), 28.35
|
||||
area = (MARGIN, MARGIN, 612 - 2 * MARGIN, 792 - 2 * MARGIN)
|
||||
close = lambda a, b: all(abs(x - y) < 0.01 for x, y in zip(a, b))
|
||||
|
||||
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000))
|
||||
check("a wide picture fits the width and keeps its shape",
|
||||
abs(w - area[2]) < 0.01 and abs(w / h - 1.6) < 0.001, (x, y, w, h))
|
||||
check("and is centred on the page", abs(y + h / 2 - 396) < 0.01)
|
||||
check("nothing of it falls outside the margins",
|
||||
x >= MARGIN - 0.01 and y >= MARGIN - 0.01
|
||||
and x + w <= 612 - MARGIN + 0.01 and y + h <= 792 - MARGIN + 0.01)
|
||||
|
||||
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "top left")
|
||||
check("50% in the top left is half the fitted size, at the margin",
|
||||
close((x, y, w), (MARGIN, MARGIN, area[2] / 2)), (x, y, w, h))
|
||||
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), 25, "bottom right")
|
||||
check("25% in the bottom right ends at the margin",
|
||||
close((x + w, y + h), (612 - MARGIN, 792 - MARGIN)), (x, y, w, h))
|
||||
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (900, 1400), 100, "right")
|
||||
check("a tall picture fits the height and sits at the right",
|
||||
abs(h - area[3]) < 0.01 and abs(x + w - (612 - MARGIN)) < 0.01, (x, y, w, h))
|
||||
|
||||
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), fill=True)
|
||||
check("fill covers the whole printable area",
|
||||
x <= MARGIN and y <= MARGIN + 0.01 and x + w >= 612 - MARGIN and y + h >= 792 - MARGIN - 0.01)
|
||||
check("and keeps the picture's shape, cutting off what overhangs",
|
||||
abs(w / h - 1.6) < 0.001 and w > area[2])
|
||||
check("fill ignores size and position",
|
||||
printing.picture_rect(LETTER, MARGIN, (1600, 1000), 25, "top left", True)
|
||||
== printing.picture_rect(LETTER, MARGIN, (1600, 1000), fill=True))
|
||||
check("an unknown position is the centre",
|
||||
printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "upside down")
|
||||
== printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "centre"))
|
||||
try:
|
||||
printing.picture_rect(LETTER, MARGIN, (0, 10))
|
||||
check("a picture with no size is refused", False)
|
||||
except ValueError:
|
||||
check("a picture with no size is refused", True)
|
||||
|
||||
check("automatic paper is landscape for a wide picture",
|
||||
printing.picture_landscape("auto", 1600, 1000))
|
||||
check("and portrait for a tall or square one",
|
||||
not printing.picture_landscape("auto", 900, 1400)
|
||||
and not printing.picture_landscape("auto", 1000, 1000))
|
||||
check("and the teacher's choice overrides it",
|
||||
not printing.picture_landscape("portrait", 1600, 1000)
|
||||
and printing.picture_landscape("landscape", 900, 1400))
|
||||
check("every position is on a three by three grid",
|
||||
len(printing.PICTURE_POSITIONS) == 9
|
||||
and sorted(printing.PICTURE_POSITIONS.values()) == [(c, r) for c in range(3) for r in range(3)])
|
||||
|
||||
print()
|
||||
print("4c. black and white, made before the preview")
|
||||
command = printing.grayscale_command(work / "in.pdf", work / "out gray.pdf")
|
||||
check("Ghostscript writes a PDF", command[0] == "gs" and "-sDEVICE=pdfwrite" in command, command)
|
||||
check("in grey and nothing else",
|
||||
"-sColorConversionStrategy=Gray" in command and "-dProcessColorModel=/DeviceGray" in command, command)
|
||||
# pdfwrite otherwise turns a page to match its text; a landscape picture
|
||||
# on a portrait sheet would come back turned.
|
||||
check("never turning a page", "-dAutoRotatePages=/None" in command, command)
|
||||
check("annotations drawn in as they print, not left for a filter to draw in colour",
|
||||
"-dPreserveAnnots=false" in command and "-dPrinted" in command, command)
|
||||
check("safely, and without stopping to ask",
|
||||
"-dSAFER" in command and "-dBATCH" in command and "-dNOPAUSE" in command, command)
|
||||
check("to the file asked for, even with a space in its name",
|
||||
f"-sOutputFile={work / 'out gray.pdf'}" in command, command)
|
||||
check("and the source after --, so no name can be read as an option",
|
||||
command[-2:] == ["--", str(work / "in.pdf")], command)
|
||||
|
||||
print()
|
||||
print("5. Wonder Ink prints through this, and only this")
|
||||
ink = (root / "scripts/classroom-wonderink").read_text()
|
||||
code = "\n".join(line for line in ink.splitlines() if not line.lstrip().startswith("#"))
|
||||
# The 0.2.2 crash, and the blank worksheet, both came from Qt's print path.
|
||||
check("no QPrinter", "QPrinter(" not in code and "QPrintDialog" not in code)
|
||||
check("no QWebEngineView.print()", not re.search(r"\bview\.print\(", code))
|
||||
check("a lesson page becomes a PDF by printToPdf", "printToPdf(" in code)
|
||||
check("which is given up on if the engine never answers",
|
||||
"def _wait(self, message, too_long, timeout=PREPARE_TIMEOUT_MS):" in code
|
||||
and "self._timer.start(timeout)" in code)
|
||||
check("black and white has longer, since a page of photographs is slow, not stuck",
|
||||
"GRAY_TIMEOUT_MS = 60_000" in code and " GRAY_TIMEOUT_MS,\n" in code)
|
||||
check("the preview submits with classroom_printing.submit",
|
||||
"classroom_printing.submit(" in code)
|
||||
check("there is no file chooser in it", "QFileDialog" not in code)
|
||||
check("a worksheet tab is printed from its file",
|
||||
"self._pdf_views[view] = resolved" in code)
|
||||
check("an inline PDF is fetched as a file first", "view.page().download(view.url())" in code)
|
||||
check("a page's background is not printed, as in Chrome",
|
||||
"PrintElementBackgrounds, False" in code)
|
||||
check("a picture on its own is laid out, not printed as the viewer page",
|
||||
"dialog.show_picture(image)" in code and "document.contentType" in ink)
|
||||
preview_code = code[code.index("class PrintPreview"):code.index("\ndef notify")]
|
||||
check("the print interface says Color, not Colour",
|
||||
'"Color"' in preview_code and '"Colour"' not in preview_code)
|
||||
check("an exception in a handler is logged, not fatal",
|
||||
"sys.excepthook = keep_going" in code)
|
||||
|
||||
# 0.2.6.3: black and white is shown as it prints.
|
||||
check("black and white is made by classroom_printing.grayscale_command, in the background",
|
||||
"classroom_printing.grayscale_command(" in preview_code and "QProcess(" in preview_code)
|
||||
check("choosing Color or Black and white lays the preview out again",
|
||||
"self._colour.currentIndexChanged.connect" in preview_code)
|
||||
check("a failed conversion is judged by its exit and its pages, never printed in colour",
|
||||
"process.exitCode() != 0" in preview_code
|
||||
and "page_count(target) != expected" in preview_code)
|
||||
check("the driver is still told ColorModel=Grayscale for the grey file",
|
||||
'gray = self._options[printer]["gray"]' in preview_code)
|
||||
check("and nothing prints that is not what is shown",
|
||||
"(self._gray is not None and self._pdf == self._gray[1]) != (gray is not None)" in preview_code)
|
||||
|
||||
# 0.2.6.3: right-click says what it prints.
|
||||
menu_code = code[code.index(" def contextMenuEvent"):code.index("\nclass WonderInk")]
|
||||
check("right-click offers Print page always",
|
||||
'"Print page…"' in menu_code and "print_view(self)" in menu_code)
|
||||
check("and Print image only where the engine's hit test found a picture or a canvas",
|
||||
'"Print image…"' in menu_code and "MediaTypeImage" in menu_code
|
||||
and "MediaTypeCanvas" in menu_code and "print_image(self)" in menu_code)
|
||||
check("the old single Print… is gone from the menu", '"Print…"' not in menu_code)
|
||||
take_code = code[code.index(" def _take_picture"):code.index(" def _show_preview")]
|
||||
check("the picture is the engine's own copy, not fetched again",
|
||||
"WebAction.CopyImageToClipboard" in take_code
|
||||
and "download(" not in take_code and "QNetwork" not in take_code)
|
||||
check("what was on the clipboard is kept and put back",
|
||||
"keep_clipboard(clipboard)" in take_code and "put_back()" in take_code)
|
||||
check("a picture that never arrives is said so, not printed as the page",
|
||||
"TAKE_PICTURE_MS" in take_code and "dialog.fail(" in take_code
|
||||
and "print_view" not in take_code)
|
||||
check("and it goes to the picture layout, on white",
|
||||
"dialog.show_picture(printable_picture(image))" in take_code
|
||||
and "Qt.GlobalColor.white" in code)
|
||||
check("the module is packaged",
|
||||
"lib/classroom_printing.py" in (root / "tools/build-release.sh").read_text())
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/bin/bash
|
||||
# When a classroom checks for updates, and when it reboots after one.
|
||||
#
|
||||
# The check: daily at 03:00, caught up once after a computer that was off
|
||||
# at 03:00 is switched on. The reboot: only once nobody has used the
|
||||
# computer for half an hour and nothing is playing. The timer itself is
|
||||
# measured in the lab (tools/lab/update-schedule-check); here are its
|
||||
# settings, and classroom-reboot-when-idle run for real with a pipe
|
||||
# standing in for a keyboard and files standing in for sound cards.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
WORK="$WORK" python3 - "$ROOT" <<'PY'
|
||||
import configparser
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("idle", str(root / "scripts/classroom-reboot-when-idle"))
|
||||
spec = importlib.util.spec_from_loader("idle", loader)
|
||||
idle = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(idle)
|
||||
|
||||
|
||||
def unit(path):
|
||||
parser = configparser.ConfigParser(strict=False, interpolation=None)
|
||||
parser.optionxform = str
|
||||
parser.read(path)
|
||||
return parser
|
||||
|
||||
|
||||
print("1. the check: daily at 03:00, caught up once after being off")
|
||||
timer = unit(root / "systemd/updater/ekklesia-classroom-update.timer")
|
||||
check("every day at 03:00", timer["Timer"].get("OnCalendar") == "*-*-* 03:00:00", dict(timer["Timer"]))
|
||||
check("a computer off at 03:00 checks when it is next on (Persistent)", timer["Timer"].get("Persistent") == "true")
|
||||
check("spread over half an hour, so rooms do not all ask at once", timer["Timer"].get("RandomizedDelaySec") == "30m")
|
||||
check("and nothing else starts it: no boot or repeating trigger to loop on",
|
||||
not any(k in timer["Timer"] for k in ("OnBootSec", "OnStartupSec", "OnUnitActiveSec", "OnUnitInactiveSec", "OnActiveSec")))
|
||||
check("enabled by the package on every install and upgrade",
|
||||
"systemctl enable ekklesia-classroom-update.timer" in (root / "packaging/debian/postinst").read_text())
|
||||
check("and installed as a package file, so an upgrade always brings the packaged schedule",
|
||||
"systemd/updater/ekklesia-classroom-update.timer" in (root / "tools/build-release.sh").read_text()
|
||||
and not (root / "packaging/debian/conffiles").exists())
|
||||
|
||||
print()
|
||||
print("2. the updater: the same checks, and no reboot of its own")
|
||||
updater = (root / "scripts/ekklesia-classroom-update").read_text()
|
||||
code = "\n".join(l for l in updater.splitlines() if not l.lstrip().startswith("#"))
|
||||
check("only the device's own channel", 'f"{channel}/manifest.json"' in code and 'channel = config["CHANNEL"]' in code)
|
||||
check("only a newer version", "version_is_newer(candidate, current)" in code)
|
||||
check("only a manifest that makes sense", "manifest_problem(manifest)" in code)
|
||||
check("only a file with the manifest's SHA-256", "actual_hash = sha256(temporary_path)" in code)
|
||||
check("only the package and version the manifest names", "if name != PACKAGE or version != candidate:" in code)
|
||||
check("never reboots by itself any more", '["systemctl", "reboot"]' not in code)
|
||||
check("but hands the reboot to classroom-reboot-when-idle, without waiting for it",
|
||||
'"systemctl", "start", "--no-block", REBOOT_WHEN_IDLE' in code
|
||||
and 'REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"' in code)
|
||||
service = unit(root / "systemd/updater/ekklesia-classroom-reboot-when-idle.service")
|
||||
check("which is never started at boot (no [Install])", "Install" not in service.sections())
|
||||
check("and is not enabled by the package",
|
||||
"enable ekklesia-classroom-reboot-when-idle" not in (root / "packaging/debian/postinst").read_text())
|
||||
build = (root / "tools/build-release.sh").read_text()
|
||||
check("both are packaged", "scripts/classroom-reboot-when-idle" in build
|
||||
and "systemd/updater/ekklesia-classroom-reboot-when-idle.service" in build)
|
||||
|
||||
print()
|
||||
print("3. is anything playing?")
|
||||
|
||||
|
||||
def cards(states):
|
||||
base = work / f"asound-{len(list(work.glob('asound-*')))}"
|
||||
for n, state in enumerate(states):
|
||||
status = base / f"card{n}" / "pcm0p" / "sub0" / "status"
|
||||
status.parent.mkdir(parents=True)
|
||||
status.write_text(state + "\n")
|
||||
return base
|
||||
|
||||
|
||||
check("nothing: no cards at all", not idle.audio_playing(work / "none"))
|
||||
check("nothing: a card that is closed", not idle.audio_playing(cards(["closed"])))
|
||||
check("nothing: a card that is open but not running", not idle.audio_playing(cards(["state: SETUP"])))
|
||||
check("playing: a card that is running", idle.audio_playing(cards(["state: RUNNING\nowner_pid : 1"])))
|
||||
check("playing: one of two cards running (the television's)", idle.audio_playing(cards(["closed", "state: RUNNING"])))
|
||||
capture = work / "asound-capture" / "card0" / "pcm0c" / "sub0" / "status"
|
||||
capture.parent.mkdir(parents=True)
|
||||
capture.write_text("state: RUNNING\n")
|
||||
check("a microphone recording is not playing", not idle.audio_playing(work / "asound-capture"))
|
||||
|
||||
print()
|
||||
print("4. the decision")
|
||||
check("half an hour untouched, nothing playing: reboot", idle.should_reboot(2000, 100, 1800, False))
|
||||
check("29 minutes: not yet", not idle.should_reboot(1840, 100, 1800, False))
|
||||
check("an hour untouched but a video playing: not while it plays", not idle.should_reboot(4000, 100, 1800, True))
|
||||
check("half an hour by default", idle.IDLE_SECONDS == 30 * 60)
|
||||
|
||||
print()
|
||||
print("5. run for real: a keyboard that is used, then left")
|
||||
keyboard = work / "input" / "event0"
|
||||
keyboard.parent.mkdir()
|
||||
os.mkfifo(keyboard)
|
||||
os.mkfifo(work / "input" / "event1") # a second device, never used
|
||||
marker = work / "rebooted"
|
||||
asound = cards(["closed"])
|
||||
holder = os.open(keyboard, os.O_RDWR | os.O_NONBLOCK) # keeps the pipe open, as a device is
|
||||
holder1 = os.open(work / "input" / "event1", os.O_RDWR | os.O_NONBLOCK)
|
||||
result = {}
|
||||
|
||||
|
||||
def run():
|
||||
result["code"] = idle.main(["--idle-seconds", "2", "--check-seconds", "0.2",
|
||||
"--input-glob", str(work / "input" / "event*"),
|
||||
"--asound-root", str(asound),
|
||||
"--reboot-command", "touch", str(marker)])
|
||||
|
||||
|
||||
thread = threading.Thread(target=run, daemon=True)
|
||||
started = time.monotonic()
|
||||
thread.start()
|
||||
for _ in range(10): # typing every 0.4 s for 4 s
|
||||
os.write(holder, b"k" * 24)
|
||||
time.sleep(0.4)
|
||||
check("while the keyboard is used, no reboot (4 s against a 2 s limit)", not marker.exists())
|
||||
thread.join(8)
|
||||
waited = time.monotonic() - started
|
||||
check(f"left alone, it reboots once the limit has passed ({waited:.1f} s)",
|
||||
marker.exists() and 5.5 <= waited <= 8, result)
|
||||
|
||||
marker.unlink(missing_ok=True)
|
||||
playing = cards(["state: RUNNING"])
|
||||
result.clear()
|
||||
|
||||
|
||||
def run_playing():
|
||||
result["code"] = idle.main(["--idle-seconds", "1", "--check-seconds", "0.2",
|
||||
"--input-glob", str(work / "input" / "event*"),
|
||||
"--asound-root", str(playing),
|
||||
"--reboot-command", "touch", str(marker)])
|
||||
|
||||
|
||||
thread = threading.Thread(target=run_playing, daemon=True)
|
||||
thread.start()
|
||||
time.sleep(3)
|
||||
check("untouched but playing for 3 s against a 1 s limit: no reboot", not marker.exists())
|
||||
((playing / "card0" / "pcm0p" / "sub0" / "status")).write_text("closed\n")
|
||||
stopped = time.monotonic()
|
||||
thread.join(6)
|
||||
after = time.monotonic() - stopped
|
||||
# Sound is seen at each check, so the limit runs from the last check that
|
||||
# heard it: at most one check (0.2 s here, a minute in a classroom) early.
|
||||
check(f"the sound stops: it waits the limit again, less at most one check, before rebooting ({after:.1f} s)",
|
||||
marker.exists() and 0.75 <= after <= 3, result)
|
||||
os.close(holder)
|
||||
os.close(holder1)
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||
@@ -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,12 +197,23 @@ for restriction in ("run_command=false", "shell_access=false",
|
||||
"action/lock_screen=false",
|
||||
"plasma/plasmashell/unlockedDesktop=false"):
|
||||
check(f"{restriction}", restriction in restrictions)
|
||||
check("a notification's Configure button opens nothing",
|
||||
"kcm_notifications=false" in restrictions)
|
||||
check("and neither does any other settings module a teacher might reach",
|
||||
all(f"{m}=false" in restrictions for m in (
|
||||
"kcm_pulseaudio", "kcm_clock", "kcm_kscreen", "kcm_users",
|
||||
"kcm_sddm", "kcm_keys", "kcm_bluetooth", "kcm_powerdevilprofilesconfig")))
|
||||
check("a locked panel offers no widget settings",
|
||||
"plasma/allow_configure_when_locked=false" in restrictions)
|
||||
check("and the help center a settings window's Help button opens is not installed",
|
||||
"khelpcenter" in re.search(r"^Conflicts: (.*)$", control, re.MULTILINE).group(1))
|
||||
check("the restrictions are immutable", "[KDE Action Restrictions][$i]" in restrictions)
|
||||
|
||||
print()
|
||||
print("4d. clicking a launcher never hides an application")
|
||||
check("minimise on click is off", 'minimizeActiveTaskOnClick", false' in layout)
|
||||
check("there is no minimise button", "ButtonsOnRight=FX" in
|
||||
check("there is no minimise button or keep-above chevron",
|
||||
"ButtonsOnRight=X" in
|
||||
(root / "config/plasma/xdg/kwinrc").read_text())
|
||||
|
||||
print()
|
||||
@@ -233,17 +244,65 @@ check("container service startup is suppressed", "/usr/sbin/policy-rc.d" in vali
|
||||
|
||||
print()
|
||||
print("6. canary release gate")
|
||||
validation = canary.index('"$ROOT/tools/validate-trixie-release.sh"')
|
||||
package_copy = canary.index('cp "$package_path"')
|
||||
first_rsync = canary.index("rsync -av")
|
||||
check("Trixie validation runs before the repository copy", validation < package_copy)
|
||||
check("Trixie validation runs before any upload", validation < first_rsync)
|
||||
validation = canary.index('"$ROOT/tools/release-gate.sh"')
|
||||
package_copy = canary.index('cp -n "$package_path"')
|
||||
first_upload = canary.index("release_upload_package")
|
||||
check("the release gate runs before the repository copy", validation < package_copy)
|
||||
check("the release gate runs before any upload", validation < first_upload)
|
||||
# A version is checked before anything is built for it, and again after
|
||||
# the long gate, immediately before the first upload.
|
||||
check("a spent version is refused before the build",
|
||||
canary.index("release_check_new_version") < canary.index("build-release.sh"))
|
||||
check("and again after the gate",
|
||||
validation < canary.rindex("release_check_new_version") < first_upload)
|
||||
# A package handed in is the one published: a second build is a second,
|
||||
# untested file.
|
||||
build_at = canary.index('"$ROOT/tools/build-release.sh"')
|
||||
check("a package handed in is not rebuilt",
|
||||
canary.rindex('if [[ -n "$prebuilt" ]]', 0, build_at) < build_at
|
||||
and "else" in canary[canary.rindex('if [[ -n "$prebuilt" ]]', 0, build_at):build_at])
|
||||
check("and must say it is the version being published",
|
||||
'release_package_version "$package_path")" != "$version"' in canary)
|
||||
check("the file is hashed before the gate and checked after it",
|
||||
canary.index('hash="$(sha256sum "$package_path"') < validation
|
||||
< canary.index("changed while it was being gated") < first_upload)
|
||||
check("the repository copy is proven to be the gated file",
|
||||
package_copy < canary.index("is not the file that passed the gate") < first_upload)
|
||||
|
||||
gate = (root / "tools/release-gate.sh").read_text()
|
||||
gate_code = "\n".join(line for line in gate.splitlines()
|
||||
if not line.lstrip().startswith("#"))
|
||||
check("the gate runs a fresh install",
|
||||
'"$ROOT/tools/validate-trixie-release.sh" "$candidate"\n' in gate_code)
|
||||
check("and the upgrade from what canary and stable serve, as rooms of each",
|
||||
"for channel in canary stable" in gate_code
|
||||
and 'VALIDATION_CHANNEL="$channel"' in gate_code)
|
||||
check("the stable rooms' older releases can be rehearsed too",
|
||||
"VALIDATION_CHANNEL=stable" in gate_code and "RELEASE_GATE_FROM" in canary)
|
||||
check("what it upgrades from is checked against its manifest's checksum",
|
||||
'if [[ "$got" != "$from_hash" ]]' in gate_code)
|
||||
check("the gate needs no credential and writes nothing to the server",
|
||||
"release_remote" not in gate_code and "ssh" not in gate_code
|
||||
and "rsync" not in gate_code)
|
||||
check("the gate stops at the first failure",
|
||||
"set -euo pipefail" in gate and "inherit_errexit" in gate)
|
||||
check("the published channel is read back afterwards",
|
||||
canary.index("release_verify_channel canary") > canary.index("release_publish_manifest canary"))
|
||||
# The gate checks convergence, not just that the package installs: an
|
||||
# upgraded room ends on the v2 channel with every setting this release
|
||||
# has, exactly once, and the command a technician runs is safe to run on
|
||||
# a machine that is already converged.
|
||||
check("the gate checks a deliberate channel survives",
|
||||
"'^CHANNEL=canary$'" in validation_script)
|
||||
'"^CHANNEL=${channel}\\$"' in validation_script)
|
||||
check("and that channel can be stable as well as canary",
|
||||
"VALIDATION_CHANNEL" in validation_script
|
||||
and "stable|canary)" in validation_script)
|
||||
check("the gate checks the installed version is the package's own",
|
||||
"/usr/local/lib/ekklesia-classroom/VERSION" in validation_script)
|
||||
check("and asks the installed updater about it",
|
||||
"ekklesia-classroom-update --check" in validation_script)
|
||||
check("and that an upgrade leaves Presentations off",
|
||||
"grep -q '^PRESENTATIONS_ENABLED=false$'" in validation_script)
|
||||
check("and that the experiment's channel comes home",
|
||||
"'^CHANNEL=stable$'" in validation_script)
|
||||
check("and that every new setting arrived exactly once",
|
||||
@@ -287,9 +346,24 @@ check("and asks the one command for it",
|
||||
# UI_SCALE=2.0: kscreen-doctor reports the output at scale 2, and a Qt
|
||||
# client on that session reports devicePixelRatio 2.0 without being told
|
||||
# anything. Forcing it as well would apply the scale twice.
|
||||
wonderink_code = "\n".join(line for line in wonderink.splitlines()
|
||||
if not line.lstrip().startswith("#"))
|
||||
check("Wonder Ink is not told twice",
|
||||
"force-device-scale-factor" not in wonderink
|
||||
and "QT_SCALE_FACTOR" not in wonderink)
|
||||
"force-device-scale-factor" not in wonderink_code
|
||||
and "QT_SCALE_FACTOR" not in wonderink_code)
|
||||
# Its PAGES are, because Chrome under Plasma turned out to be scaled twice
|
||||
# (dpr 2.25 at 150%, measured) and a lesson has to be the size teachers
|
||||
# read it at in the browser. The window itself is left to Qt.
|
||||
# A technician's command, never a teacher's: it runs in a throwaway
|
||||
# profile that something owns (an unowned one crashed it), and it never
|
||||
# touches the signed-in profile a teacher may have open beside it.
|
||||
diag = wonderink_code[wonderink_code.index("def diagnose("):wonderink_code.index("def main(")]
|
||||
check("--diagnose uses an owned, throwaway profile",
|
||||
"QWebEngineProfile(app)" in diag and "build_profile" not in diag
|
||||
and '"--diagnose" in sys.argv' in wonderink_code)
|
||||
check("its pages start at the room's scale, which is the browser's size",
|
||||
"room_zoom=ui_scale(config)" in wonderink_code
|
||||
and "self._room_zoom * self._level" in wonderink_code)
|
||||
check("the clock is sized by the bar, not by a number",
|
||||
'autoFontAndSize", true' in layout and "fontSize" not in layout)
|
||||
|
||||
@@ -324,8 +398,11 @@ check("local documents may not read other local documents",
|
||||
"LocalContentCanAccessFileUrls, False" in wonderink)
|
||||
check("nor reach the network",
|
||||
"LocalContentCanAccessRemoteUrls, False" in wonderink)
|
||||
check("there is no file chooser behind the print dialog",
|
||||
"PrintDialogOption.PrintToFile, False" in wonderink)
|
||||
# Qt's print dialog has a Print to File path with a "..." chooser behind
|
||||
# it. Wonder Ink no longer uses that dialog: its preview offers CUPS
|
||||
# printers and nothing else, and no file dialog exists anywhere in it.
|
||||
check("there is no file chooser behind printing",
|
||||
"QFileDialog" not in wonderink and "QPrintDialog" not in wonderink)
|
||||
check("nothing downloaded is executed",
|
||||
"subprocess" not in wonderink.split("def notify")[0]
|
||||
and "QDesktopServices" not in wonderink
|
||||
@@ -356,8 +433,17 @@ check("the Present-view autoplay fix is still there",
|
||||
"PlaybackRequiresUserGesture, False" in wonderink)
|
||||
check("fullscreen still returns to a maximised window",
|
||||
"WindowFullScreen" in wonderink)
|
||||
# One text box exists, and it is the print preview's page range. Anywhere
|
||||
# else a text box in this window is an address bar.
|
||||
preview = wonderink[wonderink.index("class PrintPreview"):wonderink.index("\ndef notify")]
|
||||
# The tab strip has one corner widget, and it is the lesson's size and
|
||||
# Print — not the new-tab button a browser puts there.
|
||||
controls = wonderink[wonderink.index("class LessonControls"):wonderink.index("class ZoomBadge")]
|
||||
check("still no new-tab button or address bar",
|
||||
"setCornerWidget" not in wonderink and "QLineEdit" not in wonderink)
|
||||
wonderink.count("setCornerWidget(") == 1
|
||||
and "setCornerWidget(self._controls" in wonderink
|
||||
and "open_tab" not in controls and "QLineEdit" not in controls
|
||||
and wonderink.count("QLineEdit(") == 1 and "QLineEdit(" in preview)
|
||||
|
||||
print()
|
||||
print("7b. the disc player is decided before a session starts")
|
||||
@@ -476,27 +562,33 @@ print("7c-iii. the permanent download addresses")
|
||||
# .../stable/latest.deb and .../canary/latest.deb. One address per channel
|
||||
# that never changes, and still no second record of which version is
|
||||
# current: the pointer is a symlink to the package the manifest names.
|
||||
# Both channels publish through one function, so the order below is the
|
||||
# order for both.
|
||||
release_lib = (root / "tools/release-lib.sh").read_text()
|
||||
publish = release_lib[release_lib.index("release_publish_manifest() {"):]
|
||||
publish = publish[:publish.index("\n}\n\n")]
|
||||
publish_code = "\n".join(line for line in publish.splitlines()
|
||||
if not line.lstrip().startswith("#"))
|
||||
for channel, script in (("canary", "update_canary.sh"),
|
||||
("stable", "update_stable.sh")):
|
||||
text = (root / script).read_text()
|
||||
code = "\n".join(line for line in text.splitlines()
|
||||
if not line.lstrip().startswith("#"))
|
||||
check(f"{script} moves the {channel} pointer",
|
||||
f"{channel}/latest.deb" in text or "latest.deb" in code)
|
||||
check(f"{script} points it at the versioned package",
|
||||
"ln -sfn ../packages/$package_name" in code)
|
||||
check(f"{script} publishes {channel} through the shared function",
|
||||
f"release_publish_manifest {channel} " in text)
|
||||
check("the pointer is moved to the versioned package",
|
||||
"ln -sfn ../packages/$name latest.deb.new" in publish_code)
|
||||
# Atomic, so nothing downloads a half-written pointer, and after the
|
||||
# manifest, so the pointer can lag it but never lead it — it can never
|
||||
# offer a package the channel has not published.
|
||||
check(f"{script} swaps it by rename rather than in place",
|
||||
"mv -Tf latest.deb.new latest.deb" in code)
|
||||
# Against the rsync that actually uploads the manifest, not the echo
|
||||
# at the end that prints its URL.
|
||||
upload = f"classroom-updates/{channel}/manifest.json"
|
||||
check(f"{script} moves it after the manifest goes up",
|
||||
code.index("latest.deb") > code.index(upload))
|
||||
check(f"{script} refuses to point at a package that is not there",
|
||||
"test -f ../packages/$package_name" in code)
|
||||
check("it is swapped by rename rather than in place",
|
||||
"mv -Tf latest.deb.new latest.deb" in publish_code)
|
||||
check("after the manifest goes up",
|
||||
publish_code.index("latest.deb") > publish_code.index("$channel/manifest.json\""))
|
||||
check("and refuses to point at a package that is not there",
|
||||
"test -f ../packages/$name" in publish_code)
|
||||
check("the manifest is refused unless the server holds exactly that package",
|
||||
publish_code.index("sha256sum") < publish_code.index("cat >"))
|
||||
check("a package upload never replaces a published file",
|
||||
"--ignore-existing" in release_lib)
|
||||
|
||||
check("and neither script writes a version anywhere but the manifest",
|
||||
"latest.json" not in (root / "update_canary.sh").read_text()
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
#!/bin/bash
|
||||
# Three- and four-part versions, through everything that builds, publishes
|
||||
# and installs a classroom package.
|
||||
#
|
||||
# Nothing here reaches the real update server. The server is a directory,
|
||||
# ssh and rsync are stand-ins that act on it, and it is served over HTTP
|
||||
# on the loopback interface so that the real updater, the real channel
|
||||
# check and the real dpkg are the ones being asked.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
SERVER_PID=""
|
||||
|
||||
cleanup() {
|
||||
[[ -z "$SERVER_PID" ]] || kill "$SERVER_PID" 2>/dev/null
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
passed=0
|
||||
failed=0
|
||||
|
||||
check() {
|
||||
local label="$1"
|
||||
shift
|
||||
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
echo " ok $label"
|
||||
passed=$((passed + 1))
|
||||
else
|
||||
echo " FAIL $label"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
refuses() {
|
||||
! "$@"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A package that is nothing but its version, the way dpkg will read it.
|
||||
fake_deb() {
|
||||
local version="$1" out="$2" stage="$WORK/stage-$version"
|
||||
|
||||
mkdir -p "$stage/DEBIAN"
|
||||
cat > "$stage/DEBIAN/control" <<EOF
|
||||
Package: ${3:-ekklesia-classroom}
|
||||
Version: $version
|
||||
Architecture: amd64
|
||||
Maintainer: test
|
||||
Description: test package
|
||||
EOF
|
||||
dpkg-deb --root-owner-group --build "$stage" "$out" >/dev/null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The update server, as a directory.
|
||||
SERVER="$WORK/server"
|
||||
LOCAL="$WORK/local"
|
||||
mkdir -p "$SERVER/packages" "$SERVER/stable" "$SERVER/canary" \
|
||||
"$LOCAL/packages" "$LOCAL/canary" "$LOCAL/stable" "$WORK/bin"
|
||||
|
||||
for version in 0.1.45~hypr1 0.1.45 0.2.2 0.2.5 0.2.6; do
|
||||
fake_deb "$version" "$SERVER/packages/ekklesia-classroom_${version}_amd64.deb"
|
||||
cp "$SERVER/packages/ekklesia-classroom_${version}_amd64.deb" "$LOCAL/packages/"
|
||||
done
|
||||
# Not a classroom package, and on the real server all the same.
|
||||
echo v2 > "$SERVER/packages/ekklesia-classroom-v2.deb"
|
||||
|
||||
manifest() {
|
||||
local version="$1" file="${2:-ekklesia-classroom_$1_amd64.deb}" hash
|
||||
hash="$(sha256sum "$SERVER/packages/$file" | awk '{print $1}')"
|
||||
printf '{\n "version": "%s",\n "package": "/packages/%s",\n "sha256": "%s",\n "requires_reboot": true\n}\n' \
|
||||
"$version" "$file" "$hash"
|
||||
}
|
||||
|
||||
manifest 0.2.2 > "$SERVER/stable/manifest.json"
|
||||
manifest 0.2.6 > "$SERVER/canary/manifest.json"
|
||||
ln -s ../packages/ekklesia-classroom_0.2.2_amd64.deb "$SERVER/stable/latest.deb"
|
||||
ln -s ../packages/ekklesia-classroom_0.2.6_amd64.deb "$SERVER/canary/latest.deb"
|
||||
cp "$SERVER/stable/manifest.json" "$LOCAL/stable/"
|
||||
cp "$SERVER/canary/manifest.json" "$LOCAL/canary/"
|
||||
|
||||
# ssh HOST COMMAND: run COMMAND here. The release scripts only ever talk
|
||||
# to one host, and it is this directory.
|
||||
cat > "$WORK/bin/ssh" <<'EOF'
|
||||
#!/bin/bash
|
||||
shift
|
||||
exec bash -c "$*"
|
||||
EOF
|
||||
|
||||
# rsync [-av] [--ignore-existing] -e ssh SOURCE HOST:DEST
|
||||
cat > "$WORK/bin/rsync" <<'EOF'
|
||||
#!/bin/bash
|
||||
keep=0
|
||||
args=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ignore-existing) keep=1 ;;
|
||||
-e) shift ;;
|
||||
-*) ;;
|
||||
*) args+=("$1") ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
source="${args[0]}"
|
||||
dest="${args[1]#*:}"
|
||||
[[ -d "$dest" ]] && dest="$dest/$(basename "$source")"
|
||||
if [[ "$keep" == 1 && -e "$dest" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
cp "$source" "$dest"
|
||||
EOF
|
||||
chmod +x "$WORK/bin/ssh" "$WORK/bin/rsync"
|
||||
|
||||
PORT="$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')"
|
||||
python3 -m http.server --bind 127.0.0.1 --directory "$SERVER" "$PORT" \
|
||||
>/dev/null 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
for _ in $(seq 1 50); do
|
||||
curl -fsS "http://127.0.0.1:$PORT/stable/manifest.json" >/dev/null 2>&1 && break
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
echo "0. the library, sourced the way the release scripts source it"
|
||||
# With nothing in the environment and set -u in force. Every test below
|
||||
# exports its own RELEASE_* values, and a default that only worked when
|
||||
# somebody had already set it once stopped the real build dead.
|
||||
check "it loads under set -u with nothing set" \
|
||||
env -i PATH="$PATH" bash -c "set -euo pipefail; ROOT=/checkout; source '$ROOT/tools/release-lib.sh'
|
||||
[[ \$RELEASE_HOST == classroom-control ]]
|
||||
[[ \$RELEASE_ROOT == /srv/ekklesia/classroom-updates ]]
|
||||
[[ \$RELEASE_URL == https://updates.riverofglorymn.org ]]
|
||||
[[ \$RELEASE_LOCAL == /checkout/repo ]]"
|
||||
check "and build-release.sh gets as far as checking the version" \
|
||||
bash -c "'$ROOT/tools/build-release.sh' 0.2.06 2>&1 | grep -q 'Invalid version: 0.2.06'"
|
||||
echo
|
||||
|
||||
export PATH="$WORK/bin:$PATH"
|
||||
export RELEASE_HOST=test-server RELEASE_ROOT="$SERVER" \
|
||||
RELEASE_URL="http://127.0.0.1:$PORT" RELEASE_LOCAL="$LOCAL"
|
||||
|
||||
# shellcheck source=../tools/release-lib.sh
|
||||
source "$ROOT/tools/release-lib.sh"
|
||||
|
||||
GOOD=(0.2.2 0.2.6 0.2.6.1 0.2.6.2 0.2.7 0.3.0 0.2.10 0.2.6.10 1.0.0 10.20.30.40)
|
||||
BAD=("" 0.2 0.2.6.1.1 0.2.06 00.2.6 0.2.6.0 0.2.6.01 v0.2.6 0.2.6-1 0.2.6~rc1
|
||||
"0.2.6 " " 0.2.6" 0.2.6. .0.2.6 0..2 0.2.a "0.2.6;reboot" "0.2.6
|
||||
0.2.7" 0.2.6+1 -0.2.6)
|
||||
|
||||
echo "1. what a version may look like"
|
||||
for version in "${GOOD[@]}"; do
|
||||
check "the release scripts accept $version" release_version_valid "$version"
|
||||
done
|
||||
for version in "${BAD[@]}"; do
|
||||
check "the release scripts refuse '${version//$'\n'/\\n}'" refuses release_version_valid "$version"
|
||||
done
|
||||
|
||||
# The updater decides with its own copy of the pattern, because it runs
|
||||
# on a classroom that has no checkout. The two copies are held to the
|
||||
# same list.
|
||||
python3 - "$ROOT/scripts/ekklesia-classroom-update" "${GOOD[@]}" -- "${BAD[@]}" <<'PY'
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import sys
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("updater", sys.argv[1])
|
||||
spec = importlib.util.spec_from_loader("updater", loader)
|
||||
updater = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(updater)
|
||||
|
||||
rest = sys.argv[2:]
|
||||
split = rest.index("--")
|
||||
good, bad = rest[:split], rest[split + 1:]
|
||||
wrong = [v for v in good if not updater.valid_version(v)]
|
||||
wrong += [v for v in bad if updater.valid_version(v)]
|
||||
wrong += [v for v in (None, 26, 0.26, ["0.2.6"]) if updater.valid_version(v)]
|
||||
|
||||
for version in wrong:
|
||||
print(f" FAIL the updater disagrees about {version!r}")
|
||||
|
||||
sys.exit(1 if wrong else 0)
|
||||
PY
|
||||
check "the updater agrees about every one of them, and refuses non-strings" test $? -eq 0
|
||||
|
||||
echo
|
||||
echo "2. which version is newer, as dpkg decides it"
|
||||
ordered=(0.1.45~hypr1 0.1.45 0.2.2 0.2.5 0.2.6 0.2.6.1 0.2.6.2 0.2.6.10 0.2.7 0.2.10 0.3.0)
|
||||
for ((i = 1; i < ${#ordered[@]}; i++)); do
|
||||
check "${ordered[i-1]} < ${ordered[i]}" \
|
||||
release_version_gt "${ordered[i]}" "${ordered[i-1]}"
|
||||
check "and not the other way round" \
|
||||
refuses release_version_gt "${ordered[i-1]}" "${ordered[i]}"
|
||||
done
|
||||
check "0.2.6.1 is not newer than itself" refuses release_version_gt 0.2.6.1 0.2.6.1
|
||||
check "the highest of everything published is 0.2.6" \
|
||||
test "$(release_published_versions | release_highest)" = 0.2.6
|
||||
|
||||
echo
|
||||
echo "3. a version may be published once, and only if it is newer"
|
||||
check "0.2.6.1 may be published" release_check_new_version 0.2.6.1
|
||||
check "0.2.7 may be published" release_check_new_version 0.2.7
|
||||
check "0.2.6 may not: it is already on the server" refuses release_check_new_version 0.2.6
|
||||
check "0.2.5.1 may not: it is older than 0.2.6" refuses release_check_new_version 0.2.5.1
|
||||
check "0.2.2 may not" refuses release_check_new_version 0.2.2
|
||||
check "0.2.06 may not: dpkg would call it 0.2.6" refuses release_check_new_version 0.2.06
|
||||
check "0.2.6.0 may not" refuses release_check_new_version 0.2.6.0
|
||||
|
||||
# Published only in the local copy — an upload that failed after the copy
|
||||
# was made. The number is spent all the same.
|
||||
fake_deb 0.2.8 "$LOCAL/packages/ekklesia-classroom_0.2.8_amd64.deb"
|
||||
check "a version already copied into repo/ is spent too" refuses release_check_new_version 0.2.8
|
||||
rm "$LOCAL/packages/ekklesia-classroom_0.2.8_amd64.deb"
|
||||
|
||||
echo
|
||||
echo "4. uploading never replaces a published package"
|
||||
fake_deb 0.2.6.1 "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb"
|
||||
check "0.2.6.1 uploads and arrives intact" \
|
||||
release_upload_package "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb"
|
||||
check "and the server copy is the built one" \
|
||||
cmp "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" \
|
||||
"$SERVER/packages/ekklesia-classroom_0.2.6.1_amd64.deb"
|
||||
|
||||
before="$(sha256sum "$SERVER/packages/ekklesia-classroom_0.2.6_amd64.deb")"
|
||||
mkdir -p "$WORK/rebuilt"
|
||||
fake_deb 0.2.6 "$WORK/rebuilt/ekklesia-classroom_0.2.6_amd64.deb" ekklesia-classroom-rebuilt
|
||||
check "a different 0.2.6 is refused" \
|
||||
refuses release_upload_package "$WORK/rebuilt/ekklesia-classroom_0.2.6_amd64.deb"
|
||||
check "and the published 0.2.6 is untouched" \
|
||||
test "$(sha256sum "$SERVER/packages/ekklesia-classroom_0.2.6_amd64.deb")" = "$before"
|
||||
|
||||
echo
|
||||
echo "5. a channel points at exactly what was uploaded"
|
||||
hash_0261="$(sha256sum "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" | awk '{print $1}')"
|
||||
stable_before="$(cat "$SERVER/stable/manifest.json")"
|
||||
|
||||
check "a manifest promising a different checksum is refused" \
|
||||
refuses release_publish_manifest canary 0.2.6.1 "$(printf '0%.0s' {1..64})"
|
||||
check "and canary still says 0.2.6" test "$(release_channel_version canary)" = 0.2.6
|
||||
|
||||
check "canary is moved to 0.2.6.1" release_publish_manifest canary 0.2.6.1 "$hash_0261"
|
||||
check "its manifest says so" test "$(release_channel_version canary)" = 0.2.6.1
|
||||
check "latest.deb follows it" \
|
||||
test "$(readlink "$SERVER/canary/latest.deb")" = ../packages/ekklesia-classroom_0.2.6.1_amd64.deb
|
||||
check "what a classroom downloads agrees, over HTTP" release_verify_channel canary 0.2.6.1
|
||||
check "stable's manifest was not touched" \
|
||||
test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before"
|
||||
check "and stable still verifies as 0.2.2" release_verify_channel stable 0.2.2
|
||||
check "the check notices a channel saying the wrong version" \
|
||||
refuses release_verify_channel stable 0.2.6.1
|
||||
check "0.2.6.1 is now spent" refuses release_check_new_version 0.2.6.1
|
||||
check "0.2.6.2 is next in line" release_check_new_version 0.2.6.2
|
||||
|
||||
# A package on the server that says it is a different version from its
|
||||
# name. The channel check has to read the package, not just its name.
|
||||
fake_deb 0.2.6 "$SERVER/packages/ekklesia-classroom_0.2.9_amd64.deb"
|
||||
manifest 0.2.9 > "$SERVER/canary/manifest.json"
|
||||
ln -sfn ../packages/ekklesia-classroom_0.2.9_amd64.deb "$SERVER/canary/latest.deb"
|
||||
check "a package that says it is another version fails the channel check" \
|
||||
refuses release_verify_channel canary 0.2.9
|
||||
rm "$SERVER/packages/ekklesia-classroom_0.2.9_amd64.deb"
|
||||
manifest 0.2.6.1 > "$SERVER/canary/manifest.json"
|
||||
ln -sfn ../packages/ekklesia-classroom_0.2.6.1_amd64.deb "$SERVER/canary/latest.deb"
|
||||
|
||||
echo
|
||||
echo "6. update_stable.sh"
|
||||
cp "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" "$LOCAL/packages/"
|
||||
check "refuses a version that is not newer than stable" \
|
||||
refuses "$ROOT/update_stable.sh" 0.2.2
|
||||
check "refuses a malformed version" refuses "$ROOT/update_stable.sh" 0.2.06
|
||||
fake_deb 0.2.9 "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb"
|
||||
check "refuses a package that was built but never uploaded" \
|
||||
refuses "$ROOT/update_stable.sh" 0.2.9
|
||||
rm "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb"
|
||||
check "refuses a checksum that is not one" \
|
||||
refuses "$ROOT/update_stable.sh" 0.2.6.1 not-a-checksum
|
||||
check "refuses a file that is not the one approved" \
|
||||
refuses "$ROOT/update_stable.sh" 0.2.6.1 "$(printf '0%.0s' {1..64})"
|
||||
check "stable is still 0.2.2 after all of that" \
|
||||
test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before"
|
||||
approved="$(sha256sum "$LOCAL/packages/ekklesia-classroom_0.2.6.1_amd64.deb" | awk '{print $1}')"
|
||||
check "promotes the approved file, named by its checksum" \
|
||||
"$ROOT/update_stable.sh" 0.2.6.1 "$approved"
|
||||
check "and stable now serves exactly that file" \
|
||||
bash -c "grep -q '\"sha256\": \"$approved\"' '$SERVER/stable/manifest.json' \
|
||||
&& cmp -s '$SERVER/stable/latest.deb' '$LOCAL/packages/ekklesia-classroom_0.2.6.1_amd64.deb'"
|
||||
# Back to 0.2.2 for the updater's cases below.
|
||||
printf '%s\n' "$stable_before" > "$SERVER/stable/manifest.json"
|
||||
ln -sfn ../packages/ekklesia-classroom_0.2.2_amd64.deb "$SERVER/stable/latest.deb"
|
||||
|
||||
echo
|
||||
echo "7. update_canary.sh"
|
||||
check "without a version it builds nothing and names both choices" \
|
||||
bash -c "'$ROOT/update_canary.sh' 2>&1 | grep -q '0.2.6.2 .*maintenance' \
|
||||
&& '$ROOT/update_canary.sh' 2>&1 | grep -q '0.2.7 .*feature' \
|
||||
&& ! '$ROOT/update_canary.sh'"
|
||||
check "a published version is refused before anything is built" \
|
||||
bash -c "! '$ROOT/update_canary.sh' 0.2.6 2>&1 | grep -q 'Validating sources'"
|
||||
check "a malformed version is refused" refuses "$ROOT/update_canary.sh" 0.2.6.1.1
|
||||
# A package handed in is gated and published as it is, so it has to be
|
||||
# the release named, before anything is gated at all.
|
||||
fake_deb 0.2.6.3 "$WORK/ekklesia-classroom_0.2.6.2_amd64.deb"
|
||||
check "a package handed in that says it is another version is refused" \
|
||||
bash -c "out=\$('$ROOT/update_canary.sh' 0.2.6.2 '$WORK/ekklesia-classroom_0.2.6.2_amd64.deb' 2>&1); \
|
||||
[[ \$? -ne 0 ]] && grep -q 'says it is 0.2.6.3' <<< \"\$out\" && ! grep -q 'release gate' <<< \"\$out\""
|
||||
fake_deb 0.2.6.2 "$WORK/renamed.deb"
|
||||
check "and so is one that is not named as the release" \
|
||||
bash -c "out=\$('$ROOT/update_canary.sh' 0.2.6.2 '$WORK/renamed.deb' 2>&1); \
|
||||
[[ \$? -ne 0 ]] && grep -q 'is not named' <<< \"\$out\" && ! grep -q 'release gate' <<< \"\$out\""
|
||||
rm -f "$WORK/ekklesia-classroom_0.2.6.2_amd64.deb" "$WORK/renamed.deb"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo
|
||||
echo "8. a classroom's updater, against that server"
|
||||
|
||||
# The package database and apt, as far as the updater can tell. The
|
||||
# stand-in apt installs by reading the version out of the file it is
|
||||
# given, which is what the real one does.
|
||||
cat > "$WORK/bin/dpkg-query" <<EOF
|
||||
#!/bin/bash
|
||||
[[ -s "$WORK/installed" ]] || exit 1
|
||||
cat "$WORK/installed"
|
||||
EOF
|
||||
cat > "$WORK/bin/apt-get" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$*" >> "$WORK/apt.log"
|
||||
dpkg-deb --field "\${@: -1}" Version > "$WORK/installed"
|
||||
EOF
|
||||
cat > "$WORK/bin/systemctl" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$*" >> "$WORK/systemctl.log"
|
||||
EOF
|
||||
chmod +x "$WORK/bin/dpkg-query" "$WORK/bin/apt-get" "$WORK/bin/systemctl"
|
||||
|
||||
UPDATE_URL="http://127.0.0.1:$PORT" WORK="$WORK" SERVER="$SERVER" \
|
||||
python3 - "$ROOT/scripts/ekklesia-classroom-update" <<'PY'
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
work = Path(os.environ["WORK"])
|
||||
server = Path(os.environ["SERVER"])
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("updater", sys.argv[1])
|
||||
spec = importlib.util.spec_from_loader("updater", loader)
|
||||
updater = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(updater)
|
||||
|
||||
updater.LOCKFILE = str(work / "update.lock")
|
||||
updater.CACHE_DIR = str(work / "cache")
|
||||
|
||||
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
|
||||
|
||||
|
||||
def device(channel, installed):
|
||||
config = work / f"device-{channel}.conf"
|
||||
config.write_text(
|
||||
"# a classroom\n"
|
||||
f"UPDATE_URL={os.environ['UPDATE_URL']}\n"
|
||||
f"CHANNEL={channel}\n"
|
||||
"PRESENTATIONS_ENABLED=false\n"
|
||||
"UI_SCALE=1.25\n"
|
||||
)
|
||||
updater.CONFIG = str(config)
|
||||
(work / "installed").write_text(installed)
|
||||
for log in ("apt.log", "systemctl.log"):
|
||||
(work / log).unlink(missing_ok=True)
|
||||
return config
|
||||
|
||||
|
||||
def run(*args):
|
||||
sys.argv = ["ekklesia-classroom-update", *args]
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
status = updater.main()
|
||||
return status, out.getvalue()
|
||||
|
||||
|
||||
def installs():
|
||||
log = work / "apt.log"
|
||||
return log.read_text().splitlines() if log.exists() else []
|
||||
|
||||
|
||||
def installed():
|
||||
return (work / "installed").read_text().strip()
|
||||
|
||||
|
||||
def deb(version, name=None, package="ekklesia-classroom"):
|
||||
stage = work / f"stage-{version}-{package}"
|
||||
(stage / "DEBIAN").mkdir(parents=True, exist_ok=True)
|
||||
(stage / "DEBIAN" / "control").write_text(
|
||||
f"Package: {package}\nVersion: {version}\nArchitecture: amd64\n"
|
||||
"Maintainer: test\nDescription: test\n"
|
||||
)
|
||||
out = server / "packages" / (name or f"ekklesia-classroom_{version}_amd64.deb")
|
||||
out.unlink(missing_ok=True)
|
||||
subprocess.run(["dpkg-deb", "--root-owner-group", "--build", stage, out],
|
||||
check=True, capture_output=True)
|
||||
return out
|
||||
|
||||
|
||||
def publish(channel, version, file=None, sha=None, raw=None):
|
||||
path = server / channel / "manifest.json"
|
||||
if raw is not None:
|
||||
path.write_text(raw)
|
||||
return
|
||||
file = file or f"ekklesia-classroom_{version}_amd64.deb"
|
||||
digest = sha or hashlib.sha256((server / "packages" / file).read_bytes()).hexdigest()
|
||||
path.write_text(json.dumps({
|
||||
"version": version, "package": f"/packages/{file}",
|
||||
"sha256": digest, "requires_reboot": True,
|
||||
}))
|
||||
|
||||
|
||||
publish("stable", "0.2.2")
|
||||
publish("canary", "0.2.6.1")
|
||||
|
||||
config = device("canary", "0.2.6")
|
||||
before = config.read_text()
|
||||
status, out = run("--check")
|
||||
check("a canary device on 0.2.6 sees 0.2.6.1 as an update",
|
||||
status == 0 and "Update available: 0.2.6 -> 0.2.6.1" in out, out)
|
||||
check("and --check installs nothing", installs() == [])
|
||||
|
||||
status, out = run("--no-reboot")
|
||||
check("it installs 0.2.6.1", status == 0 and installed() == "0.2.6.1", out)
|
||||
check("once, from the versioned file",
|
||||
len(installs()) == 1 and installs()[0].endswith("ekklesia-classroom_0.2.6.1_amd64.deb"),
|
||||
installs())
|
||||
check("--no-reboot is respected", not (work / "systemctl.log").exists())
|
||||
check("its device.conf is exactly as it was", config.read_text() == before)
|
||||
|
||||
for attempt in range(3):
|
||||
run("--no-reboot")
|
||||
check("a device on 0.2.6.1 does not install 0.2.6.1 again",
|
||||
len(installs()) == 1, installs())
|
||||
status, out = run()
|
||||
check("nor reboot for it", "Already up to date" in out
|
||||
and not (work / "systemctl.log").exists(), out)
|
||||
|
||||
deb("0.2.7")
|
||||
publish("canary", "0.2.7")
|
||||
status, out = run()
|
||||
check("a device on 0.2.6.1 recognises 0.2.7 as newer and installs it",
|
||||
status == 0 and installed() == "0.2.7", out)
|
||||
# Since 0.2.6.5 not there and then: the reboot is handed to a service that
|
||||
# waits until nobody is using the computer (classroom-reboot-when-idle).
|
||||
check("and asks for the reboot the manifest wants, once nobody is using the computer",
|
||||
(work / "systemctl.log").read_text().strip()
|
||||
== "start --no-block ekklesia-classroom-reboot-when-idle.service")
|
||||
publish("canary", "0.2.6.1")
|
||||
|
||||
device("canary", "0.2.7")
|
||||
status, out = run()
|
||||
check("a device on 0.2.7 is not moved back to 0.2.6.1",
|
||||
"Already up to date" in out and installs() == [], out)
|
||||
|
||||
# Three parts, exactly as before.
|
||||
publish("canary", "0.2.6")
|
||||
device("canary", "0.2.5")
|
||||
status, out = run("--no-reboot")
|
||||
check("a device on 0.2.5 still updates to 0.2.6", installed() == "0.2.6", out)
|
||||
publish("canary", "0.2.6.1")
|
||||
|
||||
# The channels.
|
||||
config = device("stable", "0.2.2")
|
||||
status, out = run("--no-reboot")
|
||||
check("a stable device on 0.2.2 reads the stable manifest",
|
||||
f"{os.environ['UPDATE_URL']}/stable/manifest.json" in out, out)
|
||||
check("and does not install the canary release",
|
||||
installs() == [] and installed() == "0.2.2" and "Already up to date" in out, out)
|
||||
|
||||
deb("0.2.6.2")
|
||||
publish("stable", "0.2.6.2")
|
||||
status, out = run("--no-reboot")
|
||||
check("a stable device goes from 0.2.2 straight to a four-part release when stable has one",
|
||||
installed() == "0.2.6.2", out)
|
||||
publish("stable", "0.2.2")
|
||||
(server / "packages" / "ekklesia-classroom_0.2.6.2_amd64.deb").unlink()
|
||||
|
||||
# A room with no package installed at all reports 0.0.0 and takes anything.
|
||||
device("canary", "")
|
||||
status, out = run("--check")
|
||||
check("a machine with nothing installed is offered 0.2.6.1",
|
||||
"0.0.0 -> 0.2.6.1" in out, out)
|
||||
|
||||
# Malformed manifests: nothing downloaded, nothing installed, and a
|
||||
# failure the timer's journal will show.
|
||||
sha = hashlib.sha256((server / "packages" / "ekklesia-classroom_0.2.6.1_amd64.deb").read_bytes()).hexdigest()
|
||||
bad = {
|
||||
"a version with a suffix": {"version": "0.2.6.1-1"},
|
||||
"a version with five parts": {"version": "0.2.6.1.1"},
|
||||
"a version with a leading zero": {"version": "0.2.06"},
|
||||
"a version that is a number": {"version": 0.3},
|
||||
"an empty version": {"version": ""},
|
||||
"a version with a shell in it": {"version": "0.2.7;reboot"},
|
||||
"no version at all": {"version": None},
|
||||
"a package for a different version": {"version": "0.2.7",
|
||||
"package": "/packages/ekklesia-classroom_0.2.6.1_amd64.deb"},
|
||||
"a package outside /packages": {"version": "0.2.7",
|
||||
"package": "/../ekklesia-classroom_0.2.7_amd64.deb"},
|
||||
"a checksum that is not one": {"version": "0.2.7", "sha256": "abc"},
|
||||
}
|
||||
for label, fields in bad.items():
|
||||
manifest = {"version": "0.2.7",
|
||||
"package": "/packages/ekklesia-classroom_0.2.7_amd64.deb",
|
||||
"sha256": sha, "requires_reboot": True}
|
||||
manifest.update(fields)
|
||||
if manifest["version"] is None:
|
||||
del manifest["version"]
|
||||
publish("canary", None, raw=json.dumps(manifest))
|
||||
device("canary", "0.2.6")
|
||||
status, out = run("--no-reboot")
|
||||
check(f"refused: {label}",
|
||||
status == 1 and installs() == [] and installed() == "0.2.6", out)
|
||||
|
||||
for label, raw in (("a manifest that is not JSON", "<html>502</html>"),
|
||||
("a manifest that is a list", "[]")):
|
||||
publish("canary", None, raw=raw)
|
||||
device("canary", "0.2.6")
|
||||
status, out = run("--no-reboot")
|
||||
check(f"refused: {label}", status == 1 and installs() == [], out)
|
||||
|
||||
# The file arrives, matches its checksum, and is the wrong package.
|
||||
shutil.rmtree(updater.CACHE_DIR, ignore_errors=True)
|
||||
deb("0.2.6", name="ekklesia-classroom_0.2.7_amd64.deb")
|
||||
publish("canary", "0.2.7")
|
||||
device("canary", "0.2.6")
|
||||
status, out = run("--no-reboot")
|
||||
check("a 0.2.7 file that installs as 0.2.6 is refused",
|
||||
status == 1 and installs() == [] and "not ekklesia-classroom 0.2.7" in out, out)
|
||||
check("and not left in the cache",
|
||||
not list(Path(updater.CACHE_DIR).glob("*0.2.7*")))
|
||||
|
||||
deb("0.2.7", package="something-else", name="ekklesia-classroom_0.2.7_amd64.deb")
|
||||
publish("canary", "0.2.7")
|
||||
status, out = run("--no-reboot")
|
||||
check("so is a file that is some other package", status == 1 and installs() == [], out)
|
||||
|
||||
deb("0.2.7")
|
||||
publish("canary", "0.2.7", sha="0" * 64)
|
||||
status, out = run("--no-reboot")
|
||||
check("and a file whose checksum is wrong",
|
||||
status == 1 and installs() == [] and "verification FAILED" in out, out)
|
||||
|
||||
publish("canary", "0.2.6.1")
|
||||
print(f"\n updater: {passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||
check "every updater case above passed" test $? -eq 0
|
||||
|
||||
echo
|
||||
echo "$passed passed, $failed failed"
|
||||
[[ "$failed" -eq 0 ]]
|
||||
@@ -11,11 +11,10 @@ fi
|
||||
|
||||
VERSION="$1"
|
||||
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Invalid version: $VERSION"
|
||||
echo "Expected MAJOR.MINOR.PATCH, for example 0.1.6"
|
||||
exit 1
|
||||
fi
|
||||
# Three parts for a feature release, four for a maintenance release of
|
||||
# one: 0.2.6, then 0.2.6.1. See tools/release-lib.sh.
|
||||
source "$ROOT/tools/release-lib.sh"
|
||||
release_require_version "$VERSION" || exit 1
|
||||
|
||||
STAGE="$ROOT/packaging/root"
|
||||
OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb"
|
||||
@@ -177,6 +176,32 @@ install -Dm644 \
|
||||
"$ROOT/config/wallpapers/classroom-calm.png" \
|
||||
"$STAGE/usr/share/backgrounds/ekklesia/classroom-calm.png"
|
||||
|
||||
# THE SAME PICTURE, AS A WALLPAPER PACKAGE.
|
||||
#
|
||||
# The look and feel names this as the default for any desktop Plasma makes
|
||||
# without the layout, and Plasma only accepts a package there, never a
|
||||
# file. Its one image is a link to the file above rather than a copy, so
|
||||
# there is still exactly one picture, and every desktop the layout wrote
|
||||
# keeps pointing at the path it always has. The name says the image's
|
||||
# size, because that is how Plasma chooses between a package's images.
|
||||
WALLPAPER_PACKAGE="$STAGE/usr/share/wallpapers/EkklesiaClassroom"
|
||||
|
||||
install -Dm644 \
|
||||
"$ROOT/config/wallpapers/EkklesiaClassroom/metadata.json" \
|
||||
"$WALLPAPER_PACKAGE/metadata.json"
|
||||
|
||||
wallpaper_size="$(python3 -c '
|
||||
import struct, sys
|
||||
with open(sys.argv[1], "rb") as png:
|
||||
header = png.read(24)
|
||||
assert header[:8] == b"\x89PNG\r\n\x1a\n" and header[12:16] == b"IHDR"
|
||||
print("%dx%d" % struct.unpack(">II", header[16:24]))
|
||||
' "$ROOT/config/wallpapers/classroom-calm.png")"
|
||||
|
||||
install -d -m755 "$WALLPAPER_PACKAGE/contents" "$WALLPAPER_PACKAGE/contents/images"
|
||||
ln -s /usr/share/backgrounds/ekklesia/classroom-calm.png \
|
||||
"$WALLPAPER_PACKAGE/contents/images/${wallpaper_size}.png"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -194,6 +219,10 @@ install -Dm755 \
|
||||
"$ROOT/scripts/ekklesia-classroom-update" \
|
||||
"$STAGE/usr/local/bin/ekklesia-classroom-update"
|
||||
|
||||
install -Dm755 \
|
||||
"$ROOT/scripts/classroom-reboot-when-idle" \
|
||||
"$STAGE/usr/local/bin/classroom-reboot-when-idle"
|
||||
|
||||
# System configuration.
|
||||
install -Dm644 \
|
||||
"$ROOT/config/sddm/ekklesia-classroom.conf" \
|
||||
@@ -207,6 +236,11 @@ install -Dm644 \
|
||||
"$ROOT/systemd/updater/ekklesia-classroom-update.timer" \
|
||||
"$STAGE/etc/systemd/system/ekklesia-classroom-update.timer"
|
||||
|
||||
# After an update: the reboot, once nobody is using the computer.
|
||||
install -Dm644 \
|
||||
"$ROOT/systemd/updater/ekklesia-classroom-reboot-when-idle.service" \
|
||||
"$STAGE/etc/systemd/system/ekklesia-classroom-reboot-when-idle.service"
|
||||
|
||||
install -Dm644 \
|
||||
"$ROOT/systemd/provision/ekklesia-classroom-provision.service" \
|
||||
"$STAGE/etc/systemd/system/ekklesia-classroom-provision.service"
|
||||
@@ -384,6 +418,16 @@ install -Dm755 \
|
||||
"$ROOT/scripts/classroom-wonderink" \
|
||||
"$STAGE/usr/local/bin/classroom-wonderink"
|
||||
|
||||
# Wonder Ink's printing: a PDF, shown, then handed to CUPS with lp.
|
||||
install -Dm644 \
|
||||
"$ROOT/lib/classroom_printing.py" \
|
||||
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_printing.py"
|
||||
|
||||
# Wonder Ink's recent tabs, restored within two hours of their last use.
|
||||
install -Dm644 \
|
||||
"$ROOT/lib/classroom_wonderink_workspace.py" \
|
||||
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_wonderink_workspace.py"
|
||||
|
||||
# PRESENTATIONS.
|
||||
#
|
||||
# The library a teacher sees, and the watcher that fills it from their
|
||||
@@ -448,6 +492,10 @@ install -Dm755 \
|
||||
"$ROOT/scripts/classroom-desktop-reset" \
|
||||
"$STAGE/usr/local/bin/classroom-desktop-reset"
|
||||
|
||||
install -Dm755 \
|
||||
"$ROOT/scripts/classroom-desktop-wallpaper" \
|
||||
"$STAGE/usr/local/bin/classroom-desktop-wallpaper"
|
||||
|
||||
install -Dm755 \
|
||||
"$ROOT/scripts/classroom-theme" \
|
||||
"$STAGE/usr/local/bin/classroom-theme"
|
||||
@@ -568,6 +616,16 @@ dpkg-deb \
|
||||
--root-owner-group \
|
||||
--build "$STAGE" "$OUTPUT"
|
||||
|
||||
# The version dpkg reads out of the file is the one a classroom will
|
||||
# report, so it is checked rather than assumed to have survived the sed.
|
||||
built_version="$(release_package_version "$OUTPUT")"
|
||||
|
||||
if [[ "$built_version" != "$VERSION" ]]; then
|
||||
echo "The package says it is $built_version, not $VERSION" >&2
|
||||
rm -f "$OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Built:"
|
||||
echo "$OUTPUT"
|
||||
|
||||
@@ -182,6 +182,12 @@ check "no discover or system settings" 0 \
|
||||
"$(in_lab "grep -cE 'discover.desktop|systemsettings.desktop' $APPLETS")"
|
||||
check "the packaged wallpaper" 1 \
|
||||
"$(in_lab "grep -c 'Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png' $APPLETS")"
|
||||
# And the one a desktop gets when nothing wrote it, which is what Tiny
|
||||
# Pebbles showed: a package name, and the package there to be found.
|
||||
check "a desktop nobody configured defaults to it" EkklesiaClassroom \
|
||||
"$(in_lab 'sed -n "/^\[Wallpaper\]/,/^\[/s/^Image=//p" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/defaults')"
|
||||
check "and that package is the same picture" /usr/share/backgrounds/ekklesia/classroom-calm.png \
|
||||
"$(in_lab 'readlink -f /usr/share/wallpapers/EkklesiaClassroom/contents/images/*.png')"
|
||||
|
||||
section "it will not strand a teacher"
|
||||
check "no automatic lock" false "$(session_config --file kscreenlockerrc --group Daemon --key Autolock)"
|
||||
@@ -302,8 +308,11 @@ check "the launcher names the shell's app id" ekklesia-wonderink \
|
||||
# Comments stripped first. The shell has a comment saying Ctrl+T is
|
||||
# deliberately absent, and a check that cannot tell that from a binding
|
||||
# is a check that fails for being right.
|
||||
# One text box exists, in the print preview, for the page range. The
|
||||
# check reads the code outside that class; anywhere else a text box in
|
||||
# this window would be an address bar.
|
||||
check "no code offers a new tab button or an address bar" 0 \
|
||||
"$(in_lab "grep -vE '^[[:space:]]*#' /usr/local/bin/classroom-wonderink | grep -cE 'QLineEdit|setCornerWidget|addressBar|bind\\(\"Ctrl\\+T' || true")"
|
||||
"$(in_lab "sed '/^class PrintPreview/,/^def notify/d; /setCornerWidget(self._controls/d' /usr/local/bin/classroom-wonderink | grep -vE '^[[:space:]]*#' | grep -cE 'QLineEdit\\(|setCornerWidget|addressBar|bind\\(\"Ctrl\\+T' || true")"
|
||||
# Separate from the ordinary browser, which is the whole reason the split
|
||||
# exists: a teacher's Wonder Ink sign-in and the room's shared YouTube
|
||||
# account must never be in one profile.
|
||||
@@ -329,7 +338,7 @@ check "and the browser is untouched by any of this" 0 \
|
||||
|
||||
section "a lesson's worksheets come back as tabs"
|
||||
check "worksheets have a directory of Wonder Ink's own" 1 \
|
||||
"$(in_lab 'grep -c "\"Wonder Ink\"$" /usr/local/bin/classroom-wonderink || true')"
|
||||
"$(in_lab 'grep -c ") / \"Wonder Ink\"$" /usr/local/bin/classroom-wonderink || true')"
|
||||
check "a finished PDF becomes a tab" 1 \
|
||||
"$(in_lab 'grep -c "def open_worksheet" /usr/local/bin/classroom-wonderink || true')"
|
||||
check "checked on its contents, not its name" 1 \
|
||||
@@ -344,8 +353,10 @@ check "and a guard consults it on every navigation" 1 \
|
||||
"$(in_lab 'grep -c "def acceptNavigationRequest" /usr/local/bin/classroom-wonderink || true')"
|
||||
check "a local document cannot read other local documents" 1 \
|
||||
"$(in_lab 'grep -c "LocalContentCanAccessFileUrls, False" /usr/local/bin/classroom-wonderink || true')"
|
||||
check "no file chooser behind the print dialog" 1 \
|
||||
"$(in_lab 'grep -c "PrintDialogOption.PrintToFile, False" /usr/local/bin/classroom-wonderink || true')"
|
||||
# Qt's print dialog, and its Print to File chooser, are gone altogether:
|
||||
# the preview offers CUPS printers and nothing else.
|
||||
check "no file chooser behind printing" 0 \
|
||||
"$(in_lab 'grep -cE "QFileDialog|QPrintDialog" /usr/local/bin/classroom-wonderink || true')"
|
||||
check "nothing downloaded is handed to the desktop to run" 0 \
|
||||
"$(in_lab 'grep -cE "QDesktopServices|xdg-open" /usr/local/bin/classroom-wonderink || true')"
|
||||
check "and no file browser was added" 0 \
|
||||
@@ -365,10 +376,33 @@ check "device.conf carries the setting" 1 \
|
||||
"$(in_lab 'grep -c "^WONDERINK_ENABLED=" /etc/ekklesia-classroom/device.conf || true')"
|
||||
check "and it defaults to on" true \
|
||||
"$(in_lab 'sed -n "s/^WONDERINK_ENABLED=//p" /etc/ekklesia-classroom/device.conf')"
|
||||
# THE TEACHER CANNOT OPEN SYSTEM SETTINGS, AND THE MAINTENANCE TERMINAL CAN.
|
||||
# Every settings module this machine has must be refused in the classroom's
|
||||
# cascade — a notification's Configure button opens whichever one it names —
|
||||
# and none may be refused in the cascade the maintenance terminal uses.
|
||||
teacher_cascade() { in_lab "XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg kreadconfig6 --file kdeglobals --group 'KDE Control Module Restrictions' --key $1 --default true 2>/dev/null"; }
|
||||
modules=$(in_lab 'find /usr/lib/x86_64-linux-gnu/qt6/plugins -path "*kcms*" -name "*.so" | sed "s#.*/##; s#\.so\$##" | sort')
|
||||
open_modules=$(for m in $modules; do [[ "$(teacher_cascade "$m")" == false ]] || echo "$m"; done | tr '\n' ' ')
|
||||
check "every settings module is refused to the teacher (unrestricted: ${open_modules:-none})" 0 \
|
||||
"$(wc -w <<< "$open_modules")"
|
||||
check "the notification settings the Configure button opens are among them" false \
|
||||
"$(teacher_cascade kcm_notifications)"
|
||||
check "but not to the maintenance terminal" true \
|
||||
"$(in_lab "XDG_CONFIG_DIRS=/etc/xdg kreadconfig6 --file kdeglobals --group 'KDE Control Module Restrictions' --key kcm_notifications --default true 2>/dev/null")"
|
||||
check "which still starts outside the classroom's configuration" 1 \
|
||||
"$(in_lab 'grep -c "XDG_CONFIG_DIRS=/etc/xdg konsole" /usr/local/bin/classroom-admin-terminal || true')"
|
||||
check "a locked panel offers no widget settings" false \
|
||||
"$(in_lab "XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg kreadconfig6 --file kdeglobals --group 'KDE Action Restrictions' --key plasma/allow_configure_when_locked --default true 2>/dev/null")"
|
||||
check "and no help center is installed to open from a Help button" 0 \
|
||||
"$(in_lab 'dpkg -l khelpcenter 2>/dev/null | grep -c "^ii" || true')"
|
||||
|
||||
# One line the setting switches, since 0.2.3's one-setting-per-application
|
||||
# layout: var WONDERINK = true|false. This asked for the older marker
|
||||
# until 0.2.6.1 and so failed on every lab run in between.
|
||||
check "the layout carries a marker the setting can switch" 1 \
|
||||
"$(in_lab 'grep -c "CLASSROOM-WONDERINK-LAUNCHER \*/ \"applications:ekklesia-wonderink.desktop\"" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')"
|
||||
check "and it is switched on for this machine" 0 \
|
||||
"$(in_lab 'grep -c "// /\* CLASSROOM-WONDERINK-LAUNCHER" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')"
|
||||
"$(in_lab 'grep -cE "^var WONDERINK = (true|false);" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')"
|
||||
check "and it is switched on for this machine" 1 \
|
||||
"$(in_lab 'grep -c "^var WONDERINK = true;" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')"
|
||||
|
||||
section "the room's own parts survived"
|
||||
check "parent call installed" 1 "$(in_lab 'ls /usr/local/bin/classroom-parent-call 2>/dev/null | wc -l')"
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
# tools/lab/dirty-upgrade [NEW.deb] [OLD.deb]
|
||||
# CHECK_ARGS=--restart tools/lab/dirty-upgrade ask twice, either side
|
||||
# of a logout
|
||||
# tools/lab/dirty-upgrade NEW.deb dist/ekklesia-classroom_0.2.2_amd64.deb
|
||||
# the day the desktops move from 0.2.2
|
||||
#
|
||||
# The container is restarted between the install and the session, because
|
||||
# a room takes its upgrade and reboots.
|
||||
@@ -38,13 +40,30 @@ docker exec "$NAME" sh -c '
|
||||
# backports for Hyprland. The lab is not testing that.
|
||||
systemctl mask ekklesia-classroom-provision.service >/dev/null 2>&1'
|
||||
|
||||
# Which version is installed, fully configured, or the rehearsal stops.
|
||||
# Every step below is a question about one particular release, and a
|
||||
# step that quietly left the machine on another one answers for the
|
||||
# wrong release — which is how the 0.2.2 rehearsal once passed on 0.2.2.
|
||||
require_installed() {
|
||||
local want got
|
||||
want=$(dpkg-deb --field "$1" Version)
|
||||
got=$(docker exec "$NAME" dpkg-query -W -f='${Version} ${db:Status-Abbrev}' ekklesia-classroom 2>/dev/null || true)
|
||||
echo " installed: $got"
|
||||
|
||||
if [[ "$got" != "$want ii " ]]; then
|
||||
echo " $(basename "$1") DID NOT INSTALL: [$got], wanted [$want ii]" >&2
|
||||
echo " Nothing after this would be a question about $want." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
step "the old classroom: $(basename "$OLD")"
|
||||
# With apt, the way a room installs anything, so an old package's own
|
||||
# dependencies are resolved rather than forced.
|
||||
docker exec "$NAME" sh -c "
|
||||
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${OLD#$ROOT/}' >/dev/null 2>&1
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y -qq -f install >/dev/null 2>&1
|
||||
DEBIAN_FRONTEND=noninteractive dpkg --configure ekklesia-classroom >/dev/null 2>&1
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '/srv/classroom/${OLD#$ROOT/}' >/dev/null 2>&1
|
||||
true" || true
|
||||
docker exec "$NAME" dpkg-query -W -f=' installed: ${Version}\n' ekklesia-classroom
|
||||
require_installed "$OLD"
|
||||
|
||||
step "and everything a used machine accumulates"
|
||||
docker exec -i "$NAME" bash <<'DIRT'
|
||||
@@ -164,14 +183,16 @@ DIRT
|
||||
step "the new classroom: $(basename "$NEW")"
|
||||
docker exec "$NAME" sh -c '
|
||||
systemctl unmask ekklesia-classroom-provision.service >/dev/null 2>&1 || true'
|
||||
# The way ekklesia-classroom-update installs it: apt, not dpkg. The
|
||||
# package conflicts with khelpcenter since 0.2.6.2, and dpkg -i refuses a
|
||||
# conflict where apt resolves it — so from 0.2.2 this step used to leave
|
||||
# the machine on 0.2.2 and every check below was asked of the old release.
|
||||
docker exec "$NAME" sh -c "
|
||||
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${NEW#$ROOT/}' 2>&1 \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y '/srv/classroom/${NEW#$ROOT/}' 2>&1 \
|
||||
| grep -E '^\[classroom-migrate\]' || true
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y -qq -f install >/dev/null 2>&1
|
||||
DEBIAN_FRONTEND=noninteractive dpkg --configure ekklesia-classroom >/dev/null 2>&1
|
||||
rm -f /usr/sbin/policy-rc.d
|
||||
true" || true
|
||||
docker exec "$NAME" dpkg-query -W -f=' installed: ${Version} ${db:Status-Abbrev}\n' ekklesia-classroom
|
||||
require_installed "$NEW"
|
||||
|
||||
step "and the room reboots"
|
||||
"$LAB" reboot
|
||||
@@ -188,8 +209,9 @@ step "and again, installed a second time over itself"
|
||||
# An update that changes nothing must change nothing. The migration runs
|
||||
# again here, over a machine that is already converged.
|
||||
docker exec "$NAME" sh -c "
|
||||
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${NEW#$ROOT/}' >/dev/null 2>&1
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install --reinstall -y -qq '/srv/classroom/${NEW#$ROOT/}' >/dev/null 2>&1
|
||||
true" || true
|
||||
require_installed "$NEW"
|
||||
before=$(docker exec "$NAME" sh -c 'cat /etc/ekklesia-classroom/device.conf | sha256sum')
|
||||
"$LAB" plasma-down >/dev/null
|
||||
"$LAB" plasma-up >/dev/null
|
||||
@@ -230,6 +252,14 @@ fi
|
||||
theme dark >/dev/null
|
||||
echo " back to $(theme show)"
|
||||
|
||||
step "and every desktop has the classroom's wallpaper, however it was made"
|
||||
# Tiny Pebbles, 0.2.2 -> 0.2.6.4: a desktop Plasma made after the layout
|
||||
# had run came up with Plasma's own wallpaper, and nothing here noticed,
|
||||
# because every session this rehearsal started had its screen from the
|
||||
# beginning. This makes Plasma build one by itself, and puts Tiny
|
||||
# Pebbles' own file in front of a shell restart.
|
||||
"$ROOT/tools/lab/wallpaper-check" || converged=1
|
||||
|
||||
step "and it still recovers"
|
||||
"$ROOT/tools/lab/recovery-check" || converged=1
|
||||
|
||||
|
||||
@@ -17,11 +17,15 @@ docker exec "$NAME" sh -c '
|
||||
chmod +x /usr/sbin/policy-rc.d
|
||||
apt-get update -qq >/dev/null 2>&1'
|
||||
|
||||
# apt-get install on the file, exactly as ekklesia-classroom-update does.
|
||||
# Not dpkg -i and then apt-get -f: when the package conflicts with
|
||||
# something installed (khelpcenter, from 0.2.6.2) dpkg refuses, and -f then
|
||||
# "fixes" it by dropping the classroom package rather than the conflict —
|
||||
# measured, and a lab that installs differently from a classroom tests the
|
||||
# wrong thing.
|
||||
docker exec "$NAME" sh -c "
|
||||
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${DEB#$ROOT/}' \
|
||||
>/dev/null 2>&1 || true
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y -qq -f install >/dev/null 2>&1
|
||||
DEBIAN_FRONTEND=noninteractive dpkg --configure ekklesia-classroom >/dev/null 2>&1
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '/srv/classroom/${DEB#$ROOT/}' \
|
||||
>/tmp/from-package-install.log 2>&1 || cat /tmp/from-package-install.log
|
||||
rm -f /usr/sbin/policy-rc.d"
|
||||
|
||||
state=$(docker exec "$NAME" dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom)
|
||||
|
||||
@@ -90,11 +90,16 @@ up)
|
||||
# Container-local: this netns belongs to the container alone and
|
||||
# nothing on the host or the church network is changed by it. Debian's
|
||||
# own mirrors are untouched, so installs still work.
|
||||
# 10.10 is the printers. The package provisions Elmer at
|
||||
# socket://10.10.0.155:9100, and the development host reaches that
|
||||
# address over Tailscale, so an unfenced lab that printed would have
|
||||
# put paper in the church's real printer.
|
||||
#
|
||||
# A blackhole route rather than a firewall rule: the lab image has no
|
||||
# iptables, and the rig's guard had been quietly doing nothing because
|
||||
# of it. Routes need no netfilter modules and cannot silently fail.
|
||||
docker exec "$NAME" sh -c '
|
||||
for net in 10.30.0.0/16 10.40.0.0/16; do
|
||||
for net in 10.10.0.0/16 10.30.0.0/16 10.40.0.0/16; do
|
||||
ip route replace blackhole "$net" || exit 1
|
||||
done' || echo " WARNING: could not fence the church networks"
|
||||
|
||||
@@ -102,7 +107,7 @@ up)
|
||||
# bash feature: under dash it fails whatever the network is doing, so
|
||||
# the check passed on a lab that could reach the broker perfectly
|
||||
# well. Measured.
|
||||
for target in 10.40.0.2/1883 10.30.0.2/1025; do
|
||||
for target in 10.40.0.2/1883 10.30.0.2/1025 10.10.0.155/9100; do
|
||||
if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
|
||||
echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2
|
||||
exit 1
|
||||
@@ -140,11 +145,11 @@ reboot)
|
||||
# named after container ids on the real broker, from sessions that
|
||||
# had been fenced when they started and were not after a reboot.
|
||||
docker exec "$NAME" sh -c '
|
||||
for net in 10.30.0.0/16 10.40.0.0/16; do
|
||||
for net in 10.10.0.0/16 10.30.0.0/16 10.40.0.0/16; do
|
||||
ip route replace blackhole "$net" || exit 1
|
||||
done' || echo " WARNING: could not fence the church networks"
|
||||
|
||||
for target in 10.40.0.2/1883 10.30.0.2/1025; do
|
||||
for target in 10.40.0.2/1883 10.30.0.2/1025 10.10.0.155/9100; do
|
||||
if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
|
||||
echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2
|
||||
exit 1
|
||||
|
||||
@@ -135,5 +135,23 @@ check("and the window will open exactly that path", window.may_open(spaces))
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
|
||||
# Pages go before their profile. Left to Python's own teardown the order
|
||||
# is arbitrary, and a profile released while a page still uses it is a
|
||||
# segfault on the way out — measured, intermittently, AFTER every check
|
||||
# had passed, which made a passing run report failure. The real Wonder
|
||||
# Ink was closed six times in the same lab and exited 0 each time.
|
||||
from PyQt6.QtCore import QCoreApplication, QEvent
|
||||
|
||||
for index in range(window._tabs.count()):
|
||||
view = window._tabs.widget(index)
|
||||
view.page().deleteLater()
|
||||
view.deleteLater()
|
||||
window.close()
|
||||
window.deleteLater()
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
|
||||
profile.deleteLater()
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
|
||||
sys.stdout.flush()
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/bin/bash
|
||||
# When a classroom checks for updates, measured on the lab's own systemd.
|
||||
#
|
||||
# switched on at the time the timer fires once at its time, and not
|
||||
# again that day
|
||||
# switched off at the time the lab machine is really stopped across the
|
||||
# time, then started: the missed check runs
|
||||
# once after it boots, and does not loop
|
||||
#
|
||||
# A pair of test units stands in for ekklesia-classroom-update: the same
|
||||
# [Timer] settings with the time moved to a minute or two from now (the
|
||||
# real one would update the lab for real). The real timer's own settings,
|
||||
# after installing the package, are the release gate's to check.
|
||||
#
|
||||
# And classroom-reboot-when-idle against real kernel input: a mouse made
|
||||
# with /dev/uinput, moved and then left alone, with a harmless command in
|
||||
# place of the reboot.
|
||||
#
|
||||
# tools/lab/update-schedule-check
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
NAME="${LAB_NAME:-classroom-v2-lab}"
|
||||
LAB="$ROOT/tools/lab/lab"
|
||||
IDLE="${IDLE_COMMAND:-/usr/local/bin/classroom-reboot-when-idle}"
|
||||
LOG=/var/tmp/ekk-schedule-test.log
|
||||
|
||||
lab() { docker exec -i "$NAME" bash -c "$1"; }
|
||||
|
||||
passed=0
|
||||
failed=0
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# A timer like the real one, due at HH:MM:SS local time, with DELAY spread.
|
||||
install_test_timer() { # WHEN DELAY
|
||||
lab "cat > /etc/systemd/system/ekk-schedule-test.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=Stand-in for the update check
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/bin/sh -c 'echo \"fired \$(date +%%s) boot \$(cut -d. -f1 /proc/uptime)\" >> $LOG'
|
||||
UNIT
|
||||
cat > /etc/systemd/system/ekk-schedule-test.timer <<UNIT
|
||||
[Unit]
|
||||
Description=Stand-in for the update check
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* $1
|
||||
RandomizedDelaySec=$2
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
UNIT
|
||||
rm -f $LOG /var/lib/systemd/timers/stamp-ekk-schedule-test.timer
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now ekk-schedule-test.timer >/dev/null 2>&1"
|
||||
}
|
||||
|
||||
fired() { lab "grep -c fired $LOG 2>/dev/null || echo 0" | tail -1; }
|
||||
local_in() { lab "date -d '+$1 seconds' +%H:%M:%S"; } # local time SECONDS from now
|
||||
|
||||
echo "switched on at the time"
|
||||
due=$(local_in 60)
|
||||
install_test_timer "$due" 0
|
||||
judge "the stand-in is waiting for $due, local time" \
|
||||
"lab 'systemctl list-timers ekk-schedule-test.timer --no-legend | grep -q ekk-schedule-test'"
|
||||
sleep 80
|
||||
judge "at $due it checked, once" '[[ $(fired) -eq 1 ]]'
|
||||
sleep 60
|
||||
judge "and not again (a minute later, still once)" '[[ $(fired) -eq 1 ]]'
|
||||
next=$(lab "systemctl show ekk-schedule-test.timer -p NextElapseUSecRealtime --value")
|
||||
tomorrow=$(lab "date -d tomorrow +%Y-%m-%d")
|
||||
judge "its next check is tomorrow at $due ($next)" '[[ "$next" == *"$tomorrow $due"* ]]'
|
||||
|
||||
echo "switched off at the time"
|
||||
due=$(local_in 90)
|
||||
install_test_timer "$due" 60
|
||||
lab "systemctl start ekk-schedule-test.timer; sync"
|
||||
# A stamp from a previous check, as every classroom has one: last checked
|
||||
# yesterday. The catch-up is decided against it.
|
||||
lab "touch -d yesterday /var/lib/systemd/timers/stamp-ekk-schedule-test.timer"
|
||||
echo " powering the lab off before $due"
|
||||
docker stop -t 20 "$NAME" >/dev/null
|
||||
sleep 150
|
||||
echo " and on again, after it"
|
||||
"$LAB" reboot >/dev/null 2>&1
|
||||
# When the lab machine really started (inside a container /proc/uptime is
|
||||
# the host's, so this is asked of docker).
|
||||
booted=$(date -d "$(docker inspect -f '{{.State.StartedAt}}' "$NAME")" +%s)
|
||||
judge "while it was off, nothing ran" '[[ $(lab "grep -c fired $LOG 2>/dev/null || echo 0" | tail -1) -le 1 ]]'
|
||||
for _ in $(seq 1 24); do [[ $(fired) -ge 1 ]] && break; sleep 5; done
|
||||
judge "switched on after $due: the missed check ran after it booted ($(lab "tail -1 $LOG"))" '[[ $(fired) -eq 1 ]]'
|
||||
after=$(( $(lab "awk '{print \$2}' $LOG | head -1") - booted ))
|
||||
judge "$after s after it started: once it was up, within the minute of spread" '(( after >= 0 && after <= 150 ))'
|
||||
sleep 90
|
||||
judge "and only once: no loop after booting (90 s later, still once)" '[[ $(fired) -eq 1 ]]'
|
||||
lab "systemctl disable --now ekk-schedule-test.timer >/dev/null 2>&1; rm -f /etc/systemd/system/ekk-schedule-test.* $LOG /var/lib/systemd/timers/stamp-ekk-schedule-test.timer; systemctl daemon-reload"
|
||||
|
||||
echo "reboot only when nobody is using it"
|
||||
docker cp "$ROOT/scripts/classroom-reboot-when-idle" "$NAME:/opt/classroom-reboot-when-idle" >/dev/null
|
||||
lab "chmod 755 /opt/classroom-reboot-when-idle; rm -f /tmp/rebooted"
|
||||
[[ "$IDLE" == /usr/local/bin/* ]] && lab "test -x $IDLE" || IDLE=/opt/classroom-reboot-when-idle
|
||||
cat > /tmp/update-schedule-mouse.py <<'PY'
|
||||
"""A mouse made with /dev/uinput: real kernel input events. Moves for N s, then stays."""
|
||||
import fcntl, os, struct, sys, time
|
||||
UI_SET_EVBIT, UI_SET_RELBIT, UI_SET_KEYBIT = 0x40045564, 0x40045566, 0x40045565
|
||||
UI_DEV_CREATE, UI_DEV_DESTROY = 0x5501, 0x5502
|
||||
EV_SYN, EV_KEY, EV_REL, REL_X, BTN_LEFT = 0, 1, 2, 0, 0x110
|
||||
fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
|
||||
for ioctl, value in ((UI_SET_EVBIT, EV_KEY), (UI_SET_KEYBIT, BTN_LEFT), (UI_SET_EVBIT, EV_REL), (UI_SET_RELBIT, REL_X)):
|
||||
fcntl.ioctl(fd, ioctl, value)
|
||||
os.write(fd, struct.pack("80sHHHHi", b"lab-classroom-mouse", 3, 1, 1, 1, 0) + b"\0" * (4 * 64 * 4))
|
||||
fcntl.ioctl(fd, UI_DEV_CREATE)
|
||||
time.sleep(1)
|
||||
def event(kind, code, value):
|
||||
now = time.time()
|
||||
os.write(fd, struct.pack("llHHi", int(now), int(now % 1 * 1e6), kind, code, value))
|
||||
end = time.monotonic() + float(sys.argv[1])
|
||||
while time.monotonic() < end:
|
||||
event(EV_REL, REL_X, 1); event(EV_SYN, 0, 0)
|
||||
time.sleep(2)
|
||||
print("mouse left alone", flush=True)
|
||||
time.sleep(float(sys.argv[2]))
|
||||
fcntl.ioctl(fd, UI_DEV_DESTROY)
|
||||
PY
|
||||
docker cp /tmp/update-schedule-mouse.py "$NAME:/opt/update-schedule-mouse.py" >/dev/null
|
||||
rm -f /tmp/update-schedule-mouse.py
|
||||
docker exec -d "$NAME" python3 /opt/update-schedule-mouse.py 40 90
|
||||
sleep 2
|
||||
# The kernel makes the device; on a classroom machine devtmpfs gives it a
|
||||
# node in /dev/input. The lab's /dev is a plain tmpfs, so the node is made
|
||||
# here, from what sysfs says, as devtmpfs would.
|
||||
lab 'for e in /sys/class/input/event*; do
|
||||
[[ "$(cat $e/device/name)" == lab-classroom-mouse ]] || continue
|
||||
IFS=: read -r major minor < $e/dev
|
||||
mknod /dev/input/$(basename $e) c $major $minor 2>/dev/null
|
||||
echo $(basename $e) > /tmp/lab-mouse-node
|
||||
done'
|
||||
sleep 1
|
||||
docker exec -d "$NAME" sh -c "$IDLE --idle-seconds 25 --check-seconds 2 --reboot-command touch /tmp/rebooted > /tmp/idle.log 2>&1"
|
||||
sleep 35
|
||||
judge "the mouse moved every 2 s for 40 s: no reboot, against a 25 s limit" "! lab 'test -e /tmp/rebooted'"
|
||||
judge "it was watching real devices ($(lab 'grep -o "watching [0-9]* input devices" /tmp/idle.log'))" \
|
||||
"lab 'grep -qE \"watching [1-9][0-9]* input devices\" /tmp/idle.log'"
|
||||
sleep 40
|
||||
judge "left alone for longer than the limit: it rebooted" "lab 'test -e /tmp/rebooted'"
|
||||
lab "pkill -f '[u]pdate-schedule-mouse'; rm -f /dev/input/\$(cat /tmp/lab-mouse-node 2>/dev/null) /tmp/lab-mouse-node /tmp/rebooted /opt/update-schedule-mouse.py /opt/classroom-reboot-when-idle"
|
||||
|
||||
echo
|
||||
echo "$passed passed, $failed failed"
|
||||
[[ "$failed" -eq 0 ]]
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/bin/bash
|
||||
# Does every desktop show the classroom's wallpaper — including one Plasma
|
||||
# makes by itself after the layout has run?
|
||||
#
|
||||
# Tiny Pebbles came through the 0.2.2 upgrade with its desktop numbered 10
|
||||
# and nothing configured on it, and showed Plasma's own purple wallpaper.
|
||||
# The layout only reaches the desktops that exist while it runs, and a
|
||||
# room whose updater reboots it at three in the morning with the
|
||||
# television off has none then. The lab cannot switch a television on, so
|
||||
# it makes Plasma create a desktop the other way it does, through a new
|
||||
# activity: the same code in the shell, and measured to give the same
|
||||
# bare containment.
|
||||
#
|
||||
# tools/lab/wallpaper-check against the running lab session
|
||||
#
|
||||
# Each answer is read off the screen, not out of a file: a configuration
|
||||
# can say anything, and the question is what the room sees. The sky at the
|
||||
# top of the classroom's picture is pale blue; Plasma's is purple.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
LAB="$ROOT/tools/lab/lab"
|
||||
NAME="${LAB_NAME:-classroom-v2-lab}"
|
||||
APPLETS=/home/classroom/.config/plasma-org.kde.plasma.desktop-appletsrc
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
check() {
|
||||
if [[ "$2" == "$3" ]]; then
|
||||
printf ' \033[32mPASS\033[0m %-58s %s\n' "$1" "$3"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
printf ' \033[31mFAIL\033[0m %-58s got [%s] want [%s]\n' "$1" "$3" "$2"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
section() { printf '\n%s\n' "$1"; }
|
||||
in_lab() { docker exec "$NAME" bash -c "$1" 2>/dev/null; }
|
||||
# The same, with this script's input handed through — for a program on a
|
||||
# here-document. Without -i docker gives it nothing to read, and it runs
|
||||
# on nothing and says nothing.
|
||||
in_lab_fed() { docker exec -i "$NAME" bash -c "$1"; }
|
||||
user() { "$LAB" run "$@" 2>/dev/null; }
|
||||
activities() {
|
||||
user busctl --user call org.kde.ActivityManager /ActivityManager/Activities \
|
||||
org.kde.ActivityManager.Activities "$@"
|
||||
}
|
||||
|
||||
# What is on the screen at the top middle, where the classroom's picture
|
||||
# is sky and Plasma's is purple: "classroom", "plasma", or the colour.
|
||||
screen() {
|
||||
"$LAB" outer grim -t ppm - 2>/dev/null | python3 -c '
|
||||
import sys
|
||||
data = sys.stdin.buffer.read()
|
||||
fields, at = [], 0
|
||||
while len(fields) < 4:
|
||||
while data[at:at + 1].isspace():
|
||||
at += 1
|
||||
end = at
|
||||
while not data[end:end + 1].isspace():
|
||||
end += 1
|
||||
fields.append(data[at:end])
|
||||
at = end
|
||||
width, height = int(fields[1]), int(fields[2])
|
||||
pixels = data[at + 1:]
|
||||
total = [0, 0, 0]
|
||||
count = 0
|
||||
for y in range(20, 140, 4):
|
||||
for x in range(width * 2 // 5, width * 3 // 5, 8):
|
||||
offset = (y * width + x) * 3
|
||||
for channel in range(3):
|
||||
total[channel] += pixels[offset + channel]
|
||||
count += 1
|
||||
r, g, b = (value / count for value in total)
|
||||
if b > r + 25 and g > r + 10:
|
||||
print("classroom")
|
||||
elif r > g + 25 and b > g + 25:
|
||||
print("plasma")
|
||||
else:
|
||||
print(f"rgb({r:.0f},{g:.0f},{b:.0f})")
|
||||
'
|
||||
}
|
||||
|
||||
# The shell writes its file a few seconds after a change, not at once.
|
||||
settle() { sleep "${1:-35}"; }
|
||||
|
||||
# The shell's file, read as groups rather than grepped, so nothing here
|
||||
# can pick a line out of the wrong containment.
|
||||
#
|
||||
# applets panels each panel's number, its applets' numbers and plugins
|
||||
# and their order. Not the panel's text: the shell adds
|
||||
# keys of its own to applets as soon as it runs (popup
|
||||
# sizes, the volume's migration), and that is Plasma,
|
||||
# not the repair. tests/ checks the repair's own
|
||||
# output byte for byte.
|
||||
# applets desktops the desktop containments as "id:image" or "id:none",
|
||||
# found by plugin and never by number
|
||||
applets() {
|
||||
in_lab_fed "python3 - $1 $APPLETS" <<'PY'
|
||||
import re, sys
|
||||
mode, path = sys.argv[1], sys.argv[2]
|
||||
groups, name = {}, None
|
||||
for line in open(path).read().splitlines():
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
name = line
|
||||
groups[name] = []
|
||||
elif name and line:
|
||||
groups[name].append(line)
|
||||
def top(cid):
|
||||
return groups.get(f"[Containments][{cid}]", [])
|
||||
ids = [m.group(1) for g in groups for m in [re.match(r"^\[Containments\]\[(\d+)\]$", g)] if m]
|
||||
if mode == "panels":
|
||||
for c in ids:
|
||||
if "plugin=org.kde.panel" not in top(c):
|
||||
continue
|
||||
applets = sorted(
|
||||
(int(m.group(1)), line[len("plugin=org.kde.plasma."):])
|
||||
for g in groups
|
||||
for m in [re.match(r"^\[Containments\]\[%s\]\[Applets\]\[(\d+)\]$" % c, g)] if m
|
||||
for line in groups[g] if line.startswith("plugin=")
|
||||
)
|
||||
order = [l[len("AppletOrder="):] for l in groups.get(f"[Containments][{c}][General]", [])
|
||||
if l.startswith("AppletOrder=")]
|
||||
print(f"panel {c}:", " ".join(f"{n}={name}" for n, name in applets), "order", *order)
|
||||
else:
|
||||
image = "Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png"
|
||||
out = []
|
||||
for c in ids:
|
||||
if {"plugin=org.kde.plasma.folder", "plugin=org.kde.desktopcontainment"} & set(top(c)):
|
||||
wallpaper = groups.get(f"[Containments][{c}][Wallpaper][org.kde.image][General]", [])
|
||||
out.append(f"{c}:{'image' if image in wallpaper else 'none'}")
|
||||
print(" ".join(out))
|
||||
PY
|
||||
}
|
||||
|
||||
panel_groups() { applets panels; }
|
||||
desktops() { applets desktops; }
|
||||
|
||||
last_repair() {
|
||||
user journalctl --user -u plasma-plasmashell.service --no-pager -o cat \
|
||||
| grep '^classroom-desktop-wallpaper:' | tail -1
|
||||
}
|
||||
|
||||
restart_shell() {
|
||||
user systemctl --user restart plasma-plasmashell.service
|
||||
for _ in $(seq 1 30); do
|
||||
in_lab 'pgrep -x plasmashell >/dev/null' && break
|
||||
sleep 1
|
||||
done
|
||||
sleep 10
|
||||
}
|
||||
|
||||
section "the package"
|
||||
check "the look and feel's default names a package" EkklesiaClassroom \
|
||||
"$(in_lab 'sed -n "/^\[Wallpaper\]/,/^\[/s/^Image=//p" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/defaults')"
|
||||
check "which is installed, and is the classroom's picture" /usr/share/backgrounds/ekklesia/classroom-calm.png \
|
||||
"$(in_lab 'readlink -f /usr/share/wallpapers/EkklesiaClassroom/contents/images/*.png')"
|
||||
check "the repair runs before every shell start" 1 \
|
||||
"$(in_lab 'grep -c "^ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper" /etc/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf')"
|
||||
|
||||
section "the session as it came up"
|
||||
check "the room shows the classroom's picture" classroom "$(screen)"
|
||||
original=$(activities CurrentActivity | cut -d'"' -f2)
|
||||
|
||||
section "a desktop Plasma makes by itself (Tiny Pebbles' television)"
|
||||
panels_before=$(panel_groups)
|
||||
late=$(activities AddActivity s "Late screen" | cut -d'"' -f2)
|
||||
activities SetCurrentActivity s "$late" >/dev/null
|
||||
settle
|
||||
now=$(desktops)
|
||||
check "Plasma made a second desktop" 2 "$(wc -w <<< "$now")"
|
||||
check "and wrote no wallpaper into it, as on Tiny Pebbles" 1 "$(grep -o ':none' <<< "$now" | wc -l)"
|
||||
check "the room still shows the classroom's picture" classroom "$(screen)"
|
||||
|
||||
section "the shell restarted, without a login"
|
||||
restart_shell
|
||||
check "the repair wrote it in" "classroom-desktop-wallpaper: the wallpaper is now configured on desktop $(tr ' ' '\n' <<< "$now" | cut -d: -f1 | paste -sd, | sed 's/,/, /g')" \
|
||||
"$(last_repair)"
|
||||
check "every desktop now names the picture" 0 "$(desktops | grep -o ':none' | wc -l)"
|
||||
check "the panel keeps its numbers, applets and order" "$panels_before" "$(panel_groups)"
|
||||
check "the room shows the classroom's picture" classroom "$(screen)"
|
||||
restart_shell
|
||||
check "a second restart finds nothing to do" \
|
||||
"classroom-desktop-wallpaper: desktop $(desktops | tr ' ' '\n' | cut -d: -f1 | paste -sd, | sed 's/,/, /g'): the classroom wallpaper already" \
|
||||
"$(last_repair)"
|
||||
check "and still does" "$panels_before" "$(panel_groups)"
|
||||
|
||||
activities SetCurrentActivity s "$original" >/dev/null
|
||||
activities RemoveActivity s "$late" >/dev/null
|
||||
sleep 3
|
||||
|
||||
section "Tiny Pebbles' own file: desktop 10, panel 1, no wallpaper"
|
||||
user systemctl --user stop plasma-plasmashell.service
|
||||
in_lab_fed "python3 - '$original' $APPLETS" <<'PY'
|
||||
import sys
|
||||
activity, path = sys.argv[1], sys.argv[2]
|
||||
applets = ["quicklaunch", "panelspacer", "icontasks", "panelspacer",
|
||||
"networkmanagement", "volume", "notifications", "digitalclock"]
|
||||
out = ["[Containments][1]", "activityId=", "formfactor=2", "immutability=1",
|
||||
"lastScreen=0", "location=4", "plugin=org.kde.panel",
|
||||
"wallpaperplugin=org.kde.image", ""]
|
||||
for number, applet in enumerate(applets, start=2):
|
||||
out += [f"[Containments][1][Applets][{number}]", "immutability=1",
|
||||
f"plugin=org.kde.plasma.{applet}", ""]
|
||||
out += ["[Containments][1][Applets][4][Configuration][General]",
|
||||
"launchers=applications:ekklesia-wonderink.desktop,applications:google-chrome.desktop,applications:ekklesia-dvd.desktop",
|
||||
"sortingStrategy=1", "",
|
||||
"[Containments][1][General]", "AppletOrder=2;3;4;5;6;7;8;9", "",
|
||||
"[Containments][10]", f"activityId={activity}", "formfactor=0",
|
||||
"immutability=1", "lastScreen=0", "location=0",
|
||||
"plugin=org.kde.plasma.folder", "wallpaperplugin=org.kde.image", "",
|
||||
"[Containments][10][General]", 'positions={"1920x1080":[]}', ""]
|
||||
open(path, "w").write("\n".join(out))
|
||||
PY
|
||||
in_lab "chown classroom:classroom $APPLETS"
|
||||
panels_before=$(panel_groups)
|
||||
user systemctl --user start plasma-plasmashell.service
|
||||
sleep 12
|
||||
check "the repair found desktop 10 and wrote it" \
|
||||
"classroom-desktop-wallpaper: the wallpaper is now configured on desktop 10" "$(last_repair)"
|
||||
check "desktop 10 names the picture" "10:image" "$(desktops)"
|
||||
check "the panel is still containment 1, applets 2 to 9, in order" "$panels_before" "$(panel_groups)"
|
||||
check "the room shows the classroom's picture" classroom "$(screen)"
|
||||
|
||||
section "and at the next login"
|
||||
"$LAB" plasma-down >/dev/null
|
||||
"$LAB" plasma-up >/dev/null
|
||||
settle 10
|
||||
check "the reset left the repair nothing to do" \
|
||||
"classroom-desktop-wallpaper: no desktop configuration yet; the layout writes the wallpaper" \
|
||||
"$(last_repair)"
|
||||
check "the layout rebuilt one desktop with the picture" 1 "$(desktops | grep -o ':image' | wc -l)"
|
||||
check "the room shows the classroom's picture" classroom "$(screen)"
|
||||
|
||||
printf '\n%s passed, %s failed\n' "$PASS" "$FAIL"
|
||||
[[ "$FAIL" -eq 0 ]]
|
||||
@@ -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:])
|
||||
@@ -0,0 +1,431 @@
|
||||
#!/bin/bash
|
||||
# Does Wonder Ink print — and does a job really reach CUPS?
|
||||
#
|
||||
# The lab's CUPS gets an "Elmer" built from Elmer's own PPD and options,
|
||||
# writing to a file instead of socket://10.10.0.155:9100, so every job
|
||||
# goes through the same filters the real printer's jobs do and none can
|
||||
# reach a printer. A second queue refuses jobs, to show a teacher is told.
|
||||
#
|
||||
# Each scenario runs the INSTALLED classroom-wonderink under a real
|
||||
# QtWebEngine (offscreen), presses the preview's buttons, and is then
|
||||
# judged by CUPS's own record of what it was given.
|
||||
#
|
||||
# tools/lab/wonderink-print-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}"
|
||||
|
||||
lab() { docker exec -i "$NAME" bash -c "$1"; }
|
||||
|
||||
# Not /tmp: in the Plasma lab /tmp is a tmpfs, and docker cp writes into
|
||||
# the layer underneath it, where nothing can see the file. Measured — the
|
||||
# first run there "failed" every scenario for exactly that reason.
|
||||
RIG=/opt/wonderink-check/wonderink-print-rig.py
|
||||
lab 'mkdir -p /opt/wonderink-check'
|
||||
docker cp "$ROOT/tools/lab/wonderink-print-rig.py" "$NAME:$RIG" >/dev/null
|
||||
MEASURE=/opt/wonderink-check/wonderink-picture-measure.py
|
||||
docker cp "$ROOT/tools/lab/wonderink-picture-measure.py" "$NAME:$MEASURE" >/dev/null
|
||||
SITE=/opt/wonderink-check/wonderink-lesson-site.py
|
||||
SITE_LOG=/opt/wonderink-check/site.log
|
||||
docker cp "$ROOT/tools/lab/wonderink-lesson-site.py" "$NAME:$SITE" >/dev/null
|
||||
lab "chmod -R a+rX /opt/wonderink-check && test -r $RIG" || {
|
||||
echo "could not put the rig in the lab" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The church's printer network is fenced off inside the lab. Checked, not
|
||||
# assumed: a lab job must never be able to reach the real Elmer.
|
||||
lab 'ip route replace blackhole 10.10.0.0/16 2>/dev/null
|
||||
! timeout 3 bash -c "exec 3<>/dev/tcp/10.10.0.155/9100" 2>/dev/null' || {
|
||||
echo "REFUSING TO CONTINUE: the real Elmer is reachable from the lab" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
lab '
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
command -v lpadmin >/dev/null && command -v pdftoppm >/dev/null || \
|
||||
apt-get install -y -qq cups cups-client poppler-utils >/dev/null 2>&1
|
||||
dpkg -s foomatic-db-compressed-ppds >/dev/null 2>&1 || \
|
||||
apt-get install -y -qq foomatic-db-compressed-ppds cups-filters ghostscript >/dev/null 2>&1
|
||||
grep -q "^FileDevice Yes" /etc/cups/cups-files.conf || echo "FileDevice Yes" >> /etc/cups/cups-files.conf
|
||||
pkill -x cupsd || true; sleep 1; /usr/sbin/cupsd; sleep 1
|
||||
cupsctl --debug-logging
|
||||
: > /var/log/cups/error_log
|
||||
mkdir -p /var/lab-print && chown lp /var/lab-print
|
||||
driver="foomatic-db-compressed-ppds:0/ppd/foomatic-ppd/Generic-PCL_6_PCL_XL_Printer-pxlcolor.ppd"
|
||||
for q in $(lpstat -e 2>/dev/null); do lpadmin -x "$q"; done
|
||||
lpadmin -p Elmer -E -v file:///var/lab-print/elmer.pxl -m "$driver" \
|
||||
-D "LAB STAND-IN for Elmer: its PPD, a file for a printer" \
|
||||
-o PrintoutMode=Normal -o ColorModel=Color 2>/dev/null
|
||||
lpadmin -d Elmer
|
||||
lpadmin -p Refusing -E -v file:///dev/null -m "$driver" -D "LAB: refuses jobs" 2>/dev/null
|
||||
cupsreject Refusing
|
||||
id teacher >/dev/null 2>&1 || useradd -m teacher
|
||||
install -d -o teacher -m 700 /tmp/teacher-rt
|
||||
# An inline PDF, served the way a site shows one rather than sends it. Its
|
||||
# own port and directory: a lab often has another test server on 8088.
|
||||
mkdir -p /opt/wonderink-check/www
|
||||
# CUPS keeps what it was given, so a check can compare the submitted
|
||||
# file with the one the preview showed, byte for byte.
|
||||
cupsctl PreserveJobFiles=Yes
|
||||
' || { echo "could not prepare the lab's CUPS" >&2; exit 1; }
|
||||
|
||||
lab "test -f /opt/wonderink-check/www/ark-wide.webp || \
|
||||
QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS='--no-sandbox --disable-gpu' \
|
||||
python3 $MEASURE make /opt/wonderink-check/www; \
|
||||
chmod -R a+r /opt/wonderink-check/www; test -f /opt/wonderink-check/www/ark-wide.webp" || {
|
||||
echo "could not make the test pictures" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Started detached. A background process started inside a docker exec
|
||||
# dies with it in the Plasma lab, whose PID 1 is systemd.
|
||||
lab 'pgrep -f "[h]ttp.server 8089" >/dev/null' || \
|
||||
docker exec -d -w /opt/wonderink-check/www "$NAME" python3 -m http.server 8089
|
||||
|
||||
# The signed-in lesson with pictures in it, restarted each run so that its
|
||||
# one-time addresses start unused.
|
||||
lab "pkill -f '[w]onderink-lesson-site.py' ; : > $SITE_LOG; chmod a+rw $SITE_LOG"
|
||||
docker exec -d "$NAME" python3 "$SITE" /opt/wonderink-check/www 8094 8095 "$SITE_LOG"
|
||||
lab 'for _ in $(seq 20); do curl -s -o /dev/null http://127.0.0.1:8094/login && exit 0; sleep 0.3; done; exit 1' || {
|
||||
echo "the lesson site did not start" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
passed=0
|
||||
failed=0
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# Runs one scenario; leaves its output in $out and the jobs it caused in $jobs.
|
||||
run() {
|
||||
local before
|
||||
before=$(lab 'lpstat -W all -o 2>/dev/null | wc -l')
|
||||
out=$(lab "su teacher -c 'cd /tmp; XDG_RUNTIME_DIR=/tmp/teacher-rt QT_QPA_PLATFORM=offscreen \
|
||||
QTWEBENGINE_CHROMIUM_FLAGS=\"--no-sandbox --disable-gpu\" \
|
||||
timeout 120 python3 $RIG $COMMAND $1 2>&1'; echo \"exit=\$?\"")
|
||||
sleep 3
|
||||
jobs=$(lab "lpstat -W all -o | tail -n +$((before + 1)) | awk '{print \$1}' | tr '\n' ' '")
|
||||
job_options() { lab "grep -E '\\[Job ${1#*-}\\] argv\\[(4|5)\\]' /var/log/cups/error_log"; }
|
||||
job_device() { lab "grep -E '\\[Job ${1#*-}\\]' /var/log/cups/error_log | grep -oE 'sDEVICE=[a-z]+' | sort -u"; }
|
||||
}
|
||||
|
||||
count() { wc -w <<< "$jobs"; }
|
||||
# The file CUPS was handed for a job, kept by PreserveJobFiles.
|
||||
spool_file() { local id="${1%% *}"; printf '/var/spool/cups/d%05d-001' "${id##*-}"; }
|
||||
said() { grep -q -- "$1" <<< "$out"; }
|
||||
# Every line of the rig's output that starts with WORD.
|
||||
lines() { grep -o -- "$1.*" <<< "$out"; }
|
||||
# Is every page of a PDF in the lab grey? {"pages", "spreads", "gray"}
|
||||
colour_of() { lab "python3 $MEASURE colour $1"; }
|
||||
is_gray() { python3 -c 'import json,sys; sys.exit(0 if json.loads(sys.argv[1])["gray"] else 1)' "$1"; }
|
||||
# What the lesson site was asked for after the right-click.
|
||||
fetched_since() {
|
||||
local since
|
||||
since=$(grep -o 'since=[0-9.]*' <<< "$out" | head -1 | cut -d= -f2)
|
||||
lab "python3 -c 'import json,sys; print(sum(1 for l in open(\"$SITE_LOG\") if json.loads(l)[\"t\"] > float(sys.argv[1])))' ${since:-0}"
|
||||
}
|
||||
teacher_text_kept() { said "CLIPBOARD \"TEACHER'S TEXT, John 3:16\""; }
|
||||
# picture layout, measured on the page CUPS kept: judges its own lines.
|
||||
judge_layout() { # FILE_DIMS SIZE POSITION PAPER FILL COLOUR
|
||||
local dims=$1 size=$2 position=$3 paper=$4 fill=$5 color=$6 spool result
|
||||
spool=$(spool_file "$jobs")
|
||||
judge "CUPS was given exactly the page the preview showed" \
|
||||
"[[ -n '$spool' ]] && lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
|
||||
result=$(lab "python3 $MEASURE measure $spool $dims $size $position $paper $fill")
|
||||
field() { python3 -c "import json,sys; print(json.loads(sys.argv[1])[sys.argv[2]])" "$result" "$1"; }
|
||||
judge "the paper is turned the way asked ($paper): $(field page)" \
|
||||
'[[ "$(field page_landscape)" == "$(field wanted_landscape)" ]]'
|
||||
judge "the picture is where and as large as asked, within 3 points (off by $(field off_by_points))" \
|
||||
'[[ "$(field off_by_points)" != None ]] && (( $(field off_by_points) <= 3 ))'
|
||||
judge "no ink anywhere else on the page — no bars ($(field ink_outside_picture) pixels)" \
|
||||
'[[ "$(field ink_outside_picture)" == 0 ]]'
|
||||
if [[ "$fill" == fill ]]; then
|
||||
# The mark is in the picture's left fifth, which filling a
|
||||
# portrait page with a wide picture cuts off. That is what the
|
||||
# control says it does; the fit scenarios prove it happens only
|
||||
# when asked.
|
||||
judge "Fill cut the edges off, as it says it will" '(( $(field pink_pixels) == 0 ))'
|
||||
elif [[ "$color" == gray ]]; then
|
||||
judge "the corner mark is on the paper, in grey — nothing cut off" '[[ "$(field mark_inked)" == True ]]'
|
||||
judge "and the page is grey, as the preview showed (colour spread $(field colour_spread))" \
|
||||
'(( $(field colour_spread) == 0 ))'
|
||||
judge "and it went in black and white, by the driver's own option" \
|
||||
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]'
|
||||
else
|
||||
judge "the pink corner mark is on the paper — nothing cut off" '(( $(field pink_pixels) > 0 ))'
|
||||
fi
|
||||
}
|
||||
exited_cleanly() { said 'exit=0' && ! said 'Traceback' && ! said 'Segmentation' && ! said 'Aborted'; }
|
||||
|
||||
# Where a picture goes, measured on the page CUPS was given:
|
||||
# picture:FILE:SIZE:POSITION:PAPER:FILL[:gray]
|
||||
PICTURES=(
|
||||
picture:ark-wide.png:100:centre:auto:fit
|
||||
picture:ark-wide.png:100:centre:portrait:fit
|
||||
picture:ark-wide.png:50:top-left:auto:fit
|
||||
picture:ark-tall.png:100:centre:auto:fit
|
||||
picture:ark-tall.png:33:bottom-right:portrait:fit
|
||||
picture:ark-wide.png:100:centre:portrait:fill
|
||||
picture:ark-wide.jpg:75:right:auto:fit:gray
|
||||
picture:ark-clear.png:50:top:auto:fit
|
||||
picture:ark-wide.png:50:top-left:auto:fit:gray
|
||||
)
|
||||
|
||||
# Black and white shown as it prints, and Color again (0.2.6.3).
|
||||
COLOURS=(bw-web bw-pdf bw-colour-again bw-cancel)
|
||||
|
||||
# Links in a signed-in lesson, followed with one click (0.2.6.4).
|
||||
LINKS=(links:follow links:pdf links:image links:image-rclick links:tabs-print)
|
||||
|
||||
# Right-click in a signed-in lesson (0.2.6.3):
|
||||
# rclick-image:ELEMENT:SIZE:POSITION[:gray], the elements in
|
||||
# wonderink-lesson-site.py.
|
||||
RIGHT_CLICKS=(
|
||||
rclick-page
|
||||
rclick-page-gray
|
||||
rclick-image:private-jpg:50:top-left
|
||||
rclick-image:private-png:100:centre
|
||||
rclick-image:clear:50:top-left
|
||||
rclick-image:once:100:centre
|
||||
rclick-image:cross:75:right
|
||||
rclick-image:blob:100:centre
|
||||
rclick-image:webp:100:centre
|
||||
rclick-image:canvas:100:centre
|
||||
rclick-image:linked:33:bottom-right
|
||||
rclick-image:private-jpg:50:top-left:gray
|
||||
rclick-broken
|
||||
rclick-cancel
|
||||
)
|
||||
|
||||
scenarios=("$@")
|
||||
[[ ${#scenarios[@]} -gt 0 ]] || scenarios=(web pdf pdf-options pdf-viewer-button inline
|
||||
options bad-range broken cancel page-closes-itself close-window page-prints-again repeat
|
||||
dark-page "${PICTURES[@]}" "${COLOURS[@]}" "${RIGHT_CLICKS[@]}" "${LINKS[@]}")
|
||||
|
||||
# The inline scenario needs a PDF to serve; the worksheet scenario writes one.
|
||||
lab 'test -f /opt/wonderink-check/www/ark.pdf' || { run pdf >/dev/null; lab 'cp "/home/teacher/Downloads/Wonder Ink/Colour the Ark.pdf" /opt/wonderink-check/www/ark.pdf; chmod a+r /opt/wonderink-check/www/ark.pdf'; }
|
||||
|
||||
for scenario in "${scenarios[@]}"; do
|
||||
echo "$scenario"
|
||||
run "$scenario"
|
||||
|
||||
judge "Wonder Ink did not crash, and exited by itself" exited_cleanly
|
||||
|
||||
# The preview has to SHOW the page, not merely count it. A preview
|
||||
# that printed the right thing while showing a blank was what the
|
||||
# first version of this check let through.
|
||||
if said 'PREVIEW '; then
|
||||
judge "the preview drew the page it will print" "! grep 'PREVIEW ' <<< \"\$out\" | grep -q 'shown=False'"
|
||||
fi
|
||||
|
||||
case "$scenario" in
|
||||
web)
|
||||
judge "the preview showed the lesson, 3 Letter pages, Elmer chosen" \
|
||||
"said \"pages=3 printers=\\['Elmer', 'Refusing'\\] paper=Letter\""
|
||||
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
|
||||
judge "Elmer's filters printed it in colour" '[[ "$(job_device $jobs)" == *pxlcolor* ]]'
|
||||
judge "and nothing was left behind" "said 'scratch_left=\\[\\]'" ;;
|
||||
pdf|pdf-viewer-button)
|
||||
judge "the preview showed the worksheet's own 2 pages" "said 'pages=2'"
|
||||
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]' ;;
|
||||
pdf-options)
|
||||
judge "one job" '[[ $(count) -eq 1 ]]'
|
||||
judge "page 2 only, three copies" \
|
||||
'[[ "$(job_options $jobs)" == *"argv[4]=\"3\""* && "$(job_options $jobs)" == *page-ranges=2* ]]'
|
||||
judge "in black and white, by the driver's own option" \
|
||||
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
|
||||
inline)
|
||||
judge "the inline PDF was recognised" "said \"mime='application/pdf'\""
|
||||
judge "and opened as a worksheet tab" "said 'current_is_pdf=True'"
|
||||
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]' ;;
|
||||
options)
|
||||
judge "pages 2-3, two copies, black and white" \
|
||||
'[[ "$(job_options $jobs)" == *page-ranges=2-3* && "$(job_options $jobs)" == *"argv[4]=\"2\""* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
|
||||
bad-range)
|
||||
judge "a page that does not exist is explained, not printed" "said 'This document has 3 pages'"
|
||||
judge "and once corrected, one job" '[[ $(count) -eq 1 ]]' ;;
|
||||
broken)
|
||||
judge "a refusing printer is reported in CUPS's words" "said 'Refusing did not take the job'"
|
||||
judge "and no job is claimed" '[[ $(count) -eq 0 ]] && ! said "NOTIFY Sent"' ;;
|
||||
cancel)
|
||||
judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]' ;;
|
||||
page-closes-itself)
|
||||
judge "the preview says the page closed, and cannot print" \
|
||||
"said \"status='The page was closed.' print_enabled=False\""
|
||||
judge "and sends nothing" '[[ $(count) -eq 0 ]]' ;;
|
||||
close-window)
|
||||
judge "closing Wonder Ink with the preview open sends nothing" '[[ $(count) -eq 0 ]]' ;;
|
||||
page-prints-again)
|
||||
judge "a second print request does not open a second preview" "said 'still one preview=True'"
|
||||
judge "one job" '[[ $(count) -eq 1 ]]' ;;
|
||||
repeat)
|
||||
judge "five prints in a row, five jobs" '[[ $(count) -eq 5 ]]' ;;
|
||||
dark-page)
|
||||
judge "one job" '[[ $(count) -eq 1 ]]'
|
||||
spool=$(spool_file "$jobs")
|
||||
answer=$(lab "python3 $MEASURE darkness $spool")
|
||||
dark=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["dark_fraction"])' "$answer")
|
||||
ink=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["ink_fraction"])' "$answer")
|
||||
judge "a black page background does not print (dark: $dark of the page)" \
|
||||
"python3 -c 'import sys; sys.exit(0 if $dark < 0.05 else 1)'"
|
||||
judge "and its white text still does, darkened ($ink of the page)" \
|
||||
"python3 -c 'import sys; sys.exit(0 if $ink > 0.005 else 1)'" ;;
|
||||
picture:*)
|
||||
IFS=: read -r _ file size position paper fill color <<< "$scenario"
|
||||
judge "the picture controls appeared, and none for a page" "said 'PICTURE picture_box=True'"
|
||||
judge "the print interface says Color" "said \"labels='Color','Color'\""
|
||||
if [[ "$color" == gray ]]; then
|
||||
judge "the preview shows it in black and white" "said 'shown_gray=True'"
|
||||
fi
|
||||
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
|
||||
dims=$(lab "python3 -c 'from PyQt6.QtGui import QImage; i=QImage(\"/opt/wonderink-check/www/$file\"); print(f\"{i.width()}x{i.height()}\")'" 2>/dev/null)
|
||||
judge_layout "$dims" "$size" "$position" "$paper" "$fill" "$color" ;;
|
||||
bw-*)
|
||||
judge "the preview starts in colour" "said 'COLOUR FIRST shown_gray=False'"
|
||||
if [[ "$scenario" != bw-colour-again ]]; then
|
||||
judge "Black and white shows a grey page, every time it is chosen" \
|
||||
'! lines "want=gray" | grep -qv "shown_gray=True" && lines "want=gray" | grep -q .'
|
||||
fi
|
||||
if lines "want=colour" | grep -q .; then
|
||||
judge "Color shows the colour page again, every time" '! lines "want=colour" | grep -qv "shown_gray=False"'
|
||||
fi
|
||||
case "$scenario" in
|
||||
bw-web)
|
||||
judge "turned over and over, it stays on page 2 of 3" '! lines TOGGLE | grep -qv "page=2/3"'
|
||||
judge "black and white is made once, and the same file shown each time" \
|
||||
'[[ $(lines "want=gray" | grep -o "pdf=[^ ]*" | sort -u | wc -l) -eq 1 ]]'
|
||||
judge "and shown again at once (after the first: $(lines 'want=gray' | tail -n +2 | grep -o 'took=[0-9.]*' | tr '\n' ' '))" \
|
||||
'! lines "want=gray" | tail -n +2 | grep -o "took=[0-9.]*" | cut -d= -f2 | grep -qv "^0\.[0-4]"'
|
||||
judge "one job" '[[ $(count) -eq 1 ]]'
|
||||
judge "page 2 only, two copies" \
|
||||
'[[ "$(job_options $jobs)" == *page-ranges=2* && "$(job_options $jobs)" == *"argv[4]=\"2\""* ]]'
|
||||
judge "in black and white, by the driver's own option" \
|
||||
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
|
||||
bw-pdf)
|
||||
judge "one job" '[[ $(count) -eq 1 ]]'
|
||||
judge "in black and white, by the driver's own option" \
|
||||
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
|
||||
bw-colour-again)
|
||||
judge "one job" '[[ $(count) -eq 1 ]]'
|
||||
judge "in colour, with no grey option" \
|
||||
'[[ "$(job_options $jobs)" != *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlcolor* ]]' ;;
|
||||
bw-cancel)
|
||||
judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]'
|
||||
judge "and nothing was left behind" "said 'scratch_left=\[\]'" ;;
|
||||
esac
|
||||
if [[ $(count) -eq 1 ]]; then
|
||||
spool=$(spool_file "$jobs")
|
||||
judge "CUPS was given exactly the file the preview showed" "lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
|
||||
answer=$(colour_of "$spool")
|
||||
if [[ "$scenario" == bw-colour-again ]]; then
|
||||
judge "and it is in colour ($answer)" '! is_gray "$answer"'
|
||||
else
|
||||
judge "and every page of it is grey ($answer)" 'is_gray "$answer"'
|
||||
fi
|
||||
fi ;;
|
||||
rclick-page)
|
||||
judge "right-click on the words offers Print page, and not Print image" \
|
||||
"said \"MENU .*'Print page…'\" && ! said \"'Print image…'\""
|
||||
judge "which prints the lesson page, not a picture" "said 'PREVIEW picture=False'"
|
||||
judge "one job" '[[ $(count) -eq 1 ]]'
|
||||
judge "CUPS was given exactly the page the preview showed" \
|
||||
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'"
|
||||
judge "the teacher's clipboard is untouched" teacher_text_kept ;;
|
||||
links:follow)
|
||||
# link_is WHICH EXPECTED — the rig's line for that link contains EXPECTED.
|
||||
link_is() { grep -o -- "LINK $1 .*" <<< "$out" | head -1 | grep -qF -- "$2"; }
|
||||
judge "a text link to a picture opens it in a new tab, and Wonder Ink shows that tab" \
|
||||
"link_is '#blank' \"tabs=2 current='/private/ark-wide.png' current_is_new=True\""
|
||||
judge "another site's link: the same" \
|
||||
"link_is '#external' \"tabs=3 current='http://localhost:8095/img/ark-tall.png' current_is_new=True\""
|
||||
judge "a linked picture: the same" \
|
||||
"link_is '#linked-picture' \"tabs=4 current='/private/ark-tall.png' current_is_new=True\""
|
||||
judge "a worksheet PDF: a new tab, in the PDF viewer" \
|
||||
"link_is '#pdf' \"tabs=5 current='/private/ark.pdf' current_is_new=True\""
|
||||
judge "a lesson button that opens a window: a new tab, shown" \
|
||||
"link_is '#popup' \"tabs=6 current='/private/ark-clear.png' current_is_new=True\""
|
||||
judge "a lesson button that is not a link: no tab, no navigation" \
|
||||
"link_is '#button' \"tabs=6 current='/links' current_is_new=False\""
|
||||
judge "an in-page anchor: stays on the page, scrolled" \
|
||||
"link_is '#anchor' \"tabs=6 current='/links#bottom' current_is_new=False\""
|
||||
judge "a link with no target: followed in the same tab, as a browser does" \
|
||||
"link_is '#same' \"tabs=6 current='/lesson' current_is_new=False\""
|
||||
judge "and Back returns to the links" "link_is 'after Back' \"current='/links\""
|
||||
judge "closing every tab the links opened leaves the lesson's own" \
|
||||
"link_is 'after closing' \"tabs=1 current='/links\""
|
||||
judge "every page and picture the links reached came through the signed-in session" \
|
||||
"! lab \"grep '\\\"signed_in\\\": false' $SITE_LOG | grep -qE '\\\"path\\\": \\\"/(links|lesson|private)'\"" ;;
|
||||
links:pdf)
|
||||
judge "a worksheet reached by a link opens as a worksheet and prints" \
|
||||
"said 'pages=2' && [[ \$(count) -eq 1 ]]"
|
||||
judge "CUPS was given exactly the file the preview showed" \
|
||||
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'" ;;
|
||||
links:image|links:image-rclick)
|
||||
if [[ "$scenario" == links:image-rclick ]]; then
|
||||
judge "right-click on the picture a link opened offers Print image" "said \"MENU .*'Print image…'\""
|
||||
fi
|
||||
judge "the picture a link opened gets the picture layout" "said 'PICTURE picture_box=True image=1600x1000'"
|
||||
judge "one job" '[[ $(count) -eq 1 ]]'
|
||||
judge "CUPS was given exactly the page the preview showed" \
|
||||
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'" ;;
|
||||
links:tabs-print)
|
||||
judge "three links opened and two closed again: two tabs left, back on the links" \
|
||||
"said \"LINK before printing tabs=2 current='/links'\""
|
||||
judge "the page they came from prints, one job" '[[ $(count) -eq 1 ]] && said "PREVIEW picture=False"'
|
||||
judge "CUPS was given exactly the page the preview showed" \
|
||||
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'" ;;
|
||||
rclick-page-gray)
|
||||
judge "a lesson page full of pictures turns grey in the preview ($(lines 'GRAY PAGE' | grep -o 'took=[0-9.]*'))" \
|
||||
"said 'GRAY PAGE .*shown_gray=True'"
|
||||
judge "one job" '[[ $(count) -eq 1 ]]'
|
||||
spool=$(spool_file "$jobs")
|
||||
judge "CUPS was given exactly the file the preview showed" "lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
|
||||
answer=$(colour_of "$spool")
|
||||
judge "and every page of it is grey ($answer)" 'is_gray "$answer"'
|
||||
judge "in black and white, by the driver's own option" \
|
||||
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
|
||||
rclick-image:*)
|
||||
IFS=: read -r _ element size position color <<< "$scenario"
|
||||
judge "right-click on the picture offers Print image and Print page" \
|
||||
"said \"MENU .*'Print image…', 'Print page…'\""
|
||||
judge "Print image opens the picture layout" "said 'PICTURE picture_box=True'"
|
||||
judge "named for the lesson it came from" "said \"title='Picture from Picture lesson'\""
|
||||
judge "nothing was fetched again to get it ($(fetched_since) requests)" '[[ $(fetched_since) -eq 0 ]]'
|
||||
judge "the teacher's clipboard is theirs again" teacher_text_kept
|
||||
if [[ "$color" == gray ]]; then
|
||||
judge "the preview shows it in black and white" "said 'shown_gray=True'"
|
||||
fi
|
||||
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
|
||||
dims=$(grep -o 'image=[0-9]*x[0-9]*' <<< "$out" | head -1 | cut -d= -f2)
|
||||
judge_layout "$dims" "$size" "$position" auto fit "$color" ;;
|
||||
rclick-broken)
|
||||
judge "a picture that never loaded is still offered as a picture" "said \"MENU .*'Print image…'\""
|
||||
judge "and the teacher is told it could not be taken, with Print off" \
|
||||
"said \"BROKEN status='This picture could not be taken from the page\" && said 'print_enabled=False pages=0 picture=False'"
|
||||
judge "the whole page is not printed instead" '[[ $(count) -eq 0 ]]'
|
||||
judge "the teacher's clipboard is untouched" teacher_text_kept ;;
|
||||
rclick-cancel)
|
||||
judge "the picture preview came up" "said 'PREVIEW picture=True'"
|
||||
judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]'
|
||||
judge "the teacher's clipboard is theirs again" teacher_text_kept
|
||||
judge "and nothing was left behind" "said 'scratch_left=\[\]'" ;;
|
||||
esac
|
||||
|
||||
[[ "$failed" -eq 0 ]] || { echo "$out" | tail -15 | sed 's/^/ /'; }
|
||||
done
|
||||
|
||||
echo
|
||||
echo "$passed passed, $failed failed"
|
||||
[[ "$failed" -eq 0 ]]
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Drive Wonder Ink's print preview through one scenario, headless.
|
||||
|
||||
usage: wonderink-print-rig.py WONDERINK_COMMAND SCENARIO
|
||||
|
||||
Run by tools/lab/wonderink-print-check inside the lab, against the
|
||||
command the package installed. It presses the preview's own buttons the
|
||||
way a teacher would, and prints what happened; the check around it asks
|
||||
CUPS what actually arrived.
|
||||
"""
|
||||
import importlib.machinery, importlib.util, sys, time, faulthandler, gc, os
|
||||
faulthandler.enable()
|
||||
path, scenario = sys.argv[1], sys.argv[2]
|
||||
loader = importlib.machinery.SourceFileLoader("wonderink", path)
|
||||
spec = importlib.util.spec_from_loader("wonderink", loader)
|
||||
wi = importlib.util.module_from_spec(spec); loader.exec_module(wi)
|
||||
|
||||
from pathlib import Path
|
||||
from PyQt6.QtCore import QTimer, QUrl
|
||||
from PyQt6.QtGui import QPdfWriter, QPainter, QFont, QPageSize
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtWebEngineCore import QWebEngineProfile, QWebEngineSettings
|
||||
|
||||
notes = []
|
||||
wi.notify = lambda s, b: (notes.append(s), print("NOTIFY", s, "|", b, flush=True))
|
||||
app = QApplication(sys.argv)
|
||||
# Wonder Ink's own profile, settings and all — not one of the rig's.
|
||||
profile = wi.build_profile(app, "rig")
|
||||
window = wi.WonderInk(profile)
|
||||
profile.downloadRequested.connect(window.on_download)
|
||||
window.resize(1280, 800); window.show()
|
||||
t0 = time.monotonic()
|
||||
def stamp(m): print(f"[{time.monotonic()-t0:6.2f}s] {m}", flush=True)
|
||||
|
||||
def lesson(view, title="Noahs Ark lesson"):
|
||||
view.setHtml(f"<html><head><title>{title}</title></head><body>" + "".join(
|
||||
f"<h1>{title}, part {i}</h1><p>{'Noah built an ark. ' * 80}</p>" for i in range(5)
|
||||
) + "</body></html>", QUrl("https://app.wonderink.org/lesson"))
|
||||
|
||||
def worksheet(name="Colour the Ark"):
|
||||
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))
|
||||
pa.drawText(200, 400, f"WORKSHEET {name} page one"); pa.drawRect(200, 600, 2000, 1500)
|
||||
w.newPage(); pa.drawText(200, 400, f"WORKSHEET {name} page two"); pa.end()
|
||||
return p
|
||||
|
||||
def when(cond, then, label, limit=30):
|
||||
start = time.monotonic()
|
||||
def poll():
|
||||
try: ok = cond()
|
||||
except Exception as e: ok = False
|
||||
if ok: return then()
|
||||
if time.monotonic() - start > limit:
|
||||
stamp(f"GAVE UP waiting for {label}"); app.exit(4); return
|
||||
QTimer.singleShot(100, poll)
|
||||
QTimer.singleShot(100, poll)
|
||||
|
||||
def preview(): return window._preview
|
||||
def ready(): return preview() is not None and preview()._pdf is not None
|
||||
def describe(d):
|
||||
return f"picture={d._picture_box.isVisible()} shown={(d._pdf, d._page) in d._images} pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}"
|
||||
|
||||
def shown_gray():
|
||||
"""Is the page the preview is drawing grey, pixel for pixel?"""
|
||||
pixmap = preview()._view._pixmap
|
||||
return None if pixmap is None else pixmap.toImage().allGray()
|
||||
|
||||
def keep_preview():
|
||||
import shutil
|
||||
out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True)
|
||||
shutil.copy(preview()._pdf, out / "preview.pdf")
|
||||
|
||||
def toggles(sequence, then):
|
||||
"""Color (0) and Black and white (1) in turn, as a teacher would, each shown before the next."""
|
||||
def step(i):
|
||||
if i == len(sequence): return then()
|
||||
start = time.monotonic(); preview()._colour.setCurrentIndex(sequence[i])
|
||||
def shown():
|
||||
d = preview()
|
||||
stamp(f"TOGGLE {i} want={'gray' if sequence[i] else 'colour'} took={time.monotonic() - start:.2f} "
|
||||
f"pdf={d._pdf.name} source={d._source.name} page={d._page}/{d._pages} shown_gray={shown_gray()}")
|
||||
step(i + 1)
|
||||
when(ready, shown, f"toggle {i}")
|
||||
step(0)
|
||||
|
||||
def colour_lesson(view, title="Rainbow lesson"):
|
||||
# Backgrounds do not print, so the colour is in the words and the drawings.
|
||||
shapes = ("<svg width='600' height='120'><circle cx='60' cy='60' r='50' fill='#e00000'/>"
|
||||
"<rect x='130' y='10' width='200' height='100' fill='#0050e0'/>"
|
||||
"<circle cx='450' cy='60' r='50' fill='#00a000'/></svg>")
|
||||
view.setHtml(f"<html><head><title>{title}</title></head><body>" + "".join(
|
||||
f"<h1 style='color:#c00'>{title}, part {i}</h1>{shapes}"
|
||||
f"<p style='color:#060'>{'God put a rainbow in the sky. ' * 60}</p>" for i in range(4)
|
||||
) + "</body></html>", QUrl("https://app.wonderink.org/rainbow"))
|
||||
|
||||
def colour_worksheet(name="Colour the Rainbow"):
|
||||
from PyQt6.QtGui import QColor
|
||||
d = wi.DOWNLOADS; d.mkdir(parents=True, exist_ok=True)
|
||||
p = d / f"{name}.pdf"
|
||||
w = QPdfWriter(str(p)); w.setPageSize(QPageSize(QPageSize.PageSizeId.Letter)); w.setResolution(300)
|
||||
pa = QPainter(w); pa.setFont(QFont("DejaVu Sans", 28))
|
||||
for page, colours in enumerate((("#e00000", "#ff9900"), ("#00a000", "#0050e0"))):
|
||||
if page: w.newPage()
|
||||
pa.setPen(QColor(colours[0])); pa.drawText(200, 400, f"WORKSHEET {name} page {page + 1}")
|
||||
pa.fillRect(200, 600, 1000, 800, QColor(colours[1]))
|
||||
pa.end()
|
||||
return p
|
||||
|
||||
def finish(code=0):
|
||||
stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}")
|
||||
QTimer.singleShot(1500, lambda: app.exit(code))
|
||||
|
||||
def press_print(then=None):
|
||||
d = preview(); stamp("PREVIEW " + describe(d))
|
||||
d._print.click()
|
||||
stamp(f"after Print: open={preview() is not None} status={d._status.text() if preview() else ''!r}")
|
||||
(then or finish)()
|
||||
|
||||
actions = {
|
||||
"web": lambda view: when(ready, press_print, "preview"),
|
||||
"cancel": lambda view: when(ready, lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()), "preview"),
|
||||
"options": lambda view: when(ready, lambda: (
|
||||
preview()._which.setCurrentIndex(1), preview()._range.setText("2-3"),
|
||||
preview()._copies.setValue(2), preview()._colour.setCurrentIndex(1),
|
||||
when(ready, press_print, "black and white")), "preview"),
|
||||
"bad-range": lambda view: when(ready, lambda: (
|
||||
preview()._which.setCurrentIndex(1), preview()._range.setText("7"), preview()._print.click(),
|
||||
stamp(f"bad range: open={preview() is not None} status={preview()._status.text()!r}"),
|
||||
preview()._range.setText("1"), press_print()), "preview"),
|
||||
"broken": lambda view: when(ready, lambda: (
|
||||
preview()._printer.setCurrentText("Refusing"),
|
||||
stamp(f"switched to Refusing: paper={preview().paper()} status={preview()._status.text()!r}"),
|
||||
when(ready, lambda: (
|
||||
stamp(f"re-rendered on {preview().paper()}: pages={preview()._pages}"),
|
||||
preview()._print.click(),
|
||||
stamp(f"broken: open={preview() is not None} status={preview()._status.text()!r}"),
|
||||
preview()._cancel.click(), finish()), "re-render")), "preview"),
|
||||
"page-closes-itself": lambda view: when(ready, lambda: (
|
||||
view.page().runJavaScript("window.close()"),
|
||||
when(lambda: preview() and "closed" in preview()._status.text(), lambda: (
|
||||
stamp(f"after page closed: status={preview()._status.text()!r} print_enabled={preview()._print.isEnabled()}"),
|
||||
preview()._cancel.click(), finish()), "closed message")), "preview"),
|
||||
"close-window": lambda view: when(ready, lambda: (stamp("closing window with preview open"), window.close()), "preview"),
|
||||
"page-prints-again": lambda view: when(ready, lambda: (
|
||||
view.page().runJavaScript("window.print()"),
|
||||
QTimer.singleShot(800, lambda: (stamp(f"second request while open: still one preview={preview() is not None}"), press_print()))), "preview"),
|
||||
}
|
||||
|
||||
SITE = "http://127.0.0.1:8094"
|
||||
|
||||
if scenario.startswith("bw-"):
|
||||
# bw-web: a colourful lesson, turned to page 2, Color and Black and white
|
||||
# back and forth, then page 2 twice in black and white.
|
||||
# bw-pdf: a colourful worksheet, all of it, in black and white.
|
||||
# bw-colour-again: black and white, then back to Color, then Print.
|
||||
# bw-cancel: black and white, then Cancel.
|
||||
if scenario == "bw-pdf":
|
||||
window.open_pdf(colour_worksheet()); view = window.current()
|
||||
else:
|
||||
view = window.open_tab(); colour_lesson(view)
|
||||
def colour_first():
|
||||
stamp(f"COLOUR FIRST shown_gray={shown_gray()} pdf={preview()._pdf.name}")
|
||||
if scenario == "bw-web":
|
||||
preview()._turn(1)
|
||||
toggles([1, 0, 1, 0, 1], lambda: (
|
||||
preview()._which.setCurrentIndex(1), preview()._range.setText("2"),
|
||||
preview()._copies.setValue(2), keep_preview(), press_print()))
|
||||
elif scenario == "bw-pdf":
|
||||
toggles([1], lambda: (keep_preview(), press_print()))
|
||||
elif scenario == "bw-colour-again":
|
||||
toggles([1, 0], lambda: (keep_preview(), press_print()))
|
||||
else:
|
||||
toggles([1], lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()))
|
||||
begin = lambda *_: (QTimer.singleShot(0, lambda: window.print_view(view)), when(ready, colour_first, "preview"))
|
||||
if scenario == "bw-pdf":
|
||||
QTimer.singleShot(2500, begin)
|
||||
else:
|
||||
view.loadFinished.connect(begin)
|
||||
elif scenario.startswith("links:"):
|
||||
# links:follow every kind of link on the lab's signed-in links page,
|
||||
# clicked in turn: what opens where, and what does not
|
||||
# links:pdf a worksheet reached by a link, printed
|
||||
# links:image a picture reached by a link, printed as a picture
|
||||
# links:image-rclick the same picture, right-click Print image
|
||||
# links:tabs-print several links opened, some closed, then the page printed
|
||||
from PyQt6.QtCore import QEvent, QPointF, Qt
|
||||
from PyQt6.QtGui import QMouseEvent
|
||||
from PyQt6.QtWidgets import QApplication as Application
|
||||
variant = scenario.split(":", 1)[1]
|
||||
links_view = window.open_tab(); links_view.load(QUrl(f"{SITE}/login"))
|
||||
def on_links():
|
||||
return links_view.url().path() == "/lesson" or links_view.url().path() == "/links"
|
||||
def click_in(view, selector, then, button=Qt.MouseButton.LeftButton):
|
||||
js = ("(function(){var e=document.querySelector(%r); if(!e) return null; e.scrollIntoView({block:'center'});"
|
||||
"var r=e.getBoundingClientRect(); return [r.x+r.width/2, r.y+r.height/2]})()" % selector)
|
||||
def at(xy):
|
||||
if not xy:
|
||||
stamp(f"NO {selector}"); return finish(6)
|
||||
zoom = view.zoomFactor(); point = QPointF(xy[0] * zoom, xy[1] * zoom)
|
||||
for kind, buttons in ((QEvent.Type.MouseButtonPress, button), (QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
|
||||
Application.sendEvent(view.focusProxy(), QMouseEvent(kind, point, QPointF(view.mapToGlobal(point)),
|
||||
button, buttons, Qt.KeyboardModifier.NoModifier))
|
||||
QTimer.singleShot(1500, then)
|
||||
view.page().runJavaScript(js, at)
|
||||
def tabs():
|
||||
return [window._tabs.widget(i).url().toString().replace(SITE, "") for i in range(window._tabs.count())]
|
||||
def report(label):
|
||||
current = window.current()
|
||||
stamp(f"LINK {label} tabs={len(tabs())} current={current.url().toString().replace(SITE, '')!r} "
|
||||
f"current_is_new={current is not links_view} worksheet={current in window._pdf_views}")
|
||||
def back_to_links():
|
||||
window._tabs.setCurrentWidget(links_view)
|
||||
def go_links(then):
|
||||
# From the lesson the site sent us to after signing in, to the links page, in this tab.
|
||||
links_view.load(QUrl(f"{SITE}/links"))
|
||||
when(lambda: links_view.url().path() == "/links", lambda: QTimer.singleShot(1200, then), "the links page")
|
||||
steps = []
|
||||
if variant == "follow":
|
||||
order = [("#blank", "text link, target=_blank, to a picture"), ("#external", "another site"),
|
||||
("#linked-picture", "a linked picture"), ("#pdf", "a worksheet PDF"),
|
||||
("#popup", "a lesson button that opens a window"), ("#button", "a lesson button"),
|
||||
("#anchor", "an in-page anchor"), ("#same", "a link with no target")]
|
||||
def next_link(i=0):
|
||||
if i == len(order):
|
||||
# Back, as the right-click menu's Back does, then close every tab the links opened.
|
||||
links_view.page().triggerAction(links_view.page().WebAction.Back)
|
||||
def closed_all():
|
||||
report("after Back")
|
||||
for view in [window._tabs.widget(j) for j in range(window._tabs.count())]:
|
||||
if view is not links_view:
|
||||
window.close_tab(window._tabs.indexOf(view))
|
||||
QTimer.singleShot(800, lambda: (report("after closing the opened tabs"),
|
||||
stamp(f"TABS {tabs()}"), finish()))
|
||||
return QTimer.singleShot(2000, closed_all)
|
||||
selector, label = order[i]
|
||||
back_to_links()
|
||||
click_in(links_view, selector, lambda: (report(f"{selector} ({label})"),
|
||||
stamp(f"BUTTON {links_view.page().title()!r}") if selector == "#button" else None,
|
||||
QTimer.singleShot(1500 if selector == "#pdf" else 200, lambda: next_link(i + 1))))
|
||||
when(on_links, lambda: go_links(lambda: (report("start"), next_link())), "signed in")
|
||||
elif variant in ("pdf", "image", "image-rclick", "tabs-print"):
|
||||
target = {"pdf": "#pdf", "image": "#blank", "image-rclick": "#blank", "tabs-print": "#blank"}[variant]
|
||||
def opened():
|
||||
report(f"{target} opened")
|
||||
view = window.current()
|
||||
if variant == "pdf":
|
||||
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(window.current())),
|
||||
when(ready, lambda: (keep_preview(), press_print()), "worksheet preview")))
|
||||
elif variant == "image":
|
||||
QTimer.singleShot(0, lambda: window.print_view(view))
|
||||
when(lambda: ready() and preview()._picture is not None, lambda: (
|
||||
stamp(f"PICTURE picture_box={preview()._picture_box.isVisible()} image={preview()._picture.width()}x{preview()._picture.height()}"),
|
||||
keep_preview(), press_print()), "picture preview")
|
||||
elif variant == "image-rclick":
|
||||
def menu_up(menu):
|
||||
items = [a.text() for a in menu.actions() if a.text()]
|
||||
stamp(f"MENU {items}")
|
||||
action = next(a for a in menu.actions() if a.text() == "Print image…")
|
||||
menu.close()
|
||||
when(lambda: ready() and preview()._picture is not None, lambda: (
|
||||
stamp(f"PICTURE picture_box={preview()._picture_box.isVisible()} image={preview()._picture.width()}x{preview()._picture.height()}"),
|
||||
keep_preview(), press_print()), "picture preview")
|
||||
QTimer.singleShot(0, action.trigger)
|
||||
click_in(view, "img", lambda: when(lambda: Application.activePopupWidget() is not None,
|
||||
lambda: menu_up(Application.activePopupWidget()), "menu"),
|
||||
button=Qt.MouseButton.RightButton)
|
||||
else:
|
||||
# Three links opened, two of them closed again, then the page they came from printed.
|
||||
def more():
|
||||
back_to_links()
|
||||
click_in(links_view, "#external", lambda: (back_to_links(), click_in(links_view, "#linked-picture", close_two)))
|
||||
def close_two():
|
||||
for view in [window._tabs.widget(j) for j in range(window._tabs.count())][-2:]:
|
||||
window.close_tab(window._tabs.indexOf(view))
|
||||
back_to_links(); report("before printing")
|
||||
QTimer.singleShot(0, lambda: window.print_view(links_view))
|
||||
when(ready, lambda: (keep_preview(), press_print()), "page preview")
|
||||
more()
|
||||
when(on_links, lambda: go_links(lambda: (back_to_links(), click_in(links_view, target, lambda: QTimer.singleShot(1500, opened)))), "signed in")
|
||||
elif scenario.startswith("rclick"):
|
||||
# A lesson behind a sign-in, with pictures in it; a real right-click.
|
||||
# rclick-page on the words: the page prints
|
||||
# rclick-image:ID:SIZE:POSITION[:gray] on a picture: that picture prints
|
||||
# rclick-broken a picture that never loaded
|
||||
# rclick-cancel a picture, then Cancel
|
||||
from PyQt6.QtCore import QEvent, QPointF, Qt
|
||||
from PyQt6.QtGui import QMouseEvent
|
||||
from PyQt6.QtWidgets import QApplication as Application
|
||||
parts = scenario.split(":")
|
||||
target = {"rclick-page": "words", "rclick-page-gray": "words", "rclick-broken": "broken",
|
||||
"rclick-cancel": "private-jpg"}.get(parts[0], parts[1] if len(parts) > 1 else "")
|
||||
clipboard = Application.clipboard()
|
||||
view = window.open_tab(); view.load(QUrl(f"{SITE}/login"))
|
||||
page_ready = []
|
||||
def poll_ready():
|
||||
if view.url().path() == "/lesson":
|
||||
view.page().runJavaScript("window.__ready === true", lambda ok: page_ready.append(1) if ok else None)
|
||||
if not page_ready: QTimer.singleShot(200, poll_ready)
|
||||
QTimer.singleShot(500, poll_ready)
|
||||
def right_click(then):
|
||||
js = ("(function(){var e=document.getElementById(%r);e.scrollIntoView({block:'center'});"
|
||||
"var r=e.getBoundingClientRect();return [r.x+r.width/2,r.y+r.height/2]})()" % target)
|
||||
def at(xy):
|
||||
zoom = view.zoomFactor(); x, y = xy[0] * zoom, xy[1] * zoom
|
||||
clipboard.setText("TEACHER'S TEXT, John 3:16")
|
||||
stamp(f"RCLICK {target} at {x:.0f},{y:.0f} since={time.time():.3f}")
|
||||
for kind, buttons in ((QEvent.Type.MouseButtonPress, Qt.MouseButton.RightButton),
|
||||
(QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
|
||||
Application.sendEvent(view.focusProxy(), QMouseEvent(
|
||||
kind, QPointF(x, y), QPointF(view.mapToGlobal(QPointF(x, y))),
|
||||
Qt.MouseButton.RightButton, buttons, Qt.KeyboardModifier.NoModifier))
|
||||
when(lambda: Application.activePopupWidget() is not None,
|
||||
lambda: then(Application.activePopupWidget()), "menu")
|
||||
view.page().runJavaScript(js, at)
|
||||
def choose(menu, text, after):
|
||||
items = [a.text() for a in menu.actions() if a.text()]
|
||||
stamp(f"MENU {items}")
|
||||
action = next((a for a in menu.actions() if a.text() == text), None)
|
||||
menu.close()
|
||||
if action is None:
|
||||
stamp(f"NO {text!r} IN THE MENU"); return finish(5)
|
||||
after()
|
||||
QTimer.singleShot(0, action.trigger)
|
||||
def picture_ready():
|
||||
d = preview(); return d is not None and d._picture is not None and d._pdf is not None
|
||||
def clipboard_line():
|
||||
stamp(f"CLIPBOARD {clipboard.text()!r}")
|
||||
if parts[0] == "rclick-page":
|
||||
go = lambda: right_click(lambda m: choose(m, "Print page…", lambda: when(ready, lambda: (
|
||||
clipboard_line(), keep_preview(), press_print()), "page preview")))
|
||||
elif parts[0] == "rclick-page-gray":
|
||||
# The page with all its pictures, in black and white: the slow case.
|
||||
def gray_page():
|
||||
start = time.monotonic(); preview()._colour.setCurrentIndex(1)
|
||||
when(ready, lambda: (
|
||||
stamp(f"GRAY PAGE took={time.monotonic() - start:.1f} shown_gray={shown_gray()} pages={preview()._pages}"),
|
||||
keep_preview(), press_print()), "black and white", limit=70)
|
||||
go = lambda: right_click(lambda m: choose(m, "Print page…", lambda: when(ready, gray_page, "page preview")))
|
||||
elif parts[0] == "rclick-broken":
|
||||
go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when(
|
||||
lambda: preview() is not None and "could not be taken" in preview()._status.text(), lambda: (
|
||||
stamp(f"BROKEN status={preview()._status.text()!r} print_enabled={preview()._print.isEnabled()} "
|
||||
f"pages={preview()._pages} picture={preview()._picture is not None}"),
|
||||
clipboard_line(), preview()._cancel.click(), finish()), "the picture failing", limit=15)))
|
||||
elif parts[0] == "rclick-cancel":
|
||||
go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when(picture_ready, lambda: (
|
||||
stamp("PREVIEW " + describe(preview())), clipboard_line(), preview()._cancel.click(), finish()),
|
||||
"picture preview")))
|
||||
else:
|
||||
_, _, size, position, *colour = parts
|
||||
def lay_out():
|
||||
d = preview()
|
||||
d._size.setCurrentIndex(d._size.findData(int(size)))
|
||||
d._place.choose(position.replace("-", " "))
|
||||
if colour == ["gray"]:
|
||||
d._colour.setCurrentIndex(1)
|
||||
QTimer.singleShot(900, lambda: when(ready, lambda: (
|
||||
stamp(f"PICTURE picture_box={d._picture_box.isVisible()} image={d._picture.width()}x{d._picture.height()} "
|
||||
f"title={d._title!r} shown_gray={shown_gray()}"),
|
||||
clipboard_line(), keep_preview(), press_print()), "layout"))
|
||||
go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when(picture_ready, lay_out, "picture preview")))
|
||||
when(lambda: bool(page_ready), go, "the lesson and its pictures", limit=30)
|
||||
elif scenario.startswith("picture:"):
|
||||
# picture:FILE:SIZE:POSITION:PAPER:FILL[:color]
|
||||
# Opens a picture on its own, chooses the layout with the preview's own
|
||||
# controls, keeps the exact PDF the preview showed, then prints it.
|
||||
_, name, size, position, paper, fill, *rest = scenario.split(":")
|
||||
view = window.open_tab(); view.load(QUrl(f"http://127.0.0.1:8089/{name}"))
|
||||
def ready_picture():
|
||||
d = preview()
|
||||
return d is not None and d._picture is not None and d._pdf is not None
|
||||
def choose():
|
||||
d = preview()
|
||||
d._size.setCurrentIndex(d._size.findData(int(size)))
|
||||
d._place.choose(position.replace("-", " "))
|
||||
d._turned.setCurrentIndex(d._turned.findData(paper))
|
||||
d._fill.setChecked(fill == "fill")
|
||||
if rest and rest[0] == "gray":
|
||||
d._colour.setCurrentIndex(1)
|
||||
# The layout settles (120 ms), then black and white if asked for.
|
||||
QTimer.singleShot(900, lambda: when(ready, keep_and_print, "layout"))
|
||||
def keep_and_print():
|
||||
import shutil
|
||||
d = preview()
|
||||
out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True)
|
||||
shutil.copy(d._pdf, out / "preview.pdf")
|
||||
stamp(f"PICTURE picture_box={d._picture_box.isVisible()} size_enabled={d._size.isEnabled()} "
|
||||
f"image={d._picture.width()}x{d._picture.height()} labels={d._colour_label.text()!r},{d._colour.itemText(0)!r} "
|
||||
f"shown_gray={shown_gray()}")
|
||||
press_print()
|
||||
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)),
|
||||
when(ready_picture, choose, "picture preview")))
|
||||
elif scenario == "dark-page":
|
||||
# A lesson page with a black background. Chrome does not print page
|
||||
# backgrounds unless asked, and neither may Wonder Ink.
|
||||
view = window.open_tab()
|
||||
view.setHtml("<html><head><title>Night sky</title></head><body style='background:#000;color:#fff'>"
|
||||
+ "<h1>Stars</h1>" + "<p>God made the stars. </p>" * 40 + "</body></html>",
|
||||
QUrl("https://app.wonderink.org/night"))
|
||||
def keep_dark():
|
||||
import shutil
|
||||
out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True)
|
||||
shutil.copy(preview()._pdf, out / "preview.pdf")
|
||||
press_print()
|
||||
view.loadFinished.connect(lambda ok: (QTimer.singleShot(0, lambda: window.print_view(view)),
|
||||
when(ready, keep_dark, "dark preview")))
|
||||
elif scenario == "repeat":
|
||||
view = window.open_tab(); lesson(view); n = [0]
|
||||
def once():
|
||||
if n[0] == 5: return finish()
|
||||
n[0] += 1; QTimer.singleShot(0, lambda: window.print_view(view))
|
||||
when(ready, lambda: press_print(then=lambda: QTimer.singleShot(200, once)), "preview")
|
||||
view.loadFinished.connect(lambda ok: once() if n[0] == 0 else None)
|
||||
elif scenario in ("pdf", "pdf-options"):
|
||||
window.open_pdf(worksheet()); view = window.current()
|
||||
if scenario == "pdf-options":
|
||||
actions["pdf-options"] = lambda view: when(ready, lambda: (
|
||||
preview()._which.setCurrentIndex(1), preview()._range.setText("2"),
|
||||
preview()._copies.setValue(3), preview()._colour.setCurrentIndex(1),
|
||||
when(ready, press_print, "black and white")), "preview")
|
||||
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)),
|
||||
actions[scenario if scenario == "pdf-options" else "web"](view)))
|
||||
elif scenario == "pdf-viewer-button":
|
||||
# The viewer's own print button reaches the page as a print request.
|
||||
window.open_pdf(worksheet()); view = window.current()
|
||||
QTimer.singleShot(2500, lambda: (actions["web"](view), view.page().printRequested.emit()))
|
||||
elif scenario == "inline":
|
||||
view = window.open_tab(); view.load(QUrl("http://127.0.0.1:8089/ark.pdf"))
|
||||
QTimer.singleShot(4000, lambda: (stamp(f"inline mime={window._mime.get(view)!r} shows_pdf={window._shows_pdf(view)}"),
|
||||
QTimer.singleShot(0, lambda: window.print_view(view)),
|
||||
when(ready, lambda: (stamp(f"worksheet tab opened: tabs={window._tabs.count()} current_is_pdf={window.current() in window._pdf_views}"), press_print()), "inline preview")))
|
||||
else:
|
||||
if scenario == "page-closes-itself":
|
||||
lesson(window.open_tab(), "The other lesson")
|
||||
view = window.open_tab(); lesson(view)
|
||||
fired = []
|
||||
def go(ok):
|
||||
if fired: return
|
||||
fired.append(1); QTimer.singleShot(0, lambda: window.print_view(view)); actions[scenario](view)
|
||||
view.loadFinished.connect(go)
|
||||
|
||||
QTimer.singleShot(90000, lambda: (stamp("TIMEOUT"), app.exit(3)))
|
||||
rc = app.exec()
|
||||
stamp(f"EXIT {rc} scratch_left={sorted(p.name for p in Path(os.environ.get('XDG_RUNTIME_DIR','/tmp')).glob('wonderink-print-*'))}")
|
||||
sys.exit(rc)
|
||||
@@ -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())
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/bin/bash
|
||||
# How large is a lesson in Wonder Ink, and do the keys change it?
|
||||
#
|
||||
# Runs the INSTALLED classroom-wonderink under a real QtWebEngine
|
||||
# (offscreen), builds its window the way main() does, and asks the page
|
||||
# itself what it is rendering at. Keys are delivered through Qt's own key
|
||||
# path to the focused page — the same shortcut map a keyboard reaches —
|
||||
# because the lab's injected keyboard does not reach the nested session.
|
||||
# A physical Ctrl+Plus is a hardware check.
|
||||
#
|
||||
# tools/lab/wonderink-zoom-check
|
||||
set -uo pipefail
|
||||
|
||||
NAME="${LAB_NAME:-classroom-v2-lab}"
|
||||
COMMAND="${WONDERINK:-/usr/local/bin/classroom-wonderink}"
|
||||
|
||||
docker exec -i \
|
||||
-e QT_QPA_PLATFORM=offscreen \
|
||||
-e QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu" \
|
||||
-e COMMAND="$COMMAND" \
|
||||
"$NAME" bash -c 'export XDG_RUNTIME_DIR=$(mktemp -d) HOME=$(mktemp -d); python3 -' <<'PY'
|
||||
import importlib.util, os, sys
|
||||
from importlib.machinery import SourceFileLoader
|
||||
|
||||
loader = SourceFileLoader("wonderink", os.environ["COMMAND"])
|
||||
spec = importlib.util.spec_from_loader("wonderink", loader)
|
||||
ink = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(ink)
|
||||
|
||||
from PyQt6.QtCore import QEventLoop, Qt, QTimer
|
||||
from PyQt6.QtTest import QTest
|
||||
from PyQt6.QtWebEngineCore import QWebEngineProfile
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv[:1])
|
||||
passed = failed = 0
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
global passed, failed
|
||||
if condition:
|
||||
print(f" \033[32mPASS\033[0m {label}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" \033[31mFAIL\033[0m {label}" + (f" [{detail}]" if detail else ""))
|
||||
failed += 1
|
||||
|
||||
|
||||
def wait(ms):
|
||||
loop = QEventLoop()
|
||||
QTimer.singleShot(ms, loop.quit)
|
||||
loop.exec()
|
||||
|
||||
|
||||
def page_dpr(view):
|
||||
answer = []
|
||||
view.page().runJavaScript("window.devicePixelRatio", answer.append)
|
||||
for _ in range(50):
|
||||
if answer:
|
||||
return answer[0]
|
||||
wait(100)
|
||||
return None
|
||||
|
||||
|
||||
PAGE = "data:text/html,<title>lesson</title><p>Noah built an ark.</p>"
|
||||
KEY = {
|
||||
"Ctrl+=": (Qt.Key.Key_Equal, Qt.KeyboardModifier.ControlModifier),
|
||||
"Ctrl+Plus": (Qt.Key.Key_Plus, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier),
|
||||
"keypad Ctrl+Plus": (Qt.Key.Key_Plus, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.KeypadModifier),
|
||||
"Ctrl+Minus": (Qt.Key.Key_Minus, Qt.KeyboardModifier.ControlModifier),
|
||||
"keypad Ctrl+Minus": (Qt.Key.Key_Minus, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.KeypadModifier),
|
||||
"Ctrl+0": (Qt.Key.Key_0, Qt.KeyboardModifier.ControlModifier),
|
||||
"Ctrl+Shift+Tab": (Qt.Key.Key_Backtab, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier),
|
||||
}
|
||||
|
||||
|
||||
def press(window, name):
|
||||
target = QApplication.focusWidget() or window.current()
|
||||
QTest.keyClick(target, *KEY[name])
|
||||
wait(400)
|
||||
|
||||
|
||||
def open_window(room_zoom):
|
||||
profile = QWebEngineProfile(f"zoomcheck{room_zoom}", app)
|
||||
window = ink.WonderInk(profile, room_zoom=room_zoom)
|
||||
window.resize(1280, 800)
|
||||
window.show()
|
||||
for _ in range(3):
|
||||
window.open_tab(PAGE)
|
||||
wait(2500)
|
||||
window.current().setFocus()
|
||||
wait(300)
|
||||
return window
|
||||
|
||||
|
||||
def factors(window):
|
||||
return [round(window._tabs.widget(i).zoomFactor(), 3) for i in range(window._tabs.count())]
|
||||
|
||||
|
||||
print("1. a room's normal size is the browser's")
|
||||
for room in (1.0, 1.5, 2.0):
|
||||
ink.SETTINGS.unlink(missing_ok=True)
|
||||
window = open_window(room)
|
||||
check(f"UI_SCALE {room}: every tab starts at {room}", factors(window) == [room] * 3, factors(window))
|
||||
window.close()
|
||||
wait(300)
|
||||
|
||||
print()
|
||||
print("2. the keys a teacher knows, at UI_SCALE 1.5")
|
||||
ink.SETTINGS.unlink(missing_ok=True)
|
||||
window = open_window(1.5)
|
||||
for name, level in (("Ctrl+=", 1.1), ("Ctrl+Plus", 1.25), ("keypad Ctrl+Plus", 1.5),
|
||||
("Ctrl+Minus", 1.25), ("keypad Ctrl+Minus", 1.1), ("Ctrl+0", 1.0)):
|
||||
press(window, name)
|
||||
check(f"{name} makes it {round(level * 100)}% in every tab",
|
||||
window.level() == level and factors(window) == [round(1.5 * level, 3)] * 3,
|
||||
f"level={window.level()} factors={factors(window)}")
|
||||
|
||||
press(window, "Ctrl+Plus")
|
||||
dpr = page_dpr(window.current())
|
||||
check("and the page itself renders at it", dpr is not None and abs(dpr - 1.65) < 0.01, dpr)
|
||||
|
||||
print()
|
||||
print("3. new tabs, and the next time Wonder Ink opens")
|
||||
view = window.open_tab(PAGE)
|
||||
wait(1500)
|
||||
check("a new tab starts at the teacher's size", round(view.zoomFactor(), 3) == 1.65, view.zoomFactor())
|
||||
check("the level is saved on its own", ink.SETTINGS.read_text().strip() == "LEVEL=1.1",
|
||||
ink.SETTINGS.read_text())
|
||||
check("in its own file, not the web profile",
|
||||
"wonderink-shell" not in str(ink.SETTINGS) and ink.SETTINGS.name == "wonderink.conf")
|
||||
index = window._tabs.currentIndex()
|
||||
press(window, "Ctrl+Shift+Tab")
|
||||
check("Ctrl+Shift+Tab still goes back a tab", window._tabs.currentIndex() == index - 1,
|
||||
f"{index} -> {window._tabs.currentIndex()}")
|
||||
window.close()
|
||||
wait(300)
|
||||
|
||||
window = open_window(2.0)
|
||||
check("reopened in a room at 2.0, the teacher's 110% is kept",
|
||||
window.level() == 1.1 and factors(window) == [2.2] * 3, factors(window))
|
||||
window.close()
|
||||
|
||||
ink.SETTINGS.write_text("LEVEL=banana\n")
|
||||
check("a damaged setting is the normal size", ink.load_level() == 1.0)
|
||||
ink.SETTINGS.write_text("LEVEL=40\n")
|
||||
check("an absurd one too", ink.load_level() == 1.0)
|
||||
|
||||
window = open_window(3.0)
|
||||
press(window, "keypad Ctrl+Plus"); press(window, "keypad Ctrl+Plus"); press(window, "keypad Ctrl+Plus")
|
||||
check("a large room zoomed further never passes the engine's limit",
|
||||
max(factors(window)) <= 5.0, factors(window))
|
||||
window.close()
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/bin/bash
|
||||
# THE RELEASE GATE: may this exact file go to a classroom?
|
||||
#
|
||||
# tools/release-gate.sh PACKAGE.deb [FROM_VERSION...]
|
||||
#
|
||||
# Runs tools/validate-trixie-release.sh against PACKAGE as
|
||||
#
|
||||
# * a fresh install;
|
||||
# * an upgrade from what canary serves today, on a canary room;
|
||||
# * an upgrade from what stable serves today, on a stable room;
|
||||
# * an upgrade from each FROM_VERSION, on a stable room — the release a
|
||||
# room is really still on, which can be several behind stable.
|
||||
#
|
||||
# The releases it upgrades from are downloaded over HTTPS the way a
|
||||
# classroom downloads them, and each is checked against the SHA-256 its
|
||||
# manifest publishes, or, for a FROM_VERSION, against the version dpkg
|
||||
# reads out of it. Nothing here needs a credential or writes to the
|
||||
# update server, so a CI runner can run it on code nobody has reviewed.
|
||||
#
|
||||
# Every run is one line in RELEASE_GATE_REPORT (default: printed only),
|
||||
# so the result can be attached to the release it describes.
|
||||
#
|
||||
# It stops at the first failure: a gate that carries on reports a pass
|
||||
# for a machine it never finished building.
|
||||
set -euo pipefail
|
||||
# The fetches below run in $(...), which otherwise ignores set -e: a
|
||||
# manifest that failed to download would go on to compare an empty hash
|
||||
# with an empty hash, and match.
|
||||
shopt -s inherit_errexit
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "$ROOT/tools/release-lib.sh"
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 PACKAGE.deb [FROM_VERSION...]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
candidate="$(realpath "$1")"
|
||||
shift
|
||||
extra_from=("$@")
|
||||
|
||||
[[ -f "$candidate" ]] || { echo "Package not found: $candidate" >&2; exit 1; }
|
||||
|
||||
version="$(release_package_version "$candidate")"
|
||||
release_require_version "$version"
|
||||
|
||||
# The name a release is published under is the version dpkg reads out of
|
||||
# it. A file called 0.2.6.6 that says it is 0.2.6.5 is the wrong file.
|
||||
if [[ "$(basename "$candidate")" != "$(release_package_name "$version")" ]]; then
|
||||
echo "$(basename "$candidate") says it is $version." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
hash="$(sha256sum "$candidate" | awk '{print $1}')"
|
||||
report="${RELEASE_GATE_REPORT:-}"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
record() {
|
||||
printf '%s\n' "$1"
|
||||
[[ -z "$report" ]] || printf '%s\n' "$1" >> "$report"
|
||||
}
|
||||
|
||||
[[ -z "$report" ]] || : > "$report"
|
||||
record "release gate for $(basename "$candidate")"
|
||||
record "sha256 $hash"
|
||||
record "started $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
# A channel's current package, fetched as a classroom fetches it and
|
||||
# proven to be what the manifest promises.
|
||||
fetch_channel() {
|
||||
local channel="$1" manifest from from_hash name got
|
||||
|
||||
manifest="$(curl -fsS -H 'Cache-Control: no-cache' "$RELEASE_URL/$channel/manifest.json")"
|
||||
read -r from from_hash < <(python3 -c 'import json, sys
|
||||
m = json.loads(sys.argv[1])
|
||||
print(m["version"], m["sha256"])' "$manifest")
|
||||
|
||||
release_require_version "$from"
|
||||
[[ "$from_hash" =~ ^[0-9a-f]{64}$ ]]
|
||||
name="$(release_package_name "$from")"
|
||||
curl -fsS -o "$work/$name" "$RELEASE_URL/packages/$name"
|
||||
got="$(sha256sum "$work/$name" | awk '{print $1}')"
|
||||
|
||||
if [[ "$got" != "$from_hash" ]]; then
|
||||
echo "$channel serves $name with sha256 $got; its manifest says $from_hash" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$work/$name"
|
||||
}
|
||||
|
||||
# A published release by version, for the rooms still on an older one.
|
||||
fetch_version() {
|
||||
local from="$1" name
|
||||
|
||||
release_require_version "$from"
|
||||
name="$(release_package_name "$from")"
|
||||
curl -fsS -o "$work/$name" "$RELEASE_URL/packages/$name"
|
||||
|
||||
if [[ "$(release_package_version "$work/$name")" != "$from" ]]; then
|
||||
echo "$name does not say it is $from" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$work/$name"
|
||||
}
|
||||
|
||||
run() {
|
||||
local label="$1" started
|
||||
shift
|
||||
started=$SECONDS
|
||||
|
||||
echo
|
||||
echo "== $label"
|
||||
"$@"
|
||||
record "PASS $label ($(( (SECONDS - started) / 60 )) min)"
|
||||
}
|
||||
|
||||
run "fresh install of $version" \
|
||||
"$ROOT/tools/validate-trixie-release.sh" "$candidate"
|
||||
|
||||
for channel in canary stable; do
|
||||
previous="$(fetch_channel "$channel")"
|
||||
from="$(release_package_version "$previous")"
|
||||
|
||||
if ! release_version_gt "$version" "$from"; then
|
||||
echo "$channel is on $from; $version is not newer, so no room could take it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run "upgrade $from -> $version on a $channel room ($channel serves $from)" \
|
||||
env VALIDATION_CHANNEL="$channel" \
|
||||
"$ROOT/tools/validate-trixie-release.sh" "$candidate" "$previous"
|
||||
done
|
||||
|
||||
for from in "${extra_from[@]}"; do
|
||||
previous="$(fetch_version "$from")"
|
||||
|
||||
run "upgrade $from -> $version on a stable room" \
|
||||
env VALIDATION_CHANNEL=stable \
|
||||
"$ROOT/tools/validate-trixie-release.sh" "$candidate" "$previous"
|
||||
done
|
||||
|
||||
record "finished $(date -u +%Y-%m-%dT%H:%M:%SZ): $(basename "$candidate") passed the release gate"
|
||||
@@ -0,0 +1,286 @@
|
||||
# Release helpers shared by tools/build-release.sh, update_canary.sh and
|
||||
# update_stable.sh. Sourced, not run.
|
||||
#
|
||||
# VERSIONS.
|
||||
#
|
||||
# 0.2.6 a feature release
|
||||
# 0.2.6.1 the first maintenance release of 0.2.6
|
||||
# 0.2.6.2 the second
|
||||
# 0.2.7 the next feature release
|
||||
#
|
||||
# Three or four dot-separated numbers, and nothing else. No leading zeros,
|
||||
# because dpkg compares 0.2.06 and 0.2.6 as the SAME version, and two
|
||||
# packages that dpkg thinks are equal are one package too many. No fourth
|
||||
# part of 0: maintenance releases count from 1, and 0.2.6.0 would sort
|
||||
# after 0.2.6 while claiming to be it.
|
||||
#
|
||||
# Ordering is dpkg's, always, through `dpkg --compare-versions`. That is
|
||||
# what a classroom's updater and apt use to decide what is newer, so
|
||||
# anything else here could only ever disagree with them.
|
||||
#
|
||||
# PUBLISHED IS PERMANENT.
|
||||
#
|
||||
# 0.2.6 was rebuilt and uploaded a second time under the same version, and
|
||||
# the second upload replaced the first on the server. A room that had
|
||||
# installed the first one could never be offered the second: the updater
|
||||
# only installs a version newer than its own, and they were equal. So a
|
||||
# version number, once it has been on the server, is never used again,
|
||||
# and a package file on the server is never written twice.
|
||||
|
||||
RELEASE_HOST="${RELEASE_HOST:-classroom-control}"
|
||||
RELEASE_ROOT="${RELEASE_ROOT:-/srv/ekklesia/classroom-updates}"
|
||||
RELEASE_URL="${RELEASE_URL:-https://updates.riverofglorymn.org}"
|
||||
# The local copy of what was published. ROOT is the checkout, set by
|
||||
# whichever script sourced this.
|
||||
RELEASE_LOCAL="${RELEASE_LOCAL:-$ROOT/repo}"
|
||||
|
||||
# The same pattern is in scripts/ekklesia-classroom-update, which is what
|
||||
# a classroom checks a manifest against. tests/test-release-versions.sh
|
||||
# holds both to the same list of good and bad versions.
|
||||
RELEASE_VERSION_PATTERN='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.[1-9][0-9]*)?$'
|
||||
|
||||
release_version_valid() {
|
||||
[[ "$1" =~ $RELEASE_VERSION_PATTERN ]]
|
||||
}
|
||||
|
||||
release_require_version() {
|
||||
if ! release_version_valid "$1"; then
|
||||
echo "Invalid version: $1" >&2
|
||||
echo "Expected MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH.MAINTENANCE," >&2
|
||||
echo "for example 0.2.7 or 0.2.6.1" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
release_version_gt() {
|
||||
dpkg --compare-versions "$1" gt "$2"
|
||||
}
|
||||
|
||||
release_package_name() {
|
||||
printf 'ekklesia-classroom_%s_amd64.deb\n' "$1"
|
||||
}
|
||||
|
||||
# The version dpkg itself reads out of a built package, which is the one a
|
||||
# classroom will report after installing it.
|
||||
release_package_version() {
|
||||
dpkg-deb --field "$1" Version
|
||||
}
|
||||
|
||||
release_remote() {
|
||||
ssh "$RELEASE_HOST" "$@"
|
||||
}
|
||||
|
||||
# Every version that has ever been published, one per line, read from the
|
||||
# package file names on the server and in the local copy of it. Names that
|
||||
# are not a classroom package (ekklesia-classroom-v2.deb) are skipped;
|
||||
# experiment builds such as 0.1.45~hypr1 are kept, because dpkg orders
|
||||
# them and they were published.
|
||||
release_published_versions() {
|
||||
{
|
||||
release_remote "ls -1 '$RELEASE_ROOT/packages'" || return 1
|
||||
ls -1 "$RELEASE_LOCAL/packages" 2>/dev/null || true
|
||||
} | sed -n 's/^ekklesia-classroom_\(.*\)_amd64\.deb$/\1/p' | sort -u
|
||||
}
|
||||
|
||||
release_highest() {
|
||||
local highest="" version
|
||||
|
||||
while read -r version; do
|
||||
[[ -n "$version" ]] || continue
|
||||
|
||||
if [[ -z "$highest" ]] || release_version_gt "$version" "$highest"; then
|
||||
highest="$version"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '%s\n' "$highest"
|
||||
}
|
||||
|
||||
# The version a channel's manifest on the server names, or nothing.
|
||||
release_channel_version() {
|
||||
release_remote "cat '$RELEASE_ROOT/$1/manifest.json' 2>/dev/null" \
|
||||
| python3 -c 'import json, sys
|
||||
try:
|
||||
print(json.load(sys.stdin)["version"])
|
||||
except Exception:
|
||||
pass'
|
||||
}
|
||||
|
||||
# May VERSION be published as a new package at all? It has to be valid,
|
||||
# its file name must never have been used, and it must be newer than every
|
||||
# package ever published, so that it is newer than whatever any room is
|
||||
# running whichever channel that room is on.
|
||||
release_check_new_version() {
|
||||
local version="$1" name published highest
|
||||
|
||||
release_require_version "$version" || return 1
|
||||
name="$(release_package_name "$version")"
|
||||
|
||||
published="$(release_published_versions)" || {
|
||||
echo "Could not list the published packages on $RELEASE_HOST." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
if grep -qxF -- "$version" <<< "$published"; then
|
||||
echo "$version has already been published." >&2
|
||||
echo "A published version is never rebuilt or replaced. Use the next one." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if release_remote "test -e '$RELEASE_ROOT/packages/$name'"; then
|
||||
echo "$name already exists on $RELEASE_HOST." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
highest="$(release_highest <<< "$published")"
|
||||
|
||||
if [[ -n "$highest" ]] && ! release_version_gt "$version" "$highest"; then
|
||||
echo "$version is not newer than $highest, the highest version already published." >&2
|
||||
echo "Every release must sort after every one before it." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Upload a package that has never been on the server, and prove that what
|
||||
# arrived is what was built. --ignore-existing is the second lock on the
|
||||
# rule above: even if the check were wrong, rsync would not replace a
|
||||
# published file.
|
||||
release_upload_package() {
|
||||
local package="$1" name local_hash remote_hash
|
||||
|
||||
name="$(basename "$package")"
|
||||
local_hash="$(sha256sum "$package" | awk '{print $1}')"
|
||||
|
||||
rsync -av --ignore-existing -e ssh \
|
||||
"$package" "$RELEASE_HOST:$RELEASE_ROOT/packages/"
|
||||
|
||||
remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')"
|
||||
|
||||
if [[ "$remote_hash" != "$local_hash" ]]; then
|
||||
echo "The package on $RELEASE_HOST does not match the one built here." >&2
|
||||
echo " built: $local_hash" >&2
|
||||
echo " on server: ${remote_hash:-missing}" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Write CHANNEL's manifest for VERSION locally, then upload it, then move
|
||||
# the channel's latest.deb. The package must already be on the server with
|
||||
# exactly the checksum the manifest is about to promise.
|
||||
release_publish_manifest() {
|
||||
local channel="$1" version="$2" hash="$3" name remote_hash
|
||||
|
||||
name="$(release_package_name "$version")"
|
||||
remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')"
|
||||
|
||||
if [[ "$remote_hash" != "$hash" ]]; then
|
||||
echo "Refusing to point $channel at $name:" >&2
|
||||
echo " the manifest would say $hash" >&2
|
||||
echo " the server has ${remote_hash:-no such file}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
mkdir -p "$RELEASE_LOCAL/$channel"
|
||||
|
||||
cat > "$RELEASE_LOCAL/$channel/manifest.json" <<EOF
|
||||
{
|
||||
"version": "$version",
|
||||
"package": "/packages/$name",
|
||||
"sha256": "$hash",
|
||||
"requires_reboot": true
|
||||
}
|
||||
EOF
|
||||
|
||||
# Manifest deliberately goes LAST, after the package it names.
|
||||
rsync -av -e ssh \
|
||||
"$RELEASE_LOCAL/$channel/manifest.json" \
|
||||
"$RELEASE_HOST:$RELEASE_ROOT/$channel/manifest.json"
|
||||
|
||||
# THE PERMANENT URL.
|
||||
#
|
||||
# .../CHANNEL/latest.deb is a symlink beside the manifest pointing at
|
||||
# the versioned package the manifest names, so there is one download
|
||||
# address that never changes and still no second place recording which
|
||||
# version is current. The manifest remains the only answer to that
|
||||
# question.
|
||||
#
|
||||
# It is swapped AFTER the manifest, and by rename, for two reasons. A
|
||||
# rename is atomic, so nothing ever downloads a half-written pointer;
|
||||
# and going last means latest.deb can only ever lag the manifest by a
|
||||
# moment, never lead it — it can never offer a package the channel has
|
||||
# not published. The versioned file itself is never touched again once
|
||||
# uploaded.
|
||||
release_remote "
|
||||
set -e
|
||||
cd '$RELEASE_ROOT/$channel'
|
||||
test -f ../packages/$name
|
||||
ln -sfn ../packages/$name latest.deb.new
|
||||
mv -Tf latest.deb.new latest.deb
|
||||
"
|
||||
}
|
||||
|
||||
# What a classroom on CHANNEL actually sees, read over HTTPS the way the
|
||||
# updater reads it: the manifest, the versioned package it names, and the
|
||||
# permanent latest.deb, all agreeing with each other and with EXPECTED if
|
||||
# one is given. Read-only; safe to run against stable at any time.
|
||||
#
|
||||
# A subshell, so the temporary directory is removed however it returns.
|
||||
release_verify_channel() (
|
||||
channel="$1"
|
||||
expected="${2:-}"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
manifest="$(curl -fsS -H 'Cache-Control: no-cache' \
|
||||
"$RELEASE_URL/$channel/manifest.json")" || {
|
||||
echo " FAIL $channel: manifest could not be fetched" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
read -r version package hash < <(python3 -c 'import json, sys
|
||||
m = json.loads(sys.argv[1])
|
||||
print(m["version"], m["package"], m["sha256"])' "$manifest")
|
||||
|
||||
name="$(release_package_name "$version")"
|
||||
|
||||
if [[ -n "$expected" && "$version" != "$expected" ]]; then
|
||||
echo " FAIL $channel: manifest says $version, expected $expected" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$package" != "/packages/$name" ]]; then
|
||||
echo " FAIL $channel: manifest names $package for version $version" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
curl -fsS -o "$work/versioned.deb" "$RELEASE_URL$package" || {
|
||||
echo " FAIL $channel: $package could not be downloaded" >&2
|
||||
return 1
|
||||
}
|
||||
curl -fsSL -H 'Cache-Control: no-cache' -o "$work/latest.deb" \
|
||||
"$RELEASE_URL/$channel/latest.deb" || {
|
||||
echo " FAIL $channel: latest.deb could not be downloaded" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
got_hash="$(sha256sum "$work/versioned.deb" | awk '{print $1}')"
|
||||
latest_hash="$(sha256sum "$work/latest.deb" | awk '{print $1}')"
|
||||
deb_version="$(release_package_version "$work/versioned.deb")"
|
||||
|
||||
if [[ "$got_hash" != "$hash" ]]; then
|
||||
echo " FAIL $channel: $name has sha256 $got_hash, manifest says $hash" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$latest_hash" != "$hash" ]]; then
|
||||
echo " FAIL $channel: latest.deb has sha256 $latest_hash, manifest says $hash" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$deb_version" != "$version" ]]; then
|
||||
echo " FAIL $channel: the package itself says $deb_version, manifest says $version" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo " ok $channel: $version, $name, sha256 $hash, latest.deb agrees"
|
||||
)
|
||||
@@ -18,6 +18,16 @@ if [[ $# -eq 2 ]]; then
|
||||
[[ -f "$previous" ]] || { echo "Package not found: $previous" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
# The channel the upgraded room is on. canary by default, which is the
|
||||
# machine a new release goes to first; stable to rehearse the day the
|
||||
# desktops are moved, from the release they are actually on.
|
||||
channel="${VALIDATION_CHANNEL:-canary}"
|
||||
|
||||
case "$channel" in
|
||||
stable|canary) ;;
|
||||
*) echo "VALIDATION_CHANNEL must be stable or canary" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
echo "Validating $(basename "$candidate") on Debian Trixie"
|
||||
|
||||
docker_args=(
|
||||
@@ -41,7 +51,7 @@ fi
|
||||
# instead teaches everybody to re-run it until it passes.
|
||||
timeout --signal=TERM --kill-after=15s 2700s \
|
||||
docker "${docker_args[@]}" debian:trixie-slim \
|
||||
bash -s -- "${previous:+/packages/previous.deb}" <<'CONTAINER'
|
||||
bash -s -- "${previous:+/packages/previous.deb}" "$channel" <<'CONTAINER'
|
||||
set -Eeuo pipefail
|
||||
|
||||
validation_failure() {
|
||||
@@ -87,6 +97,7 @@ run_logged() {
|
||||
run_logged apt-update 300 apt-get update -qq
|
||||
|
||||
previous="${1:-}"
|
||||
channel="${2:-canary}"
|
||||
|
||||
if [[ -n "$previous" ]]; then
|
||||
run_logged previous-install 1200 apt-get install -y -qq "$previous"
|
||||
@@ -94,7 +105,7 @@ if [[ -n "$previous" ]]; then
|
||||
cat > /etc/ekklesia-classroom/device.conf <<'CONFIG'
|
||||
# Configuration carried forward from 0.1.36
|
||||
UPDATE_URL=https://updates.riverofglorymn.org
|
||||
CHANNEL=canary
|
||||
CHANNEL=@CHANNEL@
|
||||
DVD_ENABLED=false
|
||||
UI_SCALE=1.25
|
||||
MQTT_ENABLED=true
|
||||
@@ -104,10 +115,36 @@ MQTT_USERNAME=classroom
|
||||
MQTT_PASSWORD=preserve-me
|
||||
CUSTOM_SETTING=preserve-me-too
|
||||
CONFIG
|
||||
sed -i "s/@CHANNEL@/${channel}/" /etc/ekklesia-classroom/device.conf
|
||||
previous_version="$(dpkg-query -W -f='${Version}' ekklesia-classroom)"
|
||||
|
||||
# The release this is an upgrade FROM, proven rather than assumed: an
|
||||
# install that silently did not happen makes every check below a
|
||||
# check of a fresh install instead.
|
||||
[[ "$previous_version" == "$(dpkg-deb --field "$previous" Version)" ]]
|
||||
[[ "$(dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom)" == "ii " ]]
|
||||
echo "installed the previous release, ekklesia-classroom $previous_version"
|
||||
fi
|
||||
|
||||
run_logged candidate-install 1200 apt-get install -y -qq /packages/candidate.deb
|
||||
|
||||
# THE VERSION, AS THE MACHINE REPORTS IT.
|
||||
#
|
||||
# Three places say which release a classroom is running: dpkg's database,
|
||||
# which the updater compares against the manifest; the VERSION file the
|
||||
# package carries; and the file name the release was published under.
|
||||
# They have to be one number, or a room either never updates or updates
|
||||
# for ever.
|
||||
candidate_version="$(dpkg-deb --field /packages/candidate.deb Version)"
|
||||
[[ "$(dpkg-query -W -f='${Version}' ekklesia-classroom)" == "$candidate_version" ]]
|
||||
[[ "$(dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom)" == "ii " ]]
|
||||
[[ "$(cat /usr/local/lib/ekklesia-classroom/VERSION)" == "$candidate_version" ]]
|
||||
echo "installed ekklesia-classroom $candidate_version${previous_version:+ over $previous_version}"
|
||||
|
||||
if [[ -n "${previous_version:-}" ]]; then
|
||||
dpkg --compare-versions "$candidate_version" gt "$previous_version"
|
||||
fi
|
||||
|
||||
command -v nmtui >/dev/null
|
||||
command -v lpadmin >/dev/null
|
||||
command -v lpinfo >/dev/null
|
||||
@@ -116,6 +153,20 @@ systemctl is-enabled NetworkManager.service >/dev/null
|
||||
systemctl is-enabled cups.service >/dev/null
|
||||
! systemctl is-enabled NetworkManager-wait-online.service >/dev/null 2>&1
|
||||
|
||||
# The update check: daily at 03:00, caught up after a night switched off,
|
||||
# and still so after this install or upgrade. The reboot after an update
|
||||
# waits until nobody is using the computer, in a unit nothing enables.
|
||||
systemctl is-enabled ekklesia-classroom-update.timer >/dev/null
|
||||
timer=/etc/systemd/system/ekklesia-classroom-update.timer
|
||||
grep -qx 'OnCalendar=\*-\*-\* 03:00:00' "$timer"
|
||||
grep -qx 'Persistent=true' "$timer"
|
||||
! grep -qE '^(OnBootSec|OnStartupSec|OnUnitActiveSec|OnUnitInactiveSec|OnActiveSec)=' "$timer"
|
||||
! ls /etc/systemd/system/ekklesia-classroom-update.timer.d/*.conf >/dev/null 2>&1
|
||||
test -x /usr/local/bin/classroom-reboot-when-idle
|
||||
[[ "$(systemctl is-enabled ekklesia-classroom-reboot-when-idle.service 2>/dev/null || true)" == "static" ]]
|
||||
! grep -q '"systemctl", "reboot"' /usr/local/bin/ekklesia-classroom-update
|
||||
grep -q 'ekklesia-classroom-reboot-when-idle.service' /usr/local/bin/ekklesia-classroom-update
|
||||
|
||||
# The audio policy reaches a session, and is ordered after the server it
|
||||
# actually talks to. Ordering it only after pipewire and wireplumber is
|
||||
# what let it race PipeWire's PulseAudio server at boot, fail its one
|
||||
@@ -316,12 +367,13 @@ grep -q 'PRESENTATIONS_ENABLED' /run/ekklesia-classroom/features.json
|
||||
classroom-manual --features | grep -qE 'PRESENTATIONS_ENABLED +shown'
|
||||
classroom-manual --features | grep -qE 'WONDERINK_ENABLED +hidden'
|
||||
|
||||
# The panel offers Presentations only where it is enabled, and the three
|
||||
# applications that were already on the bar keep their order.
|
||||
# The panel offers Presentations only where it is enabled, keeps the classroom
|
||||
# applications in order, and does not expose the separately installed manual
|
||||
# as a teacher-facing Help launcher.
|
||||
layout=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js
|
||||
grep -q 'var PRESENTATIONS = true;' "$layout"
|
||||
grep -q 'ekklesia-presentations.desktop' "$layout"
|
||||
grep -q 'ekklesia-manual.desktop' "$layout"
|
||||
! grep -q 'ekklesia-manual.desktop' "$layout"
|
||||
python3 - "$layout" <<'ORDER_CHECK'
|
||||
import re
|
||||
import sys
|
||||
@@ -375,6 +427,64 @@ timeout --signal=TERM --kill-after=5s 45s classroom-provision \
|
||||
>/tmp/provision-second.log
|
||||
grep -q 'Elmer is already correctly configured' /tmp/provision-second.log
|
||||
|
||||
# THE UPDATER THIS PACKAGE INSTALLED, AGAINST A MANIFEST.
|
||||
#
|
||||
# Served from this container, so nothing reaches the real update server.
|
||||
# The installed version has to be "up to date" against a manifest naming
|
||||
# itself — or the room reinstalls and reboots at every check — and has to
|
||||
# see the next feature release as newer.
|
||||
mkdir -p /tmp/updates/packages "/tmp/updates/$channel"
|
||||
cp /packages/candidate.deb "/tmp/updates/packages/ekklesia-classroom_${candidate_version}_amd64.deb"
|
||||
|
||||
serve_manifest() {
|
||||
python3 - "$1" "$channel" <<'MANIFEST'
|
||||
import hashlib, json, sys
|
||||
version, channel = sys.argv[1], sys.argv[2]
|
||||
name = f"ekklesia-classroom_{version}_amd64.deb"
|
||||
try:
|
||||
digest = hashlib.sha256(open(f"/tmp/updates/packages/{name}", "rb").read()).hexdigest()
|
||||
except FileNotFoundError:
|
||||
digest = "0" * 64
|
||||
json.dump({"version": version, "package": f"/packages/{name}",
|
||||
"sha256": digest, "requires_reboot": True},
|
||||
open(f"/tmp/updates/{channel}/manifest.json", "w"))
|
||||
MANIFEST
|
||||
}
|
||||
|
||||
python3 -m http.server --bind 127.0.0.1 --directory /tmp/updates 8765 \
|
||||
>/tmp/updates.log 2>&1 &
|
||||
updates_server=$!
|
||||
sleep 1
|
||||
|
||||
cp /etc/ekklesia-classroom/device.conf /tmp/device.conf.before-updater
|
||||
sed -i 's|^UPDATE_URL=.*|UPDATE_URL=http://127.0.0.1:8765|' \
|
||||
/etc/ekklesia-classroom/device.conf
|
||||
|
||||
serve_manifest "$candidate_version"
|
||||
ekklesia-classroom-update --check >/tmp/updater-same.log 2>&1
|
||||
grep -q "Already up to date" /tmp/updater-same.log
|
||||
|
||||
# The next milestone after whatever this is, and a release every
|
||||
# classroom has long since passed.
|
||||
IFS=. read -r major minor _ <<< "$candidate_version"
|
||||
next_version="${major}.$((minor + 1)).0"
|
||||
|
||||
serve_manifest "$next_version"
|
||||
ekklesia-classroom-update --check >/tmp/updater-next.log 2>&1
|
||||
grep -q "Update available: $candidate_version -> $next_version" /tmp/updater-next.log
|
||||
|
||||
serve_manifest 0.2.2
|
||||
ekklesia-classroom-update --check >/tmp/updater-older.log 2>&1
|
||||
grep -q "Already up to date" /tmp/updater-older.log
|
||||
|
||||
echo '{"version": "0.2.06", "package": "/packages/x.deb", "sha256": "x"}' \
|
||||
> "/tmp/updates/$channel/manifest.json"
|
||||
! ekklesia-classroom-update --check >/tmp/updater-bad.log 2>&1
|
||||
grep -q "invalid version" /tmp/updater-bad.log
|
||||
|
||||
kill "$updates_server"
|
||||
cp /tmp/device.conf.before-updater /etc/ekklesia-classroom/device.conf
|
||||
|
||||
if [[ -n "$previous" ]]; then
|
||||
# The room's own values, untouched by the upgrade.
|
||||
grep -q '^DVD_ENABLED=false$' /etc/ekklesia-classroom/device.conf
|
||||
@@ -385,7 +495,82 @@ if [[ -n "$previous" ]]; then
|
||||
# The channel a room was put on deliberately survives the upgrade.
|
||||
# canary is where the next development release goes, and the Plasma
|
||||
# classroom is the stable line now, so nothing here has to move.
|
||||
grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf
|
||||
grep -q "^CHANNEL=${channel}\$" /etc/ekklesia-classroom/device.conf
|
||||
|
||||
# An optional feature an existing room never had stays off. A
|
||||
# Presentations launcher appearing on the desktops' panels by itself
|
||||
# would be an upgrade changing a classroom, which upgrades do not do.
|
||||
[[ "$(grep -c '^PRESENTATIONS_ENABLED=' /etc/ekklesia-classroom/device.conf)" -eq 1 ]]
|
||||
grep -q '^PRESENTATIONS_ENABLED=false$' /etc/ekklesia-classroom/device.conf
|
||||
classroom-presentations 2>&1 | grep -q 'PRESENTATIONS_ENABLED is false'
|
||||
classroom-desktop-layout >/dev/null 2>&1 || true
|
||||
grep -q 'var PRESENTATIONS = false;' \
|
||||
/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js
|
||||
|
||||
# TINY PEBBLES, 0.2.2 -> 0.2.6.4: THE DESKTOP WITH NO WALLPAPER.
|
||||
#
|
||||
# The room's desktop was containment 10, made by Plasma after the
|
||||
# layout had run, with nothing configured on it; it showed Plasma's
|
||||
# own wallpaper. What a desktop gets when nothing wrote it is the look
|
||||
# and feel's default, and that has to be a package Plasma can find.
|
||||
lnf=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/defaults
|
||||
[[ "$(sed -n '/^\[Wallpaper\]/,/^\[/s/^Image=//p' "$lnf")" == EkklesiaClassroom ]]
|
||||
grep -q '"Id": "EkklesiaClassroom"' /usr/share/wallpapers/EkklesiaClassroom/metadata.json
|
||||
[[ "$(readlink -f /usr/share/wallpapers/EkklesiaClassroom/contents/images/*.png)" \
|
||||
== /usr/share/backgrounds/ekklesia/classroom-calm.png ]]
|
||||
grep -qx 'ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper' \
|
||||
/etc/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf
|
||||
|
||||
# And the repair the package installed, on that room's own file: the
|
||||
# panel built with no screen took 1 and its applets 2 to 9.
|
||||
wallpaper_home=$(mktemp -d)
|
||||
cat > "$wallpaper_home/plasma-org.kde.plasma.desktop-appletsrc" <<'APPLETS'
|
||||
[Containments][1]
|
||||
formfactor=2
|
||||
location=4
|
||||
plugin=org.kde.panel
|
||||
wallpaperplugin=org.kde.image
|
||||
|
||||
[Containments][1][Applets][2]
|
||||
plugin=org.kde.plasma.quicklaunch
|
||||
|
||||
[Containments][1][Applets][9]
|
||||
plugin=org.kde.plasma.digitalclock
|
||||
|
||||
[Containments][1][General]
|
||||
AppletOrder=2;3;4;5;6;7;8;9
|
||||
|
||||
[Containments][10]
|
||||
activityId=62d3d55d-bd31-4fc8-9a7b-a5b9b2b99b60
|
||||
formfactor=0
|
||||
immutability=1
|
||||
lastScreen=0
|
||||
location=0
|
||||
plugin=org.kde.plasma.folder
|
||||
wallpaperplugin=org.kde.image
|
||||
|
||||
[Containments][10][General]
|
||||
positions={"1920x1080":[]}
|
||||
APPLETS
|
||||
applets="$wallpaper_home/plasma-org.kde.plasma.desktop-appletsrc"
|
||||
panel_before=$(sed -n '/^\[Containments\]\[1\]/,/^$/p' "$applets")
|
||||
|
||||
! XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper --check >/dev/null
|
||||
XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper \
|
||||
| grep -q 'configured on desktop 10'
|
||||
sed -n '/^\[Containments\]\[10\]\[Wallpaper\]\[org.kde.image\]\[General\]$/,/^$/p' "$applets" \
|
||||
| grep -qx 'Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png'
|
||||
[[ "$(sed -n '/^\[Containments\]\[1\]/,/^$/p' "$applets")" == "$panel_before" ]]
|
||||
[[ "$(grep -c '^\[Containments\]\[[0-9]*\]$' "$applets")" -eq 2 ]]
|
||||
|
||||
# Twice changes nothing, and a room that was already right is not
|
||||
# written at all.
|
||||
repaired=$(sha256sum < "$applets")
|
||||
XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper \
|
||||
| grep -q 'the classroom wallpaper already'
|
||||
[[ "$(sha256sum < "$applets")" == "$repaired" ]]
|
||||
XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper --check >/dev/null
|
||||
rm -rf "$wallpaper_home"
|
||||
|
||||
# And a room that was carried through the experiment on its own
|
||||
# channel comes home, because that channel is the obsolete one now.
|
||||
@@ -394,7 +579,7 @@ if [[ -n "$previous" ]]; then
|
||||
grep -q '^CHANNEL=stable$' /etc/ekklesia-classroom/device.conf
|
||||
grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf
|
||||
grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf
|
||||
sed -i 's/^CHANNEL=.*/CHANNEL=canary/' /etc/ekklesia-classroom/device.conf
|
||||
sed -i "s/^CHANNEL=.*/CHANNEL=${channel}/" /etc/ekklesia-classroom/device.conf
|
||||
|
||||
# Every setting this release has that the room predates, appended
|
||||
# exactly once. classroom-migrate hands device.conf to the shared
|
||||
@@ -407,6 +592,8 @@ if [[ -n "$previous" ]]; then
|
||||
|
||||
run_logged candidate-reinstall 900 \
|
||||
apt-get install --reinstall -y -qq /packages/candidate.deb
|
||||
[[ "$(dpkg-query -W -f='${Version} ${db:Status-Abbrev}' ekklesia-classroom)" \
|
||||
== "$candidate_version ii " ]]
|
||||
|
||||
# An install that changes nothing must change nothing.
|
||||
for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE; do
|
||||
@@ -414,7 +601,7 @@ if [[ -n "$previous" ]]; then
|
||||
done
|
||||
grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf
|
||||
grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf
|
||||
grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf
|
||||
grep -q "^CHANNEL=${channel}\$" /etc/ekklesia-classroom/device.conf
|
||||
|
||||
# And by hand, on a machine that is already converged: the command a
|
||||
# technician runs must be safe to run twice.
|
||||
|
||||
@@ -4,96 +4,111 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
current_version="$(cat VERSION)"
|
||||
source "$ROOT/tools/release-lib.sh"
|
||||
|
||||
if [[ $# -eq 1 ]]; then
|
||||
version="$1"
|
||||
else
|
||||
IFS='.' read -r major minor patch <<< "$current_version"
|
||||
version="${major}.${minor}.$((patch + 1))"
|
||||
# The version is always said out loud.
|
||||
#
|
||||
# With only three parts there was one obvious next number. With four
|
||||
# there are two — 0.2.6 is followed by 0.2.6.1 if it is a fix and by
|
||||
# 0.2.7 if it is not — and which one a release is, is a decision about
|
||||
# what is in it, not arithmetic.
|
||||
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
||||
current="$(release_channel_version canary || true)"
|
||||
echo "Usage: $0 VERSION [PACKAGE.deb]" >&2
|
||||
echo >&2
|
||||
echo "With PACKAGE.deb, that file is gated and published and nothing is" >&2
|
||||
echo "built, so the file that was tested is the file that is published." >&2
|
||||
echo >&2
|
||||
echo "Canary is on ${current:-an unknown version}. Name the release, for example:" >&2
|
||||
if [[ -n "$current" ]] && release_version_valid "$current"; then
|
||||
IFS='.' read -r major minor patch maintenance <<< "$current"
|
||||
echo " $0 ${major}.${minor}.${patch}.$(( ${maintenance:-0} + 1 )) a maintenance release" >&2
|
||||
echo " $0 ${major}.${minor}.$(( patch + 1 )) the next feature release" >&2
|
||||
fi
|
||||
exit 2
|
||||
fi
|
||||
|
||||
package_name="ekklesia-classroom_${version}_amd64.deb"
|
||||
version="$1"
|
||||
package_name="$(release_package_name "$version")"
|
||||
package_path="$ROOT/dist/$package_name"
|
||||
prebuilt="${2:-}"
|
||||
|
||||
echo "Current version: $current_version"
|
||||
echo "Current canary: $(release_channel_version canary || echo unknown)"
|
||||
echo "New canary: $version"
|
||||
echo
|
||||
|
||||
# RELEASE_GATE_FROM="0.2.2" adds the upgrade from an older release that
|
||||
# rooms are still on to the gate, beside canary's and stable's own.
|
||||
#
|
||||
# Before anything is built: a version that has been published, or that
|
||||
# would not be newer than everything that has been, stops here.
|
||||
release_check_new_version "$version"
|
||||
|
||||
# ONE FILE, FROM THE GATE TO THE ROOM.
|
||||
#
|
||||
# Given a package, that package is what is gated and published, and
|
||||
# nothing is rebuilt: a build is not byte-for-byte repeatable, so a second
|
||||
# build is a second, untested file. Without one, it is built here.
|
||||
if [[ -n "$prebuilt" ]]; then
|
||||
package_path="$(realpath "$prebuilt")"
|
||||
|
||||
if [[ "$(basename "$package_path")" != "$package_name" ]]; then
|
||||
echo "$prebuilt is not named $package_name." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$(release_package_version "$package_path")" != "$version" ]]; then
|
||||
echo "$prebuilt says it is $(release_package_version "$package_path"), not $version." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Publishing the package already built: $package_path"
|
||||
else
|
||||
"$ROOT/tools/build-release.sh" "$version"
|
||||
|
||||
# Validate the exact artifact that will be published. If a previous canary is
|
||||
# available locally, exercise the real upgrade path as well as a fresh install.
|
||||
validation_args=("$package_path")
|
||||
|
||||
if [[ -f "$ROOT/repo/canary/manifest.json" ]]; then
|
||||
previous_package_name="$(python3 -c \
|
||||
'import json; print(json.load(open("repo/canary/manifest.json"))["package"].rsplit("/", 1)[-1])')"
|
||||
previous_package="$ROOT/repo/packages/$previous_package_name"
|
||||
|
||||
if [[ -f "$previous_package" && "$previous_package" != "$package_path" ]]; then
|
||||
validation_args+=("$previous_package")
|
||||
fi
|
||||
fi
|
||||
|
||||
"$ROOT/tools/validate-trixie-release.sh" "${validation_args[@]}"
|
||||
|
||||
# A fresh install, and the upgrade from whatever canary and stable serve
|
||||
# today, each on a room of that channel. Every canary is a stable release
|
||||
# in waiting, and the desktops are not on the previous canary: they are on
|
||||
# whatever stable names, which can be several releases back (0.2.2 under
|
||||
# 0.2.6.1). A package that upgrades the laptop and breaks the desktops has
|
||||
# to fail here, not on a Sunday. See tools/release-gate.sh.
|
||||
hash="$(sha256sum "$package_path" | awk '{print $1}')"
|
||||
|
||||
mkdir -p "$ROOT/repo/packages" "$ROOT/repo/canary"
|
||||
"$ROOT/tools/release-gate.sh" "$package_path" ${RELEASE_GATE_FROM:-}
|
||||
|
||||
cp "$package_path" "$ROOT/repo/packages/$package_name"
|
||||
# Checked again after the gate, which takes long enough for somebody else
|
||||
# to have published in the meantime — or for the file to have changed.
|
||||
release_check_new_version "$version"
|
||||
|
||||
cat > "$ROOT/repo/canary/manifest.json" <<EOF
|
||||
{
|
||||
"version": "$version",
|
||||
"package": "/packages/$package_name",
|
||||
"sha256": "$hash",
|
||||
"requires_reboot": true
|
||||
}
|
||||
EOF
|
||||
if [[ "$(sha256sum "$package_path" | awk '{print $1}')" != "$hash" ]]; then
|
||||
echo "$package_path changed while it was being gated. Nothing was published." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$RELEASE_LOCAL/packages"
|
||||
cp -n "$package_path" "$RELEASE_LOCAL/packages/$package_name"
|
||||
|
||||
if [[ "$(sha256sum "$RELEASE_LOCAL/packages/$package_name" | awk '{print $1}')" != "$hash" ]]; then
|
||||
echo "$RELEASE_LOCAL/packages/$package_name is not the file that passed the gate." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Publishing package..."
|
||||
|
||||
rsync -av \
|
||||
-e ssh \
|
||||
"$ROOT/repo/packages/$package_name" \
|
||||
classroom-control:/srv/ekklesia/classroom-updates/packages/
|
||||
release_upload_package "$RELEASE_LOCAL/packages/$package_name"
|
||||
|
||||
echo
|
||||
echo "Publishing canary manifest..."
|
||||
release_publish_manifest canary "$version" "$hash"
|
||||
|
||||
# Manifest deliberately goes LAST.
|
||||
rsync -av \
|
||||
-e ssh \
|
||||
"$ROOT/repo/canary/manifest.json" \
|
||||
classroom-control:/srv/ekklesia/classroom-updates/canary/manifest.json
|
||||
echo "$RELEASE_URL/canary/latest.deb -> $package_name"
|
||||
|
||||
|
||||
# THE PERMANENT URL.
|
||||
#
|
||||
# .../canary/latest.deb is a symlink beside the manifest pointing at the
|
||||
# versioned package the manifest names, so there is one download address
|
||||
# that never changes and still no second place recording which version is
|
||||
# current. The manifest remains the only answer to that question.
|
||||
#
|
||||
# It is swapped AFTER the manifest, and by rename, for two reasons. A
|
||||
# rename is atomic, so nothing ever downloads a half-written pointer; and
|
||||
# going last means latest.deb can only ever lag the manifest by a moment,
|
||||
# never lead it — it can never offer a package the channel has not
|
||||
# published. The versioned file itself is never touched again once
|
||||
# uploaded.
|
||||
ssh classroom-control "
|
||||
set -e
|
||||
cd /srv/ekklesia/classroom-updates/canary
|
||||
test -f ../packages/$package_name
|
||||
ln -sfn ../packages/$package_name latest.deb.new
|
||||
mv -Tf latest.deb.new latest.deb
|
||||
"
|
||||
|
||||
echo "https://updates.riverofglorymn.org/canary/latest.deb -> $package_name"
|
||||
echo
|
||||
echo "Checking what a classroom will see..."
|
||||
release_verify_channel canary "$version"
|
||||
echo " stable is still on $(release_channel_version stable || echo unknown)"
|
||||
|
||||
echo
|
||||
echo "Published Ekklesia Classroom $version to CANARY"
|
||||
echo "https://updates.riverofglorymn.org/canary/manifest.json"
|
||||
echo "$RELEASE_URL/canary/manifest.json"
|
||||
|
||||
@@ -4,11 +4,44 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ $# -eq 1 ]]; then
|
||||
version="$1"
|
||||
source "$ROOT/tools/release-lib.sh"
|
||||
|
||||
package_name="ekklesia-classroom_${version}_amd64.deb"
|
||||
package_path="$ROOT/repo/packages/$package_name"
|
||||
# Stable never gets a package of its own. It is pointed at one canary
|
||||
# already published, so the file every classroom downloads is byte for
|
||||
# byte the one the canary machine ran.
|
||||
#
|
||||
# ./update_stable.sh VERSION SHA256 the release that was approved, by
|
||||
# the checksum it was approved as
|
||||
# ./update_stable.sh VERSION the release, whatever file it is
|
||||
# ./update_stable.sh whatever canary is on
|
||||
#
|
||||
# With SHA256, anything but that exact file is refused. The checksum is
|
||||
# the one written down when the release went to canary and passed its
|
||||
# physical checks, so the approval and the promotion name the same bytes.
|
||||
expected_hash="${2:-}"
|
||||
|
||||
if [[ -n "$expected_hash" && ! "$expected_hash" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "Not a SHA-256 checksum: $expected_hash" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $# -ge 1 ]]; then
|
||||
version="$1"
|
||||
else
|
||||
if [[ ! -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then
|
||||
echo "No local canary manifest exists."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="$(python3 -c \
|
||||
'import json, sys; print(json.load(open(sys.argv[1]))["version"])' \
|
||||
"$RELEASE_LOCAL/canary/manifest.json")"
|
||||
fi
|
||||
|
||||
release_require_version "$version"
|
||||
|
||||
package_name="$(release_package_name "$version")"
|
||||
package_path="$RELEASE_LOCAL/packages/$package_name"
|
||||
|
||||
if [[ ! -f "$package_path" ]]; then
|
||||
echo "Package does not exist locally:"
|
||||
@@ -16,64 +49,40 @@ if [[ $# -eq 1 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
hash="$(sha256sum "$package_path" | awk '{print $1}')"
|
||||
|
||||
mkdir -p "$ROOT/repo/stable"
|
||||
|
||||
cat > "$ROOT/repo/stable/manifest.json" <<EOF
|
||||
{
|
||||
"version": "$version",
|
||||
"package": "/packages/$package_name",
|
||||
"sha256": "$hash",
|
||||
"requires_reboot": true
|
||||
}
|
||||
EOF
|
||||
else
|
||||
if [[ ! -f "$ROOT/repo/canary/manifest.json" ]]; then
|
||||
echo "No local canary manifest exists."
|
||||
if [[ "$(release_package_version "$package_path")" != "$version" ]]; then
|
||||
echo "$package_name says it is $(release_package_version "$package_path")." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$ROOT/repo/stable"
|
||||
cp "$ROOT/repo/canary/manifest.json" "$ROOT/repo/stable/manifest.json"
|
||||
# A classroom only ever installs a version newer than its own, so a stable
|
||||
# manifest that went backwards would reach nobody and would only make the
|
||||
# channel say something untrue.
|
||||
current="$(release_channel_version stable || true)"
|
||||
|
||||
version="$(python3 -c \
|
||||
'import json; print(json.load(open("repo/stable/manifest.json"))["version"])')"
|
||||
if [[ -n "$current" ]] && ! release_version_gt "$version" "$current"; then
|
||||
echo "Stable is on $current; $version is not newer." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Promoting $version to STABLE"
|
||||
hash="$(sha256sum "$package_path" | awk '{print $1}')"
|
||||
|
||||
rsync -av \
|
||||
-e ssh \
|
||||
"$ROOT/repo/stable/manifest.json" \
|
||||
classroom-control:/srv/ekklesia/classroom-updates/stable/manifest.json
|
||||
if [[ -n "$expected_hash" && "$hash" != "$expected_hash" ]]; then
|
||||
echo "$package_name is not the file that was approved:" >&2
|
||||
echo " approved: $expected_hash" >&2
|
||||
echo " here: $hash" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Promoting $version to STABLE (from ${current:-nothing})"
|
||||
|
||||
package_name="ekklesia-classroom_${version}_amd64.deb"
|
||||
# Refuses unless the server already holds exactly this package.
|
||||
release_publish_manifest stable "$version" "$hash"
|
||||
|
||||
# THE PERMANENT URL.
|
||||
#
|
||||
# .../stable/latest.deb is a symlink beside the manifest pointing at the
|
||||
# versioned package the manifest names, so there is one download address
|
||||
# that never changes and still no second place recording which version is
|
||||
# current. The manifest remains the only answer to that question.
|
||||
#
|
||||
# It is swapped AFTER the manifest, and by rename, for two reasons. A
|
||||
# rename is atomic, so nothing ever downloads a half-written pointer; and
|
||||
# going last means latest.deb can only ever lag the manifest by a moment,
|
||||
# never lead it — it can never offer a package the channel has not
|
||||
# published. The versioned file itself is never touched again once
|
||||
# uploaded.
|
||||
ssh classroom-control "
|
||||
set -e
|
||||
cd /srv/ekklesia/classroom-updates/stable
|
||||
test -f ../packages/$package_name
|
||||
ln -sfn ../packages/$package_name latest.deb.new
|
||||
mv -Tf latest.deb.new latest.deb
|
||||
"
|
||||
echo "$RELEASE_URL/stable/latest.deb -> $package_name"
|
||||
|
||||
echo "https://updates.riverofglorymn.org/stable/latest.deb -> $package_name"
|
||||
echo
|
||||
release_verify_channel stable "$version"
|
||||
|
||||
echo
|
||||
echo "Ekklesia Classroom $version is now STABLE"
|
||||
echo "https://updates.riverofglorymn.org/stable/manifest.json"
|
||||
echo "$RELEASE_URL/stable/manifest.json"
|
||||
|
||||