Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a338b2d38 | ||
|
|
aaa3f8d063 | ||
|
|
a9700536bf | ||
|
|
6c04f743ed | ||
|
|
9fc86f9d4c | ||
|
|
7b4ac39cd2 | ||
|
|
530f6f6563 | ||
|
|
b2cde1f414 | ||
|
|
7586326902 | ||
|
|
3c40e849ea | ||
|
|
23194a085c | ||
|
|
fd4330a649 | ||
|
|
78a844968c | ||
|
|
b5d9535467 | ||
|
|
5fef4e02a9 | ||
|
|
f9a0ee08f9 | ||
|
|
cb12e59dea | ||
|
|
594fbd810e | ||
|
|
550c0bf126 | ||
|
|
2325a5993b | ||
|
|
2d1cc016c5 | ||
|
|
59cb51c68b | ||
|
|
0bbe2a1ee0 | ||
|
|
cd798a33a1 | ||
|
|
3855989029 | ||
|
|
940f63e0e5 | ||
|
|
6946bae455 | ||
|
|
259c43bee8 | ||
|
|
1d9da3bca0 | ||
|
|
88ea3e86fd | ||
|
|
1cb9fd35d2 | ||
|
|
48b3097c43 | ||
|
|
10433b850d | ||
|
|
690ef95aff | ||
|
|
6f2d169355 | ||
|
|
ec1e0d1cd7 | ||
|
|
32ee0b544a | ||
|
|
9c864a1e44 | ||
|
|
89b24acff0 | ||
|
|
95ae054f6f | ||
|
|
f2942a2946 | ||
|
|
55f4c183c7 | ||
|
|
30d2d99230 | ||
|
|
fe08363534 | ||
|
|
b8688d00a7 |
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" width="22" height="22">
|
||||
<!-- The comment lives inside <svg>, not above it. gdk-pixbuf
|
||||
recognises an SVG by sniffing the first bytes of the file, and a
|
||||
comment before the opening tag pushes it out of that window:
|
||||
every GTK program then reports "couldn't recognize the image file
|
||||
format" while Qt renders it happily. Measured on both of the
|
||||
classroom's other icons, and not learned again here. -->
|
||||
<!-- Presentations.
|
||||
|
||||
Breeze's own view-presentation is a bar chart on a board, which in
|
||||
a church reads as a business meeting rather than as tonight's
|
||||
Bible study. x-office-presentation is a projector screen and comes
|
||||
closest, but it is a filetype mark: it says "a file of this kind"
|
||||
where the panel needs "the place your slides are".
|
||||
|
||||
So: a screen on a stand with a slide showing on it — a title line
|
||||
and two lines of text, which is the shape of every one of these
|
||||
sessions. Same 22-unit grid, same single-path style and the same
|
||||
ColorScheme-Text class as Parent Call, so it recolours with the
|
||||
session and needs no second file for the light theme. -->
|
||||
<defs>
|
||||
<style type="text/css">.ColorScheme-Text { color: #fcfcfc; }</style>
|
||||
</defs>
|
||||
<g class="ColorScheme-Text" style="fill:currentColor;fill-opacity:1;stroke:none">
|
||||
<!-- The screen: an outline, so the slide inside it reads as content
|
||||
rather than the whole mark being one solid block at 22px. -->
|
||||
<path d="M2 3h18a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm1 2v8h16V5z"/>
|
||||
<!-- The slide on it: a title, and two lines under it. -->
|
||||
<path d="M5 7h7v1.5H5zm0 3h12v1H5zm0 2.2h9v1H5z" style="fill-opacity:0.85"/>
|
||||
<!-- The stand. A presentation is a thing set up in front of a room,
|
||||
and the two feet are what say so at this size. -->
|
||||
<path d="M10.25 16h1.5v3.2l2.9 1.45-.67 1.34-2.98-1.49-2.98 1.49-.67-1.34 2.9-1.45z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,19 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Help
|
||||
GenericName=Classroom manual
|
||||
Comment=How to use this classroom computer
|
||||
# Breeze's own. A question mark in a circle is the one mark that needs no
|
||||
# explaining, and this is the one place in the classroom where borrowing
|
||||
# from Breeze is better than drawing something of our own.
|
||||
Icon=help-contents
|
||||
Exec=/usr/local/bin/classroom-manual
|
||||
Terminal=false
|
||||
Categories=Documentation;
|
||||
StartupNotify=true
|
||||
# Chrome in application mode reports the app id Chrome derives from the
|
||||
# --app URL, not this entry, so the panel cannot match the manual window
|
||||
# to this launcher. That is deliberate and harmless: Help is a Quick
|
||||
# Launch button, which is a launcher and never a task, so there is no
|
||||
# button here waiting to be matched.
|
||||
NoDisplay=false
|
||||
@@ -0,0 +1,28 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Presentations
|
||||
GenericName=Slides
|
||||
Comment=Show a presentation that was downloaded in the browser
|
||||
# Drawn for this: a screen with a slide on it and a corner turned, which
|
||||
# is a presentation rather than a document or a photograph. Breeze's
|
||||
# view-presentation is a chart on a board, which in a church reads as a
|
||||
# business meeting. Uses ColorScheme-Text like the classroom's other two
|
||||
# marks, so it recolours with the session and needs no light-theme twin.
|
||||
Icon=ekklesia-presentations
|
||||
Exec=/usr/local/bin/classroom-presentations
|
||||
Terminal=false
|
||||
Categories=Office;Presentation;
|
||||
StartupNotify=true
|
||||
# How the panel matches the window on screen to this launcher. GTK sets
|
||||
# the Wayland app id from GLib.set_prgname, which classroom-presentations
|
||||
# sets to exactly this. A mismatch here is not an error anywhere — it is
|
||||
# silently two buttons on the panel instead of one.
|
||||
StartupWMClass=ekklesia-presentations
|
||||
# One window. Pressing the panel button again brings the library forward
|
||||
# rather than opening a second copy of it.
|
||||
X-GNOME-SingleWindow=true
|
||||
# Deliberately visible to the applications index, like the disc player and
|
||||
# Parent Call: the panel resolves its launcher through that index, and an
|
||||
# entry hidden from it gives the bar a blank page instead of the mark.
|
||||
# There is no application menu in this classroom for it to clutter.
|
||||
NoDisplay=false
|
||||
@@ -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
|
||||
|
||||
@@ -12,19 +12,22 @@
|
||||
|
||||
// WHICH APPLICATIONS THIS DEVICE OFFERS.
|
||||
//
|
||||
// classroom-desktop-layout rewrites the three lines between the markers
|
||||
// to match device.conf and this machine's hardware, and nothing else in
|
||||
// classroom-desktop-layout rewrites the lines between the markers to
|
||||
// match device.conf and this machine's hardware, and nothing else in
|
||||
// this file. Each application is its own line because each is its own
|
||||
// setting: a laptop taking children's church needs Wonder Ink and a tower
|
||||
// used for Bible study does not, so the shape of the machine is never
|
||||
// allowed to decide what it is for.
|
||||
//
|
||||
// All true here is the shipped default, which is what an existing
|
||||
// classroom must come up as.
|
||||
// The first three are true here, which is what an existing classroom must
|
||||
// come up as. PRESENTATIONS is false, which is what an existing classroom
|
||||
// must come up as too: no room has ever had it, and an upgrade may not
|
||||
// put a button on a bar nobody asked for.
|
||||
/* CLASSROOM-FEATURES-BEGIN */
|
||||
var WONDERINK = true;
|
||||
var DVD = true;
|
||||
var PARENT_CALL = true;
|
||||
var PRESENTATIONS = false;
|
||||
/* CLASSROOM-FEATURES-END */
|
||||
|
||||
var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
|
||||
@@ -34,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];
|
||||
@@ -84,6 +94,10 @@ if (PARENT_CALL) {
|
||||
parentCall.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"];
|
||||
leftGap.writeConfig("expanding", true);
|
||||
@@ -114,6 +128,17 @@ if (DVD) {
|
||||
launchers.push("applications:ekklesia-dvd.desktop");
|
||||
}
|
||||
|
||||
// LAST, AND THAT IS THE WHOLE REASON IT IS HERE.
|
||||
//
|
||||
// Every existing room has Wonder Ink, the browser and the disc player in
|
||||
// that order, and teachers have been pressing them in that order for
|
||||
// months. Appending keeps all three exactly where they are on every
|
||||
// machine that upgrades; putting Presentations anywhere else in the row
|
||||
// would move a button somebody reaches for without looking.
|
||||
if (PRESENTATIONS) {
|
||||
launchers.push("applications:ekklesia-presentations.desktop");
|
||||
}
|
||||
|
||||
tasks.writeConfig("launchers", launchers.join(","));
|
||||
|
||||
// THE ORDER STAYS PUT WHEN AN APPLICATION OPENS.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: Your classroom computer
|
||||
order: 0
|
||||
---
|
||||
|
||||
This computer is set up for one thing: running your room. It switches
|
||||
itself on into the classroom, and there is nothing on it to break.
|
||||
|
||||
You do not need a password. You do not need to sign in. You do not need
|
||||
to shut it down at the end — leave it on.
|
||||
|
||||
## The bar along the bottom
|
||||
|
||||
Everything you need is on the bar at the bottom of the screen.
|
||||
|
||||
The buttons in the middle are the things you can open. Which ones are
|
||||
there depends on what your room is set up for, so your bar may have
|
||||
fewer buttons than another room's.
|
||||
|
||||
The right-hand end shows the network, the volume, any messages and the
|
||||
clock.
|
||||
|
||||
To open something, click its button once. To come back to it later,
|
||||
click the same button again.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: The Browser
|
||||
order: 10
|
||||
---
|
||||
|
||||
The **Browser** button opens the web: YouTube, a song, a video clip, a
|
||||
website a lesson sends you to.
|
||||
|
||||
## Using it
|
||||
|
||||
1. Click **Browser** on the bar.
|
||||
2. Type where you want to go in the bar at the top, or click **Home** to
|
||||
go back to YouTube.
|
||||
|
||||
The room stays signed in to YouTube, so you do not have to.
|
||||
|
||||
## Playing a video on the television
|
||||
|
||||
Click the full-screen button in the corner of the video, the same as you
|
||||
would at home. Press **Esc** to come back out of full screen.
|
||||
|
||||
## When you have finished
|
||||
|
||||
Leave it open, or close the window. The next person can open it again
|
||||
from the bar.
|
||||
|
||||
Anything you sign in to yourself — a personal account, an email — sign
|
||||
out of before you leave. The computer is shared.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: Sound and volume
|
||||
order: 20
|
||||
---
|
||||
|
||||
Sound goes to the television when one is connected, and to the
|
||||
computer's own speakers when one is not. You do not have to choose.
|
||||
|
||||
## Changing the volume
|
||||
|
||||
Use the volume keys on the keyboard, or click the speaker at the
|
||||
right-hand end of the bar and drag the slider.
|
||||
|
||||
A large volume bar appears on the screen while you are changing it, so
|
||||
you can see it from across the room.
|
||||
|
||||
## If there is no sound
|
||||
|
||||
1. Check the volume on the **television** as well as on the computer.
|
||||
Both have to be up.
|
||||
2. Check the television is showing this computer, and not something else
|
||||
plugged into it.
|
||||
3. If the television was switched on after the computer, the sound may
|
||||
still be going to the computer's own speakers. Turn the volume down
|
||||
and back up once; that is enough to move it across.
|
||||
|
||||
If none of that works, tell the office. Do not unplug anything.
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: Presentations
|
||||
order: 40
|
||||
feature: PRESENTATIONS_ENABLED
|
||||
printed-if: If your computer has a Presentations button on the bar
|
||||
---
|
||||
|
||||
**Presentations** holds the slides for your sessions. You download a
|
||||
presentation in the browser once, and from then on it is on this screen,
|
||||
ready to show on the television.
|
||||
|
||||
## Getting a presentation onto the computer
|
||||
|
||||
1. Click **Browser** and go to wherever the presentation is — your
|
||||
email, a shared folder, wherever it was sent to you.
|
||||
2. Download it the way you normally would.
|
||||
3. Wait until the download has finished.
|
||||
|
||||
That is all. It appears in **Presentations** by itself a few seconds
|
||||
later. You do not have to save it anywhere special, move it, or open it.
|
||||
|
||||
PowerPoint files and PDF files both work.
|
||||
|
||||
Your download stays in the browser exactly as it was. Nothing here
|
||||
changes it, moves it or deletes it.
|
||||
|
||||
## Opening the library
|
||||
|
||||
Click **Presentations** on the bar.
|
||||
|
||||

|
||||
|
||||
Each session is a card with its name and a picture of one of its slides.
|
||||
Find tonight's by looking at the picture.
|
||||
|
||||
If a session has only just been downloaded, its card says it is being
|
||||
got ready. It will be ready in a moment.
|
||||
|
||||
## Quick Look — checking the slides first
|
||||
|
||||
**Quick Look** lets you read through a session before you show it to
|
||||
anybody.
|
||||
|
||||

|
||||
|
||||
* **Next** and **Previous** move through the slides.
|
||||
* **Back to Presentations** returns to the library.
|
||||
* **Present** starts the slideshow straight away.
|
||||
|
||||
Looking through a session in Quick Look does not count as having
|
||||
presented it.
|
||||
|
||||
## Changing the picture on the card
|
||||
|
||||
The first slide of a session is often the same opening slide as every
|
||||
other session, which makes the cards hard to tell apart. Use the slide
|
||||
that names the session instead — usually the second one.
|
||||
|
||||
1. Click **Quick Look**.
|
||||
2. Press **Next** until you reach the slide you want.
|
||||
3. Click **Use this slide as the picture**.
|
||||
|
||||
The card changes straight away, and stays that way — after you close the
|
||||
library, and after the computer is switched off and on again.
|
||||
|
||||
Choosing the picture does not change where the slideshow starts. It
|
||||
always starts at the first slide.
|
||||
|
||||
## Showing a presentation
|
||||
|
||||
Click **Present**, on the card or inside Quick Look.
|
||||
|
||||
The slides fill the whole screen. Nothing else is on it.
|
||||
|
||||
| To do this | Press |
|
||||
| --- | --- |
|
||||
| Next slide | **Space**, the **right arrow**, the **down arrow** or **Page Down** |
|
||||
| Previous slide | The **left arrow**, the **up arrow** or **Page Up** |
|
||||
| First slide | **Home** |
|
||||
| Last slide | **End** |
|
||||
| Finish | **Esc** |
|
||||
|
||||
A presentation remote works too — the forward and back buttons on the
|
||||
clicker are the same as Page Down and Page Up.
|
||||
|
||||
## Finishing
|
||||
|
||||
Press **Esc**.
|
||||
|
||||
You come straight back to the library. Going past the last slide does
|
||||
the same thing.
|
||||
|
||||
## Removing a presentation
|
||||
|
||||
Click **Remove** on its card, and confirm.
|
||||
|
||||
That takes it off this screen. Your download in the browser is not
|
||||
touched, so you can always get it back by downloading it again.
|
||||
|
||||
## Presentations you no longer need
|
||||
|
||||
The computer tidies up after itself, so the library does not slowly fill
|
||||
up with old sessions.
|
||||
|
||||
A session you have shown is kept for a month after the last time you
|
||||
showed it. Showing it again starts that month over.
|
||||
|
||||
A session you downloaded but never showed is kept for six months.
|
||||
|
||||
You do not have to do anything about this, and nothing disappears while
|
||||
you are using it.
|
||||
|
||||
## If a presentation will not open
|
||||
|
||||
Its card says so, and offers only **Remove**. Usually the file did not
|
||||
download properly.
|
||||
|
||||
Download it again. Your original download is still there either way.
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: Wonder Ink
|
||||
order: 50
|
||||
feature: WONDERINK_ENABLED
|
||||
printed-if: If Wonder Ink is available in your classroom
|
||||
---
|
||||
|
||||
**Wonder Ink** opens the lesson. It is its own application, not the
|
||||
browser, and it opens straight into the lesson site.
|
||||
|
||||
## Using it
|
||||
|
||||
1. Click **Wonder Ink** on the bar.
|
||||
2. Sign in with your Wonder Ink account.
|
||||
|
||||
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 **×** 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. 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. Your recent tabs are kept for two hours in case you need them
|
||||
again, but never your sign-in.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: Parent Call
|
||||
order: 60
|
||||
feature: PARENT_CALL_ENABLED
|
||||
printed-if: If your computer has a Parent Call button in the bottom-left corner
|
||||
---
|
||||
|
||||
**Parent Call** asks the sanctuary to fetch a child's grown-up, without
|
||||
you leaving the room.
|
||||
|
||||
## Using it
|
||||
|
||||
1. Click **Parent Call**, in the bottom-left corner of the screen.
|
||||
2. Type the child's name.
|
||||
3. Click **Send**.
|
||||
|
||||
The card tells you what is happening. **Sent to sanctuary** means
|
||||
somebody there has it.
|
||||
|
||||
## What it does and does not promise
|
||||
|
||||
**Sent to sanctuary** means the message arrived. It does not mean it has
|
||||
been read out yet — somebody there decides when.
|
||||
|
||||
If it cannot reach the sanctuary it says so plainly. It will never tell
|
||||
you a call went through when it did not.
|
||||
|
||||
If that happens, send somebody.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: Playing a DVD
|
||||
order: 70
|
||||
feature: DVD_ENABLED
|
||||
printed-if: If your computer has a DVD button and a disc drive
|
||||
---
|
||||
|
||||
## Playing a disc
|
||||
|
||||
1. Put the disc in the drive.
|
||||
2. Wait for **DVD Ready** to appear.
|
||||
3. Click **DVD** on the bar.
|
||||
|
||||
The disc fills the screen and plays on the television. Discs never start
|
||||
on their own — it always takes a button press.
|
||||
|
||||
## While it is playing
|
||||
|
||||
Move the mouse to the very bottom of the screen and a row of controls
|
||||
slides up: previous chapter, play and pause, next chapter, the disc
|
||||
menu, eject, and close.
|
||||
|
||||
**Space** also plays and pauses, and the arrow keys move around the disc
|
||||
menu.
|
||||
|
||||
## Finishing
|
||||
|
||||
Click **✕** on that row to stop, or **⏏** to stop and eject the disc.
|
||||
|
||||
Take the disc with you.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
title: Printing
|
||||
order: 80
|
||||
---
|
||||
|
||||
Printing goes to the church printer. There is nothing to choose and
|
||||
nothing to set up.
|
||||
|
||||
## Printing something
|
||||
|
||||
1. Press **Ctrl+P**, or use the **Print** button on the page you are
|
||||
looking at.
|
||||
2. Check the number of copies.
|
||||
3. Click **Print**.
|
||||
|
||||
## If nothing comes out
|
||||
|
||||
The printer may be off, out of paper, or somebody else's job may be
|
||||
ahead of yours. Your job waits in the queue and prints when the printer
|
||||
is ready again — you do not need to send it twice.
|
||||
|
||||
Sending it again two or three times is how ten copies of the same
|
||||
worksheet appear later.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: If something goes wrong
|
||||
order: 90
|
||||
---
|
||||
|
||||
Almost nothing here needs a technical person, and nothing you can do
|
||||
from the keyboard will break the computer.
|
||||
|
||||
## The screen is black
|
||||
|
||||
Move the mouse or press a key. If it stays black, check the television
|
||||
is on and showing this computer.
|
||||
|
||||
## A window will not close
|
||||
|
||||
Click its button on the bar to bring it back, and close it normally.
|
||||
|
||||
## The bar has gone
|
||||
|
||||
Wait a few seconds; it puts itself back.
|
||||
|
||||
If it does not, switch the computer off at the power button, wait ten
|
||||
seconds, and switch it on again. It comes back to the same screen it
|
||||
always does.
|
||||
|
||||
## Something asks for a password
|
||||
|
||||
Nothing a teacher needs to do asks for a password. If a password box
|
||||
appears, you have reached something you did not mean to — press **Esc**
|
||||
or click **Cancel**, and carry on.
|
||||
|
||||
## It is still wrong
|
||||
|
||||
Tell the office, and say **which room** and **what you were doing when
|
||||
it happened**. That second part is the one that saves everybody time.
|
||||
@@ -0,0 +1,85 @@
|
||||
# The teacher's manual
|
||||
|
||||
**This directory is the only place teacher instructions are written.**
|
||||
|
||||
Everything a teacher reads comes out of the `.md` files beside this one:
|
||||
|
||||
* the **digital manual** on each classroom computer, which shows only the
|
||||
features that computer actually has; and
|
||||
* the **printable PDF**, which covers every feature with wording that
|
||||
stays true in a room that does not have all of them.
|
||||
|
||||
Both are built by `tools/build-manual` from these files. There is no
|
||||
second copy to keep in step, and there is no per-room manual: a room
|
||||
differs from another room by its `device.conf`, and the manual reads the
|
||||
same setting the panel does.
|
||||
|
||||
## Writing a page
|
||||
|
||||
Each page is Markdown with a small header:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Presentations
|
||||
order: 40
|
||||
feature: PRESENTATIONS_ENABLED
|
||||
printed-if: If your computer has a Presentations button
|
||||
---
|
||||
|
||||
## Downloading a presentation
|
||||
...
|
||||
```
|
||||
|
||||
| Field | Means |
|
||||
| --- | --- |
|
||||
| `title` | The heading, and the entry in the contents |
|
||||
| `order` | Where it comes. Common pages are 00–39, optional ones 40+ |
|
||||
| `feature` | The `device.conf` setting that decides whether a computer shows this page. Leave it out for a page every room gets |
|
||||
| `printed-if` | The conditional sentence the printed manual puts above the page. Required whenever `feature` is set |
|
||||
|
||||
Only `##` and `###` headings, ordinary paragraphs, `*` bullets, `1.`
|
||||
numbered steps, simple `|` tables, `` pictures
|
||||
and **bold** are supported. That is deliberate: the same text has to come
|
||||
out well as a web page and as print, and a manual for teachers does not
|
||||
need anything else.
|
||||
|
||||
## Writing style
|
||||
|
||||
The existing pages set it, and it matters more than the format:
|
||||
|
||||
* Say what the teacher does, not what the computer does.
|
||||
* One instruction per step, in the order they happen.
|
||||
* Name things the way the screen names them — **Present**, **Quick
|
||||
Look**, the **bar** along the bottom.
|
||||
* No paths, no package names, no settings files, no version numbers.
|
||||
* If something can go wrong, say what to do about it in the same breath.
|
||||
|
||||
## Building
|
||||
|
||||
```
|
||||
tools/build-manual # both, into docs/manual/build/
|
||||
tools/build-manual --check # verify only; what the release runs
|
||||
```
|
||||
|
||||
`tools/build-release.sh` builds and checks the manual on every release, so
|
||||
the digital and printed versions cannot drift apart. The check fails the
|
||||
build if a page declares a `feature` with no `printed-if`, if it names a
|
||||
feature `device.conf` does not have, or if the built files are missing.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Pictures live in `docs/manual/images/`. They are captured from the real
|
||||
interface with `tools/lab/lab shot`, never drawn or mocked up. A manual
|
||||
showing a screen that does not exist is worse than one with no pictures.
|
||||
|
||||
They are **embedded** into the digital manual as data URIs, so it stays a
|
||||
single file that works wherever it is copied and with the network
|
||||
unplugged. A picture named by a page that is not in `images/` fails the
|
||||
build, as does one with no caption.
|
||||
|
||||
**The printed manual prints the caption, not the picture** — as
|
||||
`[ Picture: ... — see the computer's own Help ]`. Embedding a PNG in a
|
||||
PDF means decoding it and writing an `/XObject`, and this builder exists
|
||||
so that a release needs nothing but Python. If the printed manual ever
|
||||
needs real pictures, that is the thing to add, and it is a contained
|
||||
change: `render_pdf` is the only function that would move.
|
||||
@@ -0,0 +1,408 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<< /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 14 0 R] /Count 12 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /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 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 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 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 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 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 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 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 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 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 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 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
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 569.0 Tm (Keep this copy in the room. If the date above is older than the one on the computer's own Help, ask) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 554.1 Tm (the office for a new one.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<< /Length 1250 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Your classroom computer) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (This computer is set up for one thing: running your room. It switches itself on into the) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 719.0 Tm (classroom, and there is nothing on it to break.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 699.0 Tm (You do not need a password. You do not need to sign in. You do not need to shut it down at the end -) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 684.1 Tm (leave it on.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 656.2 Tm (The bar along the bottom) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 635.5 Tm (Everything you need is on the bar at the bottom of the screen.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 615.6 Tm (The buttons in the middle are the things you can open. Which ones are there depends on what your) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 600.7 Tm (room is set up for, so your bar may have fewer buttons than another room's.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 580.7 Tm (The right-hand end shows the network, the volume, any messages and the clock.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 560.8 Tm (To open something, click its button once. To come back to it later, click the same button again.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<< /Length 1408 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (The Browser) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (The Browser button opens the web: YouTube, a song, a video clip, a website a lesson sends you to.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 706.0 Tm (Using it) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 685.2 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 685.2 Tm (Click Browser on the bar.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 670.3 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 670.3 Tm (Type where you want to go in the bar at the top, or click Home to go back to YouTube.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 650.4 Tm (The room stays signed in to YouTube, so you do not have to.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 622.5 Tm (Playing a video on the television) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 601.7 Tm (Click the full-screen button in the corner of the video, the same as you would at home. Press Esc) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 586.8 Tm (to come back out of full screen.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 558.9 Tm (When you have finished) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 538.2 Tm (Leave it open, or close the window. The next person can open it again from the bar.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 518.2 Tm (Anything you sign in to yourself - a personal account, an email - sign out of before you leave.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 503.3 Tm (The computer is shared.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<< /Length 1694 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Sound and volume) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (Sound goes to the television when one is connected, and to the computer's own speakers when one) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 719.0 Tm (is not. You do not have to choose.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 691.0 Tm (Changing the volume) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 670.3 Tm (Use the volume keys on the keyboard, or click the speaker at the right-hand end of the bar and) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 655.4 Tm (drag the slider.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 635.5 Tm (A large volume bar appears on the screen while you are changing it, so you can see it from across) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 620.6 Tm (the room.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 592.7 Tm (If there is no sound) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 571.9 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 571.9 Tm (Check the volume on the television as well as on the computer. Both have to be up.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 557.0 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 557.0 Tm (Check the television is showing this computer, and not something else plugged into it.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 542.1 Tm (3.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 542.1 Tm (If the television was switched on after the computer, the sound may still be going to the) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 527.2 Tm (computer's own speakers. Turn the volume down and back up once; that is enough to move it) Tj ET
|
||||
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
|
||||
19 0 obj
|
||||
<< /Length 4084 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Presentations) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F2 10.5 Tf 1 0 0 1 62.0 733.9 Tm (If your computer has a Presentations button on the bar.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 713.0 Tm (Presentations holds the slides for your sessions. You download a presentation in the browser) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 698.0 Tm (once, and from then on it is on this screen, ready to show on the television.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 670.1 Tm (Getting a presentation onto the computer) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 649.4 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 649.4 Tm (Click Browser and go to wherever the presentation is - your email, a shared folder,) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 634.5 Tm (wherever it was sent to you.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 619.6 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 619.6 Tm (Download it the way you normally would.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 604.7 Tm (3.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 604.7 Tm (Wait until the download has finished.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 584.7 Tm (That is all. It appears in Presentations by itself a few seconds later. You do not have to save) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 569.8 Tm (it anywhere special, move it, or open it.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 549.9 Tm (PowerPoint files and PDF files both work.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 530.0 Tm (Your download stays in the browser exactly as it was. Nothing here changes it, moves it or) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 515.1 Tm (deletes it.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 487.2 Tm (Opening the library) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 466.4 Tm (Click Presentations on the bar.) Tj ET
|
||||
BT /F1 9.5 Tf 1 0 0 1 62.0 446.5 Tm ([ Picture: The Presentations library, with a card for each session - see the computer's own Help ]) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 428.0 Tm (Each session is a card with its name and a picture of one of its slides. Find tonight's by looking) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 413.1 Tm (at the picture.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 393.2 Tm (If a session has only just been downloaded, its card says it is being got ready. It will be ready) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 378.3 Tm (in a moment.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 350.4 Tm (Quick Look - checking the slides first) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 329.7 Tm (Quick Look lets you read through a session before you show it to anybody.) Tj ET
|
||||
BT /F1 9.5 Tf 1 0 0 1 62.0 309.7 Tm ([ Picture: Quick Look, showing one slide with Next and Previous underneath - see the computer's own Help ]) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 291.3 Tm (-) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 291.3 Tm (Next and Previous move through the slides.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 276.3 Tm (-) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 276.3 Tm (Back to Presentations returns to the library.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 261.4 Tm (-) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 261.4 Tm (Present starts the slideshow straight away.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 241.5 Tm (Looking through a session in Quick Look does not count as having presented it.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 213.6 Tm (Changing the picture on the card) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 192.9 Tm (The first slide of a session is often the same opening slide as every other session, which makes) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 178.0 Tm (the cards hard to tell apart. Use the slide that names the session instead - usually the second) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 163.0 Tm (one.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 143.1 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 143.1 Tm (Click Quick Look.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 128.2 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 128.2 Tm (Press Next until you reach the slide you want.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 113.3 Tm (3.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 113.3 Tm (Click Use this slide as the picture.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 93.4 Tm (The card changes straight away, and stays that way - after you close the library, and after the) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
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
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 760.1 Tm (Choosing the picture does not change where the slideshow starts. It always starts at the first) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 745.2 Tm (slide.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 717.3 Tm (Showing a presentation) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 696.5 Tm (Click Present, on the card or inside Quick Look.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 676.6 Tm (The slides fill the whole screen. Nothing else is on it.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 656.7 Tm ( ) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 656.7 Tm (TO DO THIS PRESS) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 641.8 Tm ( ) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 641.8 Tm (Next slide Space, the right arrow, the down arrow or Page Down) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 626.9 Tm ( ) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 626.9 Tm (Previous slide The left arrow, the up arrow or Page Up) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 612.0 Tm ( ) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 612.0 Tm (First slide Home) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 597.1 Tm ( ) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 597.1 Tm (Last slide End) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 582.2 Tm ( ) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 582.2 Tm (Finish Esc) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 562.2 Tm (A presentation remote works too - the forward and back buttons on the clicker are the same as) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 547.3 Tm (Page Down and Page Up.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 519.4 Tm (Finishing) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 498.7 Tm (Press Esc.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 478.8 Tm (You come straight back to the library. Going past the last slide does the same thing.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 450.9 Tm (Removing a presentation) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 430.1 Tm (Click Remove on its card, and confirm.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 410.2 Tm (That takes it off this screen. Your download in the browser is not touched, so you can always get) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 395.3 Tm (it back by downloading it again.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 367.4 Tm (Presentations you no longer need) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 346.6 Tm (The computer tidies up after itself, so the library does not slowly fill up with old sessions.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 326.7 Tm (A session you have shown is kept for a month after the last time you showed it. Showing it again) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 311.8 Tm (starts that month over.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 291.9 Tm (A session you downloaded but never showed is kept for six months.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 272.0 Tm (You do not have to do anything about this, and nothing disappears while you are using it.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 244.1 Tm (If a presentation will not open) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 223.3 Tm (Its card says so, and offers only Remove. Usually the file did not download properly.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 203.4 Tm (Download it again. Your original download is still there either way.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
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
|
||||
BT /F2 10.5 Tf 1 0 0 1 62.0 733.9 Tm (If Wonder Ink is available in your classroom.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 713.0 Tm (Wonder Ink opens the lesson. It is its own application, not the browser, and it opens straight) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 698.0 Tm (into the lesson site.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 670.1 Tm (Using it) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 649.4 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 649.4 Tm (Click Wonder Ink on the bar.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 634.5 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 634.5 Tm (Sign in with your Wonder Ink account.) Tj ET
|
||||
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 (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
|
||||
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
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F2 10.5 Tf 1 0 0 1 62.0 733.9 Tm (If your computer has a Parent Call button in the bottom-left corner.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 713.0 Tm (Parent Call asks the sanctuary to fetch a child's grown-up, without you leaving the room.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 685.0 Tm (Using it) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 664.3 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 664.3 Tm (Click Parent Call, in the bottom-left corner of the screen.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 649.4 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 649.4 Tm (Type the child's name.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 634.5 Tm (3.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 634.5 Tm (Click Send.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 614.6 Tm (The card tells you what is happening. Sent to sanctuary means somebody there has it.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 586.7 Tm (What it does and does not promise) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 565.9 Tm (Sent to sanctuary means the message arrived. It does not mean it has been read out yet - somebody) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 551.0 Tm (there decides when.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 531.1 Tm (If it cannot reach the sanctuary it says so plainly. It will never tell you a call went through) Tj ET
|
||||
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
|
||||
24 0 obj
|
||||
<< /Length 1500 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Playing a DVD) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F2 10.5 Tf 1 0 0 1 62.0 733.9 Tm (If your computer has a DVD button and a disc drive.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 705.0 Tm (Playing a disc) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 684.2 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 684.2 Tm (Put the disc in the drive.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 669.3 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 669.3 Tm (Wait for DVD Ready to appear.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 654.4 Tm (3.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 654.4 Tm (Click DVD on the bar.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 634.5 Tm (The disc fills the screen and plays on the television. Discs never start on their own - it always) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 619.6 Tm (takes a button press.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 591.7 Tm (While it is playing) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 570.9 Tm (Move the mouse to the very bottom of the screen and a row of controls slides up: previous chapter,) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 556.0 Tm (play and pause, next chapter, the disc menu, eject, and close.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 536.1 Tm (Space also plays and pauses, and the arrow keys move around the disc menu.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 508.2 Tm (Finishing) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 487.4 Tm (Click X on that row to stop, or eject to stop and eject the disc.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 467.5 Tm (Take the disc with you.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<< /Length 1166 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Printing) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (Printing goes to the church printer. There is nothing to choose and nothing to set up.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 706.0 Tm (Printing something) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 685.2 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 685.2 Tm (Press Ctrl+P, or use the Print button on the page you are looking at.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 670.3 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 670.3 Tm (Check the number of copies.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 655.4 Tm (3.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 655.4 Tm (Click Print.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 627.5 Tm (If nothing comes out) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 606.7 Tm (The printer may be off, out of paper, or somebody else's job may be ahead of yours. Your job waits) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 591.8 Tm (in the queue and prints when the printer is ready again - you do not need to send it twice.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 571.9 Tm (Sending it again two or three times is how ten copies of the same worksheet appear later.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
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
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (Almost nothing here needs a technical person, and nothing you can do from the keyboard will) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 719.0 Tm (break the computer.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 691.0 Tm (The screen is black) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 670.3 Tm (Move the mouse or press a key. If it stays black, check the television is on and showing this) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 655.4 Tm (computer.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 627.5 Tm (A window will not close) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 606.7 Tm (Click its button on the bar to bring it back, and close it normally.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 578.8 Tm (The bar has gone) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 558.1 Tm (Wait a few seconds; it puts itself back.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 538.2 Tm (If it does not, switch the computer off at the power button, wait ten seconds, and switch it on) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 523.2 Tm (again. It comes back to the same screen it always does.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 495.3 Tm (Something asks for a password) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 474.6 Tm (Nothing a teacher needs to do asks for a password. If a password box appears, you have reached) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 459.7 Tm (something you did not mean to - press Esc or click Cancel, and carry on.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 431.8 Tm (It is still wrong) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 411.0 Tm (Tell the office, and say which room and what you were doing when it happened. That second part is) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 396.1 Tm (the one that saves everybody time.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
28 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
xref
|
||||
0 29
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 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 29 /Root 1 0 R >>
|
||||
startxref
|
||||
26735
|
||||
%%EOF
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 67 KiB |
@@ -154,6 +154,74 @@ that would be a one-way door.
|
||||
|
||||
The original is kept at `/etc/network/interfaces.before-ekklesia-classroom`.
|
||||
|
||||
### What 0.2.5 got wrong, and what 0.2.6 changed
|
||||
|
||||
The prayer-room laptop's Wi-Fi is **hidden** — `ROGC Classroom`, an SSID
|
||||
with a space in it, announced by nothing. `--adopt` built a connection,
|
||||
failed to bring it up, rolled back, and left the adapter belonging to
|
||||
nobody. Four separate defects, all fixed:
|
||||
|
||||
**1. The hidden flag was thrown away.** The stanza said `wpa-scan-ssid 1`
|
||||
and the parser read it — into `options`, where the only accessor ignored
|
||||
it. The generated profile had `802-11-wireless.hidden: no`, and
|
||||
NetworkManager will not associate with an SSID it has not seen in a scan.
|
||||
A hidden one is never in a scan. `credentials()` returned a pair; there
|
||||
is now a `profile()` that returns the whole network, and `add()` writes
|
||||
`802-11-wireless.hidden` in **both** directions rather than only when
|
||||
true — a default that happens to be right is not an answer.
|
||||
|
||||
**2. `ifdown` ran after the stanza was commented out.** ifupdown reads
|
||||
`/etc/network/interfaces` to find out how to bring an interface down.
|
||||
With the stanza gone it says *"Ignoring unknown interface"* and does
|
||||
nothing — so ifupdown's own `wpa_supplicant` and DHCP client were still
|
||||
holding the radio when NetworkManager was asked to associate with it.
|
||||
Two supplicants on one interface is a fight NetworkManager loses. The
|
||||
order is now: **`ifdown` first, while the file still says how; rewrite
|
||||
the file second.**
|
||||
|
||||
**3. The rollback left the adapter unmanaged.** `restore()` ended with
|
||||
`nmcli device set <dev> managed no`, which is right while ifupdown can
|
||||
actually bring the interface up. When `ifup` fails too, those two lines
|
||||
are a wireless adapter neither manager owns: no connection, and a panel
|
||||
that cannot even scan. That is the state the laptop was found in, and it
|
||||
is why `sudo nmcli device set wlp2s0 managed yes` was a manual recovery
|
||||
step. The rollback now escalates — if ifupdown cannot get the network
|
||||
back either, the interface is handed to NetworkManager rather than left
|
||||
with neither, and it says so loudly.
|
||||
|
||||
**4. A `#` in a password was silently truncated.** The parser cut every
|
||||
line at its first `#`. `interfaces(5)` is explicit that only whole-line
|
||||
comments exist — *"end-of-line comments are NOT supported"* — so a
|
||||
pre-shared key containing `#` became a prefix of itself, and the only
|
||||
symptom is an association that fails exactly the way a wrong password
|
||||
fails.
|
||||
|
||||
Two smaller ones went with them. The rollback un-comments its own marked
|
||||
lines in place instead of copying the backup over the whole file, because
|
||||
the backup is written once and never refreshed — a whole-file restore
|
||||
silently reverted any *wired* change an administrator made in between.
|
||||
And `nmcli -t` output is now split on its own escaping rules rather than
|
||||
on `:`, with `LC_ALL=C`, so a network whose name contains a colon cannot
|
||||
make a successful migration look like a failed one.
|
||||
|
||||
`--adopt` also now refuses a network it cannot recreate — WPA-Enterprise
|
||||
has no pre-shared key to carry across — *before* taking the interface
|
||||
away from ifupdown to find out, and reports any `unmanaged-devices` line
|
||||
in NetworkManager's own configuration, which survives the stanza going
|
||||
away and would otherwise leave the interface belonging to nobody after a
|
||||
restart.
|
||||
|
||||
Verified against a real NetworkManager 1.52.1: the keyfile it writes
|
||||
carries `hidden=true`, `ssid=ROGC Classroom` with its space, and a psk
|
||||
containing `#` intact, at 0600 root:root with no `connection.permissions`.
|
||||
`tests/test-classroom-wifi-adopt.sh` drives the whole hand-over against
|
||||
stubbed tools and asserts the ordering, the rollback and the managed
|
||||
state.
|
||||
|
||||
**The prayer-room laptop must not be migrated again.** Its stanza is
|
||||
already gone, so `--adopt` reports "nothing to adopt" and exits 0 without
|
||||
touching anything; there is a test for exactly that.
|
||||
|
||||
After the migration the panel shows the connection at the next login, the
|
||||
room can scan, and the credential protection below applies to it like any
|
||||
other system connection.
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
# Presentations
|
||||
|
||||
Optional per device, off by default. `PRESENTATIONS_ENABLED=true` in
|
||||
`/etc/ekklesia-classroom/device.conf` puts a **Presentations** button on
|
||||
the bar and starts the importer; nothing else in the room changes.
|
||||
|
||||
It depends on nothing else. A machine may have Presentations with no
|
||||
Wonder Ink, no drive, no Parent Call, on Wi-Fi or a cable, as a laptop or
|
||||
a tower. The Bible study laptop is the first installation.
|
||||
|
||||
## What was measured before any of this was designed
|
||||
|
||||
The brief was to use existing presentation software. So the existing
|
||||
presentation software was tried first, on Debian 13 in the Plasma lab,
|
||||
against a ten-slide deck in both PPTX and PDF. **The critical
|
||||
requirement is that a teacher who ends or cancels a slideshow lands back
|
||||
in the library and never in an editor.**
|
||||
|
||||
| Tried | What it actually did |
|
||||
| --- | --- |
|
||||
| `soffice --show deck.pptx` | Starts a real fullscreen slideshow — `app_id=libreoffice-impress`, `"Presenting: session7"`, `fullscreen=1`. But it needs `XDG_SESSION_TYPE=wayland` **and** `libreoffice-gtk3`/`-qt6`, or it dies with *"Failed to open display"*; it shows the LibreOffice splash on the way up; and a **"Tip of the Day: 1/225"** dialog opened on top of the slideshow on first run. Ending the show leaves the Impress editor. |
|
||||
| `okular --presentation deck.pdf` | Opens **three** windows: the presentation, the ordinary Okular document window behind it, and an **"Information — Okular"** dialog. The full viewer UI — File, Open, Settings — sits behind the slides. |
|
||||
| `zathura deck.pdf` | Opens windowed, not fullscreen. A document reader, not a presenter. |
|
||||
| `pdfpc deck.pdf` | Single fullscreen window, the closest of the four. With `--single-screen` it came up windowed, and in the lab it did not respond to Right, Escape or `q`. A presenter-console tool with notes and a timer, and another dependency. |
|
||||
|
||||
None of them returns a teacher to a library, because none of them knows
|
||||
there is one. Every one of them leaves an application on the screen that
|
||||
can open any file on the machine.
|
||||
|
||||
### What was chosen instead
|
||||
|
||||
**LibreOffice converts once, headless, at import. Poppler renders the
|
||||
pages. The classroom shows the pictures.**
|
||||
|
||||
```
|
||||
Session 7.pptx --soffice --headless --convert-to pdf--> slides.pdf
|
||||
slides.pdf --pdftoppm -png -scale-to-x 1920--------> page-001.png ...
|
||||
```
|
||||
|
||||
Measured on the same deck:
|
||||
|
||||
| Step | Time |
|
||||
| --- | --- |
|
||||
| PPTX → PDF, ten slides | 0.34 s |
|
||||
| All ten pages → PNG | 0.03 s |
|
||||
| One page at 1920px | 0.10 s |
|
||||
|
||||
Fonts embed correctly (`BAAAAA+LiberationSans`, subset, embedded) and the
|
||||
text survives, which is where a title comes from when the filename is a
|
||||
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
|
||||
interactively. `soffice` runs once, with no window, at import.
|
||||
* **Escape is ours.** The presenter is a window of the library's own
|
||||
process, so ending a slideshow is the library raising itself, not a
|
||||
second program being asked to exit.
|
||||
* **Presenting is instant.** The pictures already exist; there is no
|
||||
LibreOffice to start in front of a room.
|
||||
* **PDF is first class.** A session exported from Google Slides as PDF
|
||||
skips the conversion entirely and keeps exactly the appearance Google
|
||||
gave it. PPT and PPTX join that path rather than having one of their
|
||||
own.
|
||||
* **Offline by construction.** Nothing after the download touches the
|
||||
network.
|
||||
|
||||
`libreoffice-gtk3` is deliberately *not* a dependency: nothing
|
||||
interactive runs, so no UI integration package is needed.
|
||||
|
||||
## Where things are
|
||||
|
||||
```
|
||||
~/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
|
||||
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
|
||||
```
|
||||
|
||||
## Importing
|
||||
|
||||
`ekklesia-classroom-presentations.service` (user) runs
|
||||
`classroom-presentation-import`, which looks at `~/Downloads` every four
|
||||
seconds. Polling, not inotify: a teacher waits the same few seconds
|
||||
either way, and this cannot miss an event across a restart.
|
||||
|
||||
A file is imported when **all** of these hold:
|
||||
|
||||
* its extension is `.pdf`, `.pptx` or `.ppt`;
|
||||
* it is not inside the `Wonder Ink` directory;
|
||||
* there is no `.crdownload`, `.part`, `.partial`, `.download` or `.tmp`
|
||||
sibling — Chrome writes `Session 7.pptx.crdownload` and renames it when
|
||||
the transfer finishes, so that sibling is the authoritative "still
|
||||
arriving";
|
||||
* it has not changed for six seconds, which catches a copy from a USB
|
||||
stick that has no `.crdownload` to go by;
|
||||
* it is not empty; and
|
||||
* its fingerprint — path, size, modification time — is not already in
|
||||
`imported.json`.
|
||||
|
||||
Two downloads of the same name are two presentations in separate id
|
||||
directories. The second gets `(2)` after its title; neither overwrites the
|
||||
other.
|
||||
|
||||
**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**. Its managed original remains recoverable even when PDF or page
|
||||
generation fails.
|
||||
|
||||
## Memory (0.2.6.2)
|
||||
|
||||
On a 4 GB Plasma VM the kernel killed `ekklesia-classroom-presentations.service`
|
||||
mid-import and Plasma said *Memory Shortage Avoided*. Measured afterwards,
|
||||
LibreOffice converting one deck with the importer's own code:
|
||||
|
||||
| Deck | PPTX | LibreOffice peak | Rendering peak |
|
||||
| --- | --- | --- | --- |
|
||||
| 10 slides of text | 20 KB | 203 MB, 0.8 s | 38 MB |
|
||||
| 30 slides, 12 photos shared | 23.5 MB | 738 MB, 16 s | 70 MB |
|
||||
| 60 slides, the same 12 photos | 23.6 MB | 754 MB, 28 s | 70 MB |
|
||||
| 30 slides, 30 different 12 MP photos | 58.7 MB | **1.43 GB**, 22 s | 69 MB |
|
||||
| 20 slides, 20 different 27 MP photos | 143 MB | **2.08 GB**, 24 s | 70 MB |
|
||||
|
||||
It grows with the photographs, not the slides, and it is a spike rather
|
||||
than a leak. PDF export with images reduced to 200 dpi saved only 5%,
|
||||
because LibreOffice holds the full images regardless. So the failure was a
|
||||
photo-heavy deck meeting a 4 GB machine with the room's other programs
|
||||
open — and what made it a problem was what happened next. systemd's
|
||||
default `OOMPolicy=stop` took the whole service down with the killed
|
||||
LibreOffice; it restarted, `sweep()` resumed the same conversion with no
|
||||
limit, and the library said *Getting this ready…* throughout.
|
||||
|
||||
Now:
|
||||
|
||||
* **`MemoryMax=40%`** — 1.6 GB on a 4 GB machine, 3.2 GB on 8 GB. Measured
|
||||
in the lab at 1.6 GB, the 2.1 GB deck still imported in 33 s: much of
|
||||
that peak is file-backed and reclaimable, and on a machine with swap the
|
||||
rest is swapped rather than killed (1.4 GB of it at a 200 MB ceiling).
|
||||
* **`OOMScoreAdjust=800`** — under pressure everywhere, LibreOffice goes
|
||||
before the lesson on the screen, the browser or the panel.
|
||||
* **`OOMPolicy=continue`** — a killed LibreOffice is a failed import, not a
|
||||
dead service. Measured with no swap and a 300 MB ceiling: the kernel
|
||||
killed LibreOffice, the watcher kept its PID with no restart, and the
|
||||
import was recorded as `failed`, kind `memory`.
|
||||
* **Two attempts, then stop.** Each attempt is counted before it starts.
|
||||
Measured by killing the whole service during attempt 1 and again during
|
||||
attempt 2: it resumed once, then said "giving up … the teacher can try
|
||||
again" and did not start a third.
|
||||
* **The card says so**, and offers **Try again**: *This needed more memory
|
||||
than the computer had free. Close other programs, then choose Try
|
||||
again.* Try again was pressed in the lab and the deck became ready.
|
||||
A file that simply will not convert still says *This file could not be
|
||||
opened* and offers no Try again, because trying again will not help.
|
||||
* **Never stuck.** An import still "getting ready" 20 minutes after it
|
||||
began — longer than any attempt can take — is shown as the same Try
|
||||
again card, because nothing is working on it.
|
||||
* **One import at a time**, by an exclusive lock as well as the one loop;
|
||||
measured, three decks downloaded together converted one after another
|
||||
with never more than one LibreOffice running.
|
||||
|
||||
What the lab cannot give is a real 4 GB machine's mix of programs; the
|
||||
ceilings were emulated per service.
|
||||
|
||||
## The three things kept apart
|
||||
|
||||
This is the part most worth not breaking.
|
||||
|
||||
| | Changed by | Never changed by |
|
||||
| --- | --- | --- |
|
||||
| **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 |
|
||||
|
||||
The first slide of a Bible study session is a generic opening shared by
|
||||
every session; the one that names the session is usually the second. So
|
||||
the thumbnail defaults to slide 1 and Quick Look exists to change it, and
|
||||
changing it must not be mistaken for having presented anything.
|
||||
|
||||
`Presenter.show_page` calls `mark_presented()` at the moment the first
|
||||
slide is drawn, and not before. Pressing **Present** is an intention; a
|
||||
picture on the screen is a presentation.
|
||||
|
||||
## Retention
|
||||
|
||||
| State | Kept |
|
||||
| --- | --- |
|
||||
| Never presented | 180 days from import |
|
||||
| Presented | 30 days from the **last** presentation |
|
||||
|
||||
Every presentation restarts the thirty days. No teacher-facing setting.
|
||||
|
||||
The cleanup runs hourly in the importer and:
|
||||
|
||||
* never removes a presentation whose id is in
|
||||
`$XDG_RUNTIME_DIR/ekklesia-classroom-presenting` — the lock the
|
||||
presenter holds while a slide is on the screen. In the runtime
|
||||
directory so a crash cannot make a presentation permanently
|
||||
un-removable;
|
||||
* only ever removes a directory under the library root;
|
||||
* 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
|
||||
|
||||
A fullscreen `Gtk.Window` in the library's own process showing one
|
||||
pre-rendered page at a time, letterboxed — never cropped, never
|
||||
stretched.
|
||||
|
||||
| Key | Does |
|
||||
| --- | --- |
|
||||
| Space, →, ↓, Page Down, Return, `n` | Next slide |
|
||||
| ←, ↑, Page Up, Backspace, `p` | Previous slide |
|
||||
| Home / End | First / last slide |
|
||||
| Escape, `q`, F5 | Finish, back to the library |
|
||||
|
||||
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.** 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
|
||||
|
||||
The pastor's material comes from Google Slides. These are all in Debian
|
||||
main and install without a question:
|
||||
|
||||
| Package | Covers |
|
||||
| --- | --- |
|
||||
| `fonts-liberation2` | Arial, Times New Roman, Courier New — metric compatible |
|
||||
| `fonts-crosextra-carlito` | Calibri |
|
||||
| `fonts-crosextra-caladea` | Cambria |
|
||||
| `fonts-croscore` | the same three under their Chrome OS names |
|
||||
| `fonts-liberation-sans-narrow` | Arial Narrow |
|
||||
| `fonts-roboto`, `fonts-open-sans`, `fonts-lato`, `fonts-montserrat` | the web fonts Google Slides offers |
|
||||
| `fonts-urw-base35` | the PostScript standard 35 |
|
||||
|
||||
`ttf-mscorefonts-installer` is deliberately **absent**. It is in contrib,
|
||||
it downloads from a third party at install time, and it shows an EULA
|
||||
prompt — which an unattended classroom upgrade cannot answer.
|
||||
|
||||
## Checking a machine
|
||||
|
||||
```
|
||||
classroom-presentation-import --status what the library holds
|
||||
classroom-presentation-import --once one pass, then stop
|
||||
classroom-presentation-import --cleanup apply retention now
|
||||
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: 48 KiB |
|
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: 51 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 65 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
|
||||
@@ -323,6 +356,35 @@ DVD_ENABLED=false
|
||||
Nothing about `DEVICE_TYPE=laptop` turns those applications off; they are
|
||||
off because those lines say so.
|
||||
|
||||
### Physical verification — the Wi-Fi hand-over
|
||||
|
||||
No lab can do this one: it needs the laptop's radio. Over SSH, watching,
|
||||
with a way back into the room if it goes wrong:
|
||||
|
||||
1. `classroom-wifi --check` — expect it to name the interface and say
|
||||
ifupdown owns it. Changes nothing.
|
||||
2. `classroom-wifi --adopt --dry-run` — expect the three "would" lines.
|
||||
Changes nothing. `/etc/network/interfaces` must still be identical.
|
||||
3. `classroom-wifi --adopt`. It prints each step. Expect it to end with
|
||||
*"is NetworkManager's now, and the room is online"*.
|
||||
If it fails it puts ifupdown back by itself and says so; the machine
|
||||
should still be reachable. `classroom-wifi --rollback` undoes it later.
|
||||
4. `nmcli device status` — the Wi-Fi device must no longer say
|
||||
`unmanaged`.
|
||||
5. `nmcli networking connectivity` — must say `full`, not `none`.
|
||||
6. `nmcli device wifi list` — must now list networks.
|
||||
7. Log out and back in. The panel must show the Wi-Fi connection.
|
||||
8. Reboot. It must reconnect on its own, with nobody typing a password.
|
||||
9. As the **classroom** account, confirm the password is still
|
||||
unreachable:
|
||||
- `nmcli -s -g 802-11-wireless-security.psk connection show <name>`
|
||||
must be empty
|
||||
- the applet's *Show Network's QR Code* must not produce one
|
||||
- `/etc/NetworkManager/system-connections/*.nmconnection` must be
|
||||
unreadable
|
||||
10. As **tech**, confirm maintenance still works: `sudo nmcli -s ...`
|
||||
returns the key.
|
||||
|
||||
### Physical verification — a real lid and a real television
|
||||
|
||||
The lab has no lid and no HDMI hotplug, so none of this is proven by a
|
||||
@@ -437,10 +499,307 @@ 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
|
||||
single after reinstall
|
||||
- [ ] `tools/validate-trixie-release.sh` passes against the 0.1.37 package and
|
||||
the 0.1.36 package
|
||||
|
||||
## Presentations — 0.2.6, the prayer-room laptop
|
||||
|
||||
Everything here needs the real television, the real keyboard and the
|
||||
pastor's own session files. The automated tests and the Plasma lab cover
|
||||
the logic and the windows; none of them can tell you that Session 7's
|
||||
text is the right size from the back of the room.
|
||||
|
||||
Enable it first, and reboot:
|
||||
|
||||
```
|
||||
sudo sed -i 's/^PRESENTATIONS_ENABLED=.*/PRESENTATIONS_ENABLED=true/' \
|
||||
/etc/ekklesia-classroom/device.conf
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
### Download
|
||||
|
||||
- [ ] The bar has a **Presentations** button, after the Browser
|
||||
- [ ] Wonder Ink and Parent Call are still absent from this laptop
|
||||
- [ ] Download tonight's session in ordinary Chrome
|
||||
- [ ] 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 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 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 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.
|
||||
|
||||
### 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
|
||||
- [ ] The presentation remote's forward and back buttons work
|
||||
- [ ] Fonts and layout are right on the television, from the back
|
||||
- [ ] **Esc** returns to the library — **not** the Impress editor, not a
|
||||
terminal, not a blank screen
|
||||
- [ ] Going past the last slide does the same
|
||||
- [ ] `--status` now shows a presentation time and a count
|
||||
- [ ] Present it again: the count rises and the expiry moves out 30 days
|
||||
- [ ] Unplug the network and present again: it still works
|
||||
|
||||
### The awkward ones
|
||||
|
||||
- [ ] A PDF and a PPTX of the same session look the same on screen
|
||||
- [ ] A deliberately broken file gives a short message and only
|
||||
**Remove** — no editor, no terminal
|
||||
- [ ] A long session (40+ slides) opens without the library freezing
|
||||
- [ ] Download a Wonder Ink worksheet: it does **not** appear in the
|
||||
library
|
||||
|
||||
### Remove
|
||||
|
||||
Use a **test copy**. Not tomorrow's session.
|
||||
|
||||
- [ ] **Remove** asks first
|
||||
- [ ] The card goes, and its slides and pictures go with it
|
||||
- [ ] 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
|
||||
|
||||
### Help is temporarily hidden
|
||||
|
||||
- [ ] 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
|
||||
|
||||
The laptop's Wi-Fi is already working through NetworkManager and its
|
||||
ifupdown stanza is already gone. **Do not run `--adopt` on it.**
|
||||
|
||||
- [ ] `classroom-wifi --check` says there is no wireless interface under
|
||||
ifupdown's control
|
||||
- [ ] `classroom-wifi --adopt` says "nothing to adopt" and changes nothing
|
||||
- [ ] `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).
|
||||
|
||||
@@ -101,6 +101,19 @@ DVD_ENABLED=true
|
||||
# in the corner is then one more thing to explain.
|
||||
PARENT_CALL_ENABLED=true
|
||||
|
||||
# Whether this device offers Presentations.
|
||||
#
|
||||
# A teacher downloads a session in Chrome and it appears here, ready to
|
||||
# show on the television: no file manager, no Impress, nothing to
|
||||
# explain. Off by default, because every existing classroom upgrading
|
||||
# into this release has never had it and a new button nobody asked for
|
||||
# is a new thing to support.
|
||||
#
|
||||
# It depends on nothing else. A machine may have Presentations with no
|
||||
# Wonder Ink, no disc drive, no Parent Call, on Wi-Fi or a cable, as a
|
||||
# laptop or a tower.
|
||||
PRESENTATIONS_ENABLED=false
|
||||
|
||||
# Which network this device is meant to be on.
|
||||
#
|
||||
# wired an Ethernet cable is the intended connection.
|
||||
@@ -212,6 +225,22 @@ DVD_ENABLED=true
|
||||
# A room full of children needs it. A Bible study does not, and the button
|
||||
# in the corner is then one more thing to explain.
|
||||
PARENT_CALL_ENABLED=true
|
||||
""",
|
||||
),
|
||||
(
|
||||
"PRESENTATIONS_ENABLED",
|
||||
"""# Whether this device offers Presentations.
|
||||
#
|
||||
# A teacher downloads a session in Chrome and it appears here, ready to
|
||||
# show on the television: no file manager, no Impress, nothing to
|
||||
# explain. Off by default, because every existing classroom upgrading
|
||||
# into this release has never had it and a new button nobody asked for
|
||||
# is a new thing to support.
|
||||
#
|
||||
# It depends on nothing else. A machine may have Presentations with no
|
||||
# Wonder Ink, no disc drive, no Parent Call, on Wi-Fi or a cable, as a
|
||||
# laptop or a tower.
|
||||
PRESENTATIONS_ENABLED=false
|
||||
""",
|
||||
),
|
||||
(
|
||||
@@ -325,6 +354,12 @@ FEATURES = {
|
||||
"WONDERINK_ENABLED": True,
|
||||
"DVD_ENABLED": True,
|
||||
"PARENT_CALL_ENABLED": True,
|
||||
# The one that defaults to OFF, and the only one that can. Every
|
||||
# existing classroom has Wonder Ink, a drive and Parent Call, so a
|
||||
# room upgrading into this release must come up with all three. None
|
||||
# of them has ever had Presentations, so a room upgrading into this
|
||||
# release must come up without it.
|
||||
"PRESENTATIONS_ENABLED": False,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -36,10 +36,24 @@ import re
|
||||
|
||||
# ifupdown's own spelling. Values may be quoted, and a psk may be a
|
||||
# 64-character hex key rather than a passphrase.
|
||||
SETTINGS = ("wpa-ssid", "wpa-psk", "wpa-key-mgmt", "wpa-conf", "wpa-proto")
|
||||
SETTINGS = ("wpa-ssid", "wpa-psk", "wpa-key-mgmt", "wpa-conf", "wpa-proto",
|
||||
"wpa-scan-ssid")
|
||||
|
||||
# Whether the access point broadcasts its name.
|
||||
#
|
||||
# wpa-scan-ssid 1 is ifupdown's spelling of wpa_supplicant's scan_ssid=1:
|
||||
# probe for this network by name, because it is not in a passive scan.
|
||||
# NetworkManager spells the same thing 802-11-wireless.hidden, and a
|
||||
# migration that drops it produces a connection that is correct in every
|
||||
# visible respect and can never associate — NetworkManager will not
|
||||
# connect to an SSID it has not seen in a scan, and a hidden one is never
|
||||
# in a scan. That is what happened to the prayer-room laptop on 0.2.5.
|
||||
HIDDEN = "wpa-scan-ssid"
|
||||
|
||||
_STANZA = re.compile(r"^\s*(iface|auto|allow-hotplug|source|source-directory)\s+(.*)$")
|
||||
|
||||
TRUTHY = {"1", "yes", "true", "on"}
|
||||
|
||||
|
||||
def _unquote(value):
|
||||
value = (value or "").strip()
|
||||
@@ -63,7 +77,19 @@ def parse(text):
|
||||
current = None
|
||||
|
||||
for number, raw in enumerate(lines):
|
||||
line = raw.split("#", 1)[0].rstrip()
|
||||
# A comment is a WHOLE line, and only a whole line.
|
||||
#
|
||||
# interfaces(5): "Lines starting with '#' are ignored. Note that
|
||||
# end-of-line comments are NOT supported." This used to cut every
|
||||
# line at its first '#', which is a silent corruption of exactly
|
||||
# one value: a pre-shared key containing a '#' was truncated, the
|
||||
# migrated connection got a password that was a prefix of the
|
||||
# real one, and the only symptom is an association that fails the
|
||||
# way a wrong password fails.
|
||||
if raw.lstrip().startswith("#"):
|
||||
continue
|
||||
|
||||
line = raw.rstrip()
|
||||
match = _STANZA.match(line)
|
||||
|
||||
if match:
|
||||
@@ -129,12 +155,57 @@ def credentials(stanza):
|
||||
network inline returns no SSID: that one is not ours to move, and the
|
||||
caller must leave it alone rather than guess.
|
||||
"""
|
||||
found = profile(stanza)
|
||||
|
||||
if found is None:
|
||||
return None, None
|
||||
|
||||
return found["ssid"], found["psk"]
|
||||
|
||||
|
||||
def profile(stanza):
|
||||
"""Everything needed to recreate this stanza in NetworkManager.
|
||||
|
||||
A dict, or None when the stanza is not ours to move. Returning the
|
||||
whole network rather than a pair is the fix for the 0.2.5 failure:
|
||||
`wpa-scan-ssid` was sitting in the parsed options the whole time and
|
||||
the only accessor threw it away, so the hand-over could not have
|
||||
preserved it however carefully it was written.
|
||||
|
||||
`key_mgmt` is "wpa-psk", "none", or None meaning "this file says
|
||||
something this command does not understand". The caller must refuse
|
||||
that last one rather than guess: a WPA-Enterprise network recreated
|
||||
as a PSK network is a migration that cannot work and has already
|
||||
taken the interface away from ifupdown to find out.
|
||||
"""
|
||||
options = stanza.get("options") or {}
|
||||
|
||||
if "wpa-conf" in options and "wpa-ssid" not in options:
|
||||
return None, None
|
||||
return None
|
||||
|
||||
return options.get("wpa-ssid") or None, options.get("wpa-psk") or None
|
||||
ssid = options.get("wpa-ssid") or None
|
||||
|
||||
if not ssid:
|
||||
return None
|
||||
|
||||
psk = options.get("wpa-psk") or None
|
||||
declared = (options.get("wpa-key-mgmt") or "").strip().upper()
|
||||
|
||||
if declared in ("", "WPA-PSK", "WPA-PSK-SHA256", "SAE", "WPA-PSK SAE"):
|
||||
key_mgmt = "wpa-psk" if psk else "none"
|
||||
elif declared == "NONE":
|
||||
key_mgmt = "none"
|
||||
else:
|
||||
# WPA-EAP, IEEE8021X and anything else this has never seen.
|
||||
key_mgmt = None
|
||||
|
||||
return {
|
||||
"ssid": ssid,
|
||||
"psk": psk,
|
||||
# A network that has to be probed for by name. See HIDDEN.
|
||||
"hidden": (options.get(HIDDEN) or "").strip().lower() in TRUTHY,
|
||||
"key_mgmt": key_mgmt,
|
||||
}
|
||||
|
||||
|
||||
def comment_out(text, stanza, marker="# ekklesia-classroom: "):
|
||||
@@ -160,3 +231,31 @@ def comment_out(text, stanza, marker="# ekklesia-classroom: "):
|
||||
break
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def uncomment(text, marker="# ekklesia-classroom: "):
|
||||
"""Undo comment_out, in the file as it stands now.
|
||||
|
||||
Returns (text, count). The rollback uses this rather than copying the
|
||||
backup over the top, because the backup is kept once and never
|
||||
refreshed: an administrator who corrected a static Ethernet address
|
||||
between the migration and the rollback would have had that correction
|
||||
silently reverted by a whole-file restore. This touches only the
|
||||
lines this package marked, so everything else in the file — wired
|
||||
stanzas, source lines, an administrator's own comments — is exactly
|
||||
as the machine left it.
|
||||
"""
|
||||
lines = (text or "").splitlines(keepends=True)
|
||||
restored = 0
|
||||
|
||||
for number, line in enumerate(lines):
|
||||
stripped = line.lstrip()
|
||||
|
||||
if not stripped.startswith(marker):
|
||||
continue
|
||||
|
||||
indent = line[:len(line) - len(stripped)]
|
||||
lines[number] = indent + stripped[len(marker):]
|
||||
restored += 1
|
||||
|
||||
return "".join(lines), restored
|
||||
|
||||
@@ -0,0 +1,898 @@
|
||||
"""The classroom's presentation library.
|
||||
|
||||
What a teacher sees is in classroom-presentations. This is everything
|
||||
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 THE ORIGINAL LIVES IN THE MANAGED LIBRARY.
|
||||
|
||||
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.
|
||||
|
||||
Measured in the lab, on Debian 13, before any of this was designed —
|
||||
docs/presentations.md has the whole table. The short of it:
|
||||
|
||||
soffice --show starts a real fullscreen slideshow, and leaves
|
||||
the Impress editor behind when it ends. It also
|
||||
wants a first-run dialog dismissed and shows a
|
||||
splash. A teacher who presses Escape must land
|
||||
in the library, never in an application that can
|
||||
open any file on the machine.
|
||||
|
||||
okular --presentation opens THREE windows: the
|
||||
presentation, the ordinary Okular document
|
||||
window behind it, and an information dialog.
|
||||
|
||||
So LibreOffice converts to PDF once, at import, with no window on the
|
||||
screen at all, and poppler renders that PDF to page images. The classroom
|
||||
presents the images. Nothing here renders a slide: LibreOffice and
|
||||
poppler do all of it, which is the same engine Impress would have used
|
||||
live. This is not a slide renderer — it is a picture viewer over pictures
|
||||
somebody else drew.
|
||||
|
||||
That also makes a PDF export a first-class citizen rather than a special
|
||||
case: a PDF is already the thing everything else is converted into, so a
|
||||
session exported from Google Slides as PDF skips a conversion and keeps
|
||||
the appearance Google gave it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import unicodedata
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# What a teacher may put in the library.
|
||||
#
|
||||
# Three, and deliberately no more. Every one of them is something the
|
||||
# pastor's material is actually exported as, and each has a conversion
|
||||
# into PDF that Debian packages.
|
||||
SUPPORTED = {
|
||||
".pdf": "pdf",
|
||||
".pptx": "pptx",
|
||||
".ppt": "ppt",
|
||||
}
|
||||
|
||||
# A download that is still arriving.
|
||||
#
|
||||
# Chrome writes "Session 7.pptx.crdownload" and renames it to
|
||||
# "Session 7.pptx" when the transfer completes, so the presence of the
|
||||
# sibling is the authoritative answer to "has this finished". The others
|
||||
# are what a different browser or a copy in progress leaves.
|
||||
PARTIAL_SUFFIXES = (".crdownload", ".part", ".partial", ".download", ".tmp")
|
||||
|
||||
# How long a file has to stop changing before it counts as finished.
|
||||
#
|
||||
# Belt and braces with the .crdownload check above. A file copied onto
|
||||
# the machine from a USB stick has no .crdownload beside it and is still
|
||||
# half written, and a teacher watching a presentation appear mid-copy
|
||||
# would get a library entry that cannot be opened.
|
||||
SETTLE_SECONDS = 6
|
||||
|
||||
# Retention, in days. Neither is a teacher-facing setting in 0.2.6.
|
||||
NEVER_PRESENTED_DAYS = 180
|
||||
AFTER_PRESENTING_DAYS = 30
|
||||
|
||||
# How wide a rendered slide is, in pixels.
|
||||
#
|
||||
# The classroom televisions are 1080p and 4K. 1920 is the 1080p set
|
||||
# exactly and a 4K set scaled up by two, which on static Bible study
|
||||
# slides is indistinguishable at classroom distance and is a quarter of
|
||||
# the pixels to render and store.
|
||||
SLIDE_WIDTH = 1920
|
||||
|
||||
# The library card. Small on purpose: a wall of these has to stay quick.
|
||||
THUMBNAIL_WIDTH = 480
|
||||
|
||||
|
||||
def data_home():
|
||||
return Path(os.environ.get("XDG_DATA_HOME")
|
||||
or Path.home() / ".local" / "share")
|
||||
|
||||
|
||||
def runtime_home():
|
||||
return Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp")
|
||||
|
||||
|
||||
def library_root():
|
||||
return data_home() / "ekklesia-classroom" / "presentations"
|
||||
|
||||
|
||||
def downloads_root():
|
||||
return Path(os.environ.get("XDG_DOWNLOAD_DIR") or Path.home() / "Downloads")
|
||||
|
||||
|
||||
# Wonder Ink's own downloads, which are not presentations and must never
|
||||
# be imported, moved or removed by any of this. classroom-wonderink puts
|
||||
# a lesson's worksheets here; see the DOWNLOADS comment in that file for
|
||||
# why it is a directory of its own.
|
||||
WONDERINK_DOWNLOADS = "Wonder Ink"
|
||||
|
||||
|
||||
def presenting_lock():
|
||||
"""The file that says a presentation is on the screen right now.
|
||||
|
||||
In the runtime directory, so it cannot outlive a reboot: a lock left
|
||||
behind by a crash would otherwise make a presentation permanently
|
||||
un-removable.
|
||||
"""
|
||||
return runtime_home() / "ekklesia-classroom-presenting"
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def stamp(moment=None):
|
||||
return (moment or now()).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def parse_stamp(text):
|
||||
if not text:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(
|
||||
tzinfo=timezone.utc)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def write_json(path, data):
|
||||
"""Write a JSON file so that a crash cannot leave half of one.
|
||||
|
||||
Everything the library knows is written this way. A presentation whose
|
||||
metadata is truncated is a card a teacher cannot open and cannot
|
||||
remove, which is worse than one that was never imported.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + ".new")
|
||||
|
||||
with open(temporary, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def read_json(path, default=None):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
# ------------------------------------------------------------- titles
|
||||
|
||||
# Things a download picks up on its way to the machine, none of which
|
||||
# mean anything to a teacher.
|
||||
_NOISE = re.compile(
|
||||
r"""(
|
||||
\s*\(\d+\)$ # Chrome's " (1)" for a second copy
|
||||
| ^\d{8,}[-_\s]+ # a leading timestamp
|
||||
| [-_\s]*\b[0-9a-f]{8,}\b # a hash or a UUID fragment
|
||||
| \b(final|v\d+|copy|export|download)\b
|
||||
)""",
|
||||
re.IGNORECASE | re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def readable_title(filename, fallback_text=""):
|
||||
"""A title a teacher would recognise, from a filename they did not choose.
|
||||
|
||||
Google Slides exports as "Session 7 - Romans.pptx" and a browser may
|
||||
make that "Session_7_-_Romans%20(1).pptx". Neither a UUID nor a
|
||||
40-character hash is a title, and a teacher scanning a wall of cards
|
||||
for tonight's session should not have to read one.
|
||||
"""
|
||||
stem = Path(filename).stem
|
||||
|
||||
# Percent-escapes a download can carry, decoded only when they are
|
||||
# obviously escapes rather than a literal per cent in a name.
|
||||
if "%20" in stem or "%28" in stem:
|
||||
for escape, character in (("%20", " "), ("%28", "("), ("%29", ")"),
|
||||
("%26", "&"), ("%27", "'")):
|
||||
stem = stem.replace(escape, character)
|
||||
|
||||
stem = unicodedata.normalize("NFC", stem)
|
||||
stem = stem.replace("_", " ").replace("+", " ")
|
||||
# A dash between words is a dash; a dash padding one is a space.
|
||||
stem = re.sub(r"\s*-\s*", " - ", stem)
|
||||
|
||||
cleaned = _NOISE.sub(" ", stem)
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip(" -–—")
|
||||
|
||||
if len(cleaned) >= 3 and not re.fullmatch(r"[\W\d_]+", cleaned):
|
||||
# Long enough to be a name and not purely punctuation and digits.
|
||||
return cleaned[:80]
|
||||
|
||||
# The filename told us nothing. The first readable line of the
|
||||
# document itself is a better answer than "a4f39c2e".
|
||||
for line in (fallback_text or "").splitlines():
|
||||
line = re.sub(r"\s+", " ", line).strip()
|
||||
|
||||
if len(line) >= 3:
|
||||
return line[:80]
|
||||
|
||||
return "Presentation"
|
||||
|
||||
|
||||
def unique_title(title, taken):
|
||||
"""A title that is not already on another card.
|
||||
|
||||
Two files of the same name are two presentations, never one
|
||||
overwriting the other, so the second has to be tellable from the
|
||||
first on a wall of cards.
|
||||
"""
|
||||
if title not in taken:
|
||||
return title
|
||||
|
||||
for number in range(2, 100):
|
||||
candidate = f"{title} ({number})"
|
||||
|
||||
if candidate not in taken:
|
||||
return candidate
|
||||
|
||||
return f"{title} ({uuid.uuid4().hex[:6]})"
|
||||
|
||||
|
||||
# -------------------------------------------------------- the library
|
||||
|
||||
class Presentation:
|
||||
"""One entry in the library, and the directory holding it."""
|
||||
|
||||
def __init__(self, directory, data):
|
||||
self.directory = Path(directory)
|
||||
self.data = data
|
||||
|
||||
# -- identity -------------------------------------------------
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self.data.get("id") or self.directory.name
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self.data.get("title") or "Presentation"
|
||||
|
||||
@property
|
||||
def pages(self):
|
||||
try:
|
||||
return max(0, int(self.data.get("pages") or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
return self.data.get("state") or "importing"
|
||||
|
||||
@property
|
||||
def ready(self):
|
||||
return self.state == "ready" and self.slides.exists()
|
||||
|
||||
@property
|
||||
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 ""
|
||||
|
||||
# -- files ----------------------------------------------------
|
||||
|
||||
@property
|
||||
def slides(self):
|
||||
"""The PDF everything is presented and rendered from."""
|
||||
return self.directory / "slides.pdf"
|
||||
|
||||
@property
|
||||
def pages_directory(self):
|
||||
return self.directory / "pages"
|
||||
|
||||
def page_image(self, number):
|
||||
return self.pages_directory / f"page-{number:03d}.png"
|
||||
|
||||
@property
|
||||
def thumbnail(self):
|
||||
return self.directory / "thumbnail.png"
|
||||
|
||||
@property
|
||||
def managed_copy(self):
|
||||
name = self.data.get("managed_copy")
|
||||
|
||||
return self.directory / name if name else None
|
||||
|
||||
# -- what a teacher chose -------------------------------------
|
||||
|
||||
@property
|
||||
def thumbnail_page(self):
|
||||
"""Which slide is the card's picture.
|
||||
|
||||
Independent of where a presentation starts, and independent of
|
||||
whether it has ever been presented. Changing it is not a
|
||||
presentation and does not touch a retention timer — the three are
|
||||
kept apart on purpose, because the first slide of a Bible study
|
||||
session is a generic opening shared by every session and the one
|
||||
that names the session is usually the second.
|
||||
"""
|
||||
try:
|
||||
chosen = int(self.data.get("thumbnail_page") or 1)
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
return min(max(chosen, 1), self.pages or 1)
|
||||
|
||||
# -- retention ------------------------------------------------
|
||||
|
||||
@property
|
||||
def imported_at(self):
|
||||
return parse_stamp(self.data.get("imported_at"))
|
||||
|
||||
@property
|
||||
def last_presented_at(self):
|
||||
return parse_stamp(self.data.get("last_presented_at"))
|
||||
|
||||
@property
|
||||
def times_presented(self):
|
||||
try:
|
||||
return max(0, int(self.data.get("times_presented") or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def expires_at(self):
|
||||
"""When the cleanup may remove this, or None if it cannot say.
|
||||
|
||||
Two clocks, and only ever one of them running. A presentation
|
||||
that has never been shown is kept for six months from the day it
|
||||
arrived. One that has been shown is kept for a month from the
|
||||
last time it was actually shown, and every showing starts that
|
||||
month again.
|
||||
"""
|
||||
presented = self.last_presented_at
|
||||
|
||||
if presented is not None:
|
||||
return presented + timedelta(days=AFTER_PRESENTING_DAYS)
|
||||
|
||||
imported = self.imported_at
|
||||
|
||||
if imported is None:
|
||||
return None
|
||||
|
||||
return imported + timedelta(days=NEVER_PRESENTED_DAYS)
|
||||
|
||||
def expired(self, moment=None):
|
||||
due = self.expires_at()
|
||||
|
||||
return due is not None and (moment or now()) >= due
|
||||
|
||||
# -- writing --------------------------------------------------
|
||||
|
||||
@property
|
||||
def metadata_path(self):
|
||||
return self.directory / "presentation.json"
|
||||
|
||||
def save(self):
|
||||
write_json(self.metadata_path, self.data)
|
||||
|
||||
def set_thumbnail_page(self, number):
|
||||
"""Choose the slide the library card shows.
|
||||
|
||||
Deliberately writes nothing else. This is not a presentation, it
|
||||
is not an import, and it must not move a retention timer.
|
||||
"""
|
||||
number = min(max(int(number), 1), self.pages or 1)
|
||||
self.data["thumbnail_page"] = number
|
||||
self.save()
|
||||
|
||||
source = self.page_image(number)
|
||||
|
||||
if source.exists():
|
||||
render_thumbnail(source, self.thumbnail)
|
||||
|
||||
return number
|
||||
|
||||
def mark_presented(self, moment=None):
|
||||
"""Record that this was really shown to a room.
|
||||
|
||||
Called only after the presenter has a slide on the screen. Quick
|
||||
Look, opening the library, generating previews and choosing a
|
||||
thumbnail all deliberately do not call it, and neither does a
|
||||
viewer that failed to start — a presentation that did not happen
|
||||
must not buy another thirty days.
|
||||
"""
|
||||
self.data["last_presented_at"] = stamp(moment)
|
||||
self.data["times_presented"] = self.times_presented + 1
|
||||
self.save()
|
||||
|
||||
|
||||
def load(directory):
|
||||
directory = Path(directory)
|
||||
data = read_json(directory / "presentation.json")
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
return Presentation(directory, data)
|
||||
|
||||
|
||||
def library(root=None):
|
||||
"""Every presentation, newest import first.
|
||||
|
||||
A directory that is half written — an import interrupted by a reboot,
|
||||
say — has no readable metadata and is simply not in the list. The
|
||||
importer clears those up; the library never shows a card it cannot
|
||||
open.
|
||||
"""
|
||||
root = Path(root) if root is not None else library_root()
|
||||
|
||||
if not root.is_dir():
|
||||
return []
|
||||
|
||||
found = []
|
||||
|
||||
for entry in sorted(root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
|
||||
presentation = load(entry)
|
||||
|
||||
if presentation is not None:
|
||||
found.append(presentation)
|
||||
|
||||
found.sort(key=lambda p: (p.data.get("imported_at") or ""), reverse=True)
|
||||
|
||||
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.
|
||||
|
||||
Excluded BY ID rather than by title, which is not a detail. The import
|
||||
being named is already in the library — it is written before the
|
||||
conversion starts so an interruption is recoverable — so its own
|
||||
provisional title has to come out of the set. Taking it out by title
|
||||
removed the other presentation's identical title along with it, a set
|
||||
holding one copy of a repeated string, and two files whose names
|
||||
differ only in their extension both came out called "Session 7 -
|
||||
Romans". Found in the lab, importing a PDF and a PPTX of the same
|
||||
session, which is exactly what tomorrow's test does.
|
||||
"""
|
||||
return {
|
||||
presentation.title
|
||||
for presentation in library(root)
|
||||
if exclude is None or presentation.id != exclude
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------ imports
|
||||
|
||||
def is_partial(path):
|
||||
"""Whether this file is still arriving."""
|
||||
path = Path(path)
|
||||
|
||||
if path.name.startswith("."):
|
||||
return True
|
||||
|
||||
if path.suffix.lower() in PARTIAL_SUFFIXES:
|
||||
return True
|
||||
|
||||
# Chrome's in-progress file sits beside the finished name.
|
||||
for suffix in PARTIAL_SUFFIXES:
|
||||
if path.with_name(path.name + suffix).exists():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def settled(path, seconds=SETTLE_SECONDS):
|
||||
"""Whether the file has stopped being written to."""
|
||||
try:
|
||||
stat = Path(path).stat()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return (time.time() - stat.st_mtime) >= seconds and stat.st_size > 0
|
||||
|
||||
|
||||
def importable(path):
|
||||
"""Whether this is a finished file the library should take."""
|
||||
path = Path(path)
|
||||
|
||||
if path.suffix.lower() not in SUPPORTED:
|
||||
return False
|
||||
|
||||
if not path.is_file():
|
||||
return False
|
||||
|
||||
return not is_partial(path) and settled(path)
|
||||
|
||||
|
||||
def within_wonderink(path, downloads=None):
|
||||
"""Whether this file belongs to Wonder Ink's own download workflow.
|
||||
|
||||
Wonder Ink puts a lesson's worksheets in a directory of its own
|
||||
inside Downloads. They are worksheets, not presentations: they are
|
||||
not imported, and nothing in the library ever removes one.
|
||||
"""
|
||||
downloads = Path(downloads) if downloads is not None else downloads_root()
|
||||
|
||||
try:
|
||||
relative = Path(path).resolve().relative_to(downloads.resolve())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
return WONDERINK_DOWNLOADS in relative.parts
|
||||
|
||||
|
||||
def candidates(downloads=None):
|
||||
"""Finished presentation files sitting in the teacher's Downloads.
|
||||
|
||||
The top level only. Nothing recurses into Wonder Ink's directory,
|
||||
and nothing looks anywhere else on the machine: a library that
|
||||
imported from the whole home directory would be a file manager.
|
||||
"""
|
||||
downloads = Path(downloads) if downloads is not None else downloads_root()
|
||||
|
||||
if not downloads.is_dir():
|
||||
return []
|
||||
|
||||
found = []
|
||||
|
||||
try:
|
||||
entries = sorted(downloads.iterdir())
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
for entry in entries:
|
||||
if entry.is_dir():
|
||||
continue
|
||||
|
||||
if within_wonderink(entry, downloads):
|
||||
continue
|
||||
|
||||
if importable(entry):
|
||||
found.append(entry)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def fingerprint(path):
|
||||
"""What makes this exact download this exact download.
|
||||
|
||||
Path, size and modification time. Enough that re-downloading the same
|
||||
session tomorrow is a new presentation, and that a reboot does not
|
||||
import everything in Downloads all over again.
|
||||
"""
|
||||
try:
|
||||
stat = Path(path).stat()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
return f"{Path(path).resolve()}|{stat.st_size}|{int(stat.st_mtime)}"
|
||||
|
||||
|
||||
def ledger_path(root=None):
|
||||
root = Path(root) if root is not None else library_root()
|
||||
|
||||
return root / "imported.json"
|
||||
|
||||
|
||||
def ledger(root=None):
|
||||
data = read_json(ledger_path(root), default={})
|
||||
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def remember(mark, value, root=None):
|
||||
"""Record that this download has been dealt with.
|
||||
|
||||
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
|
||||
write_json(ledger_path(root), data)
|
||||
|
||||
|
||||
# ----------------------------------------------------- rendering
|
||||
|
||||
# Everything below shells out. Nothing here draws a slide: LibreOffice
|
||||
# converts a deck to PDF exactly as Impress would have rendered it live,
|
||||
# and poppler turns PDF pages into pictures. The classroom's own code
|
||||
# only ever displays a picture one of them produced.
|
||||
|
||||
SOFFICE = "soffice"
|
||||
PDFTOPPM = "pdftoppm"
|
||||
PDFINFO = "pdfinfo"
|
||||
PDFTOTEXT = "pdftotext"
|
||||
|
||||
# A large deck on a slow machine. Generous, because the alternative to
|
||||
# waiting is a half-converted file presented to a room.
|
||||
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
|
||||
|
||||
try:
|
||||
return subprocess.run(
|
||||
command, capture_output=True, text=True, timeout=timeout,
|
||||
check=False, **kwargs)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
class Failed:
|
||||
returncode = 127
|
||||
stdout = ""
|
||||
stderr = str(error)
|
||||
|
||||
return Failed()
|
||||
|
||||
|
||||
def convert_to_pdf(source, destination):
|
||||
"""Turn a PPT or PPTX into the PDF everything else works from.
|
||||
|
||||
Headless, in a profile of its own, with no window on any screen. A
|
||||
teacher never sees LibreOffice: not its splash, not its first-run
|
||||
dialog, and above all not its editor. It runs once, at import, and
|
||||
then never again for that presentation.
|
||||
|
||||
A separate -env:UserInstallation is not optional. LibreOffice takes
|
||||
an exclusive lock on its user profile, so a conversion running while
|
||||
anything else on the machine uses LibreOffice would fail, and the
|
||||
failure would arrive as a presentation that never finished importing.
|
||||
"""
|
||||
source = Path(source)
|
||||
destination = Path(destination)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
workspace = destination.parent / "convert"
|
||||
profile = destination.parent / "loprofile"
|
||||
|
||||
if workspace.exists():
|
||||
shutil.rmtree(workspace, ignore_errors=True)
|
||||
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
profile.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
result = _run(
|
||||
[
|
||||
SOFFICE,
|
||||
f"-env:UserInstallation=file://{profile}",
|
||||
"--headless", "--norestore", "--nologo", "--nolockcheck",
|
||||
"--nodefault", "--nofirststartwizard",
|
||||
"--convert-to", "pdf:impress_pdf_Export",
|
||||
"--outdir", str(workspace),
|
||||
str(source),
|
||||
],
|
||||
CONVERT_TIMEOUT,
|
||||
)
|
||||
|
||||
produced = sorted(workspace.glob("*.pdf"))
|
||||
|
||||
if not produced:
|
||||
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()
|
||||
|
||||
os.replace(produced[0], destination)
|
||||
shutil.rmtree(profile, ignore_errors=True)
|
||||
shutil.rmtree(workspace, ignore_errors=True)
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def page_count(pdf):
|
||||
"""How many slides, according to poppler."""
|
||||
result = _run([PDFINFO, str(pdf)], 60)
|
||||
|
||||
for line in (result.stdout or "").splitlines():
|
||||
if line.startswith("Pages:"):
|
||||
try:
|
||||
return int(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def first_text(pdf, pages=2):
|
||||
"""A little of the document's own words, for a title of last resort."""
|
||||
result = _run([PDFTOTEXT, "-f", "1", "-l", str(pages), str(pdf), "-"], 60)
|
||||
|
||||
return result.stdout or ""
|
||||
|
||||
|
||||
def render_pages(pdf, directory, width=SLIDE_WIDTH, progress=None):
|
||||
"""Every slide as a picture, at the size a television shows it.
|
||||
|
||||
One pdftoppm run for the whole document rather than one per page:
|
||||
measured in the lab at 0.03s for ten pages, against a process launch
|
||||
each time. `progress` is called with the number rendered so far, so a
|
||||
library window can show a large import filling in rather than freeze.
|
||||
"""
|
||||
pdf = Path(pdf)
|
||||
directory = Path(directory)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import subprocess
|
||||
|
||||
command = [
|
||||
PDFTOPPM, "-png",
|
||||
"-scale-to-x", str(width), "-scale-to-y", "-1",
|
||||
str(pdf), str(directory / "page"),
|
||||
]
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True)
|
||||
except OSError as error:
|
||||
return 0, str(error)
|
||||
|
||||
deadline = time.monotonic() + RENDER_TIMEOUT
|
||||
|
||||
while process.poll() is None:
|
||||
if time.monotonic() > deadline:
|
||||
process.kill()
|
||||
return 0, "rendering took too long"
|
||||
|
||||
if progress is not None:
|
||||
progress(len(list(directory.glob("page-*.png"))))
|
||||
|
||||
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")
|
||||
|
||||
# pdftoppm numbers by the page count's width: page-1 for a nine-page
|
||||
# document, page-01 for a ninety-page one. The library asks for
|
||||
# page-001, so they are renamed into one shape and nothing that reads
|
||||
# them has to know how many there were.
|
||||
rendered = sorted(directory.glob("page-*.png"))
|
||||
|
||||
for number, path in enumerate(rendered, start=1):
|
||||
wanted = directory / f"page-{number:03d}.png"
|
||||
|
||||
if path != wanted:
|
||||
os.replace(path, wanted)
|
||||
|
||||
return len(rendered), ""
|
||||
|
||||
|
||||
def render_thumbnail(source, destination, width=THUMBNAIL_WIDTH):
|
||||
"""The small picture on a library card, from an already rendered page."""
|
||||
source = Path(source)
|
||||
destination = Path(destination)
|
||||
|
||||
if not source.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("GdkPixbuf", "2.0")
|
||||
from gi.repository import GdkPixbuf
|
||||
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
str(source), width, -1, True)
|
||||
temporary = destination.with_name(destination.name + ".new")
|
||||
pixbuf.savev(str(temporary), "png", [], [])
|
||||
os.replace(temporary, destination)
|
||||
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - a card without a picture is fine
|
||||
# A thumbnail is a convenience. A library that refused to show a
|
||||
# presentation because it could not shrink a picture would be
|
||||
# worse than one showing a card with no picture on it.
|
||||
return False
|
||||
@@ -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)
|
||||
@@ -1,9 +1,11 @@
|
||||
"""The classroom stylesheet, resolved at the room's UI scale.
|
||||
"""The classroom's look: its measurements and its palette.
|
||||
|
||||
The dock and the DVD control bar share this so they cannot drift apart:
|
||||
one template, one set of measurements, one look.
|
||||
The DVD control bar, Parent Call and the Presentations library all draw
|
||||
themselves, and they share this so they cannot drift apart: one set of
|
||||
measurements, one palette, one look.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from classroom_config import scaled
|
||||
@@ -34,3 +36,96 @@ def render(scale, template=None):
|
||||
css = css.replace(f"@{token}@", str(scaled(value, scale)))
|
||||
|
||||
return css
|
||||
|
||||
|
||||
# ---------------------------------------------------------- the palette
|
||||
|
||||
def load_colours():
|
||||
"""The session's own palette, read from the colour scheme in use.
|
||||
|
||||
There is one set of design tokens in this classroom, and they live in
|
||||
a Plasma colour scheme. Reading them here rather than repeating them
|
||||
means this card follows classroom-theme light and dark without
|
||||
knowing that either exists.
|
||||
"""
|
||||
palette = {
|
||||
"window": "#141c20", "view": "#0f171a", "text": "#e6edef",
|
||||
"dim": "#93a5aa", "accent": "#4fb3a8", "on_accent": "#0e1518",
|
||||
"negative": "#e36a6a", "neutral": "#e0b15c", "positive": "#6fcf97",
|
||||
"line": "#25333a",
|
||||
}
|
||||
|
||||
scheme = "EkklesiaDark"
|
||||
|
||||
for path in (
|
||||
os.path.expanduser("~/.config/kdeglobals"),
|
||||
"/etc/ekklesia-classroom/xdg/kdeglobals",
|
||||
"/etc/xdg/kdeglobals",
|
||||
):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
section = None
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if line.startswith("["):
|
||||
section = line
|
||||
elif section and section.startswith("[General]") and \
|
||||
line.startswith("ColorScheme="):
|
||||
scheme = line.split("=", 1)[1].strip()
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
else:
|
||||
if scheme:
|
||||
break
|
||||
|
||||
def rgb(value):
|
||||
parts = [part.strip() for part in value.split(",")]
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
try:
|
||||
return "#%02x%02x%02x" % tuple(int(part) for part in parts)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(f"/usr/share/color-schemes/{scheme}.colors", "r",
|
||||
encoding="utf-8") as handle:
|
||||
group = None
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if line.startswith("["):
|
||||
group = line
|
||||
continue
|
||||
if "=" not in line or group is None:
|
||||
continue
|
||||
key, value = (part.strip() for part in line.split("=", 1))
|
||||
colour = rgb(value)
|
||||
if colour is None:
|
||||
continue
|
||||
if group == "[Colors:Window]":
|
||||
if key == "BackgroundNormal":
|
||||
palette["window"] = colour
|
||||
elif key == "ForegroundNormal":
|
||||
palette["text"] = colour
|
||||
elif key == "ForegroundInactive":
|
||||
palette["dim"] = colour
|
||||
elif key == "ForegroundNegative":
|
||||
palette["negative"] = colour
|
||||
elif key == "ForegroundNeutral":
|
||||
palette["neutral"] = colour
|
||||
elif key == "ForegroundPositive":
|
||||
palette["positive"] = colour
|
||||
elif key == "BackgroundAlternate":
|
||||
palette["line"] = colour
|
||||
elif group == "[Colors:View]" and key == "BackgroundNormal":
|
||||
palette["view"] = colour
|
||||
elif group == "[Colors:Selection]":
|
||||
if key == "BackgroundNormal":
|
||||
palette["accent"] = colour
|
||||
elif key == "ForegroundNormal":
|
||||
palette["on_accent"] = colour
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return palette
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -36,6 +36,41 @@ Description: Ekklesia managed classroom environment
|
||||
of this build. Chrome stays exactly where it was, for the ordinary
|
||||
browser.
|
||||
.
|
||||
libreoffice-impress and poppler-utils are Presentations, and neither is
|
||||
ever shown to a teacher. LibreOffice runs once per import, headless,
|
||||
with no window on any screen, to turn a PPT or PPTX into a PDF; poppler
|
||||
turns that PDF into one picture per slide; the classroom's own library
|
||||
shows the pictures. Measured before it was chosen: `soffice --show`
|
||||
does start a real slideshow and then leaves the Impress editor on
|
||||
screen when it ends, and `okular --presentation` opens its own document
|
||||
window and an information dialog behind the slides. Neither is
|
||||
something a teacher may be left looking at, and converting once at
|
||||
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
|
||||
Cambria, Croscore for the same three under their Chrome OS names — and
|
||||
Roboto, Open Sans, Lato and Montserrat are the web fonts Google Slides
|
||||
itself offers. Every one is in Debian main and installs without a
|
||||
question. ttf-mscorefonts-installer is deliberately absent: it is in
|
||||
contrib, it downloads from a third party at install time and it shows
|
||||
an EULA prompt, and an unattended classroom upgrade cannot answer one.
|
||||
.
|
||||
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.
|
||||
|
||||
@@ -62,6 +62,13 @@ systemctl enable sddm.service >/dev/null 2>&1 || true
|
||||
systemctl --global enable ekklesia-classroom-audio.service >/dev/null 2>&1 || true
|
||||
systemctl --global enable ekklesia-classroom-desktop.service >/dev/null 2>&1 || true
|
||||
|
||||
# The presentation watcher. Enabled on every machine and started by every
|
||||
# session, because whether it does anything is device.conf's answer, not
|
||||
# the installer's: with PRESENTATIONS_ENABLED=false — which is the
|
||||
# default, and therefore every existing classroom — it exits immediately
|
||||
# and the room never runs an importer it has no use for.
|
||||
systemctl --global enable ekklesia-classroom-presentations.service >/dev/null 2>&1 || true
|
||||
|
||||
# The panel's disc player is decided before the display manager starts, not
|
||||
# at the same time as it. Enabled rather than started: this install has
|
||||
# just run the same command directly, below.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"""Match the classroom's panel to the machine it is on.
|
||||
|
||||
Which applications a device offers is decided here, once, and written into
|
||||
the three feature lines of the installed layout. The panel is then built
|
||||
the feature lines of the installed layout. The panel is then built
|
||||
from that layout at the next login.
|
||||
|
||||
Each application is its own question, and the shape of the machine is
|
||||
@@ -15,6 +15,11 @@ never one of them:
|
||||
|
||||
Parent Call PARENT_CALL_ENABLED. Same rule.
|
||||
|
||||
Presentations PRESENTATIONS_ENABLED. Same rule again, and it asks
|
||||
the machine nothing at all: a presentation needs a
|
||||
screen, which every device has. Alone among these it
|
||||
defaults to OFF, because no existing room has it.
|
||||
|
||||
the disc DVD_ENABLED *and* a drive being present. The setting can
|
||||
player take the player away from a machine that has a drive;
|
||||
nothing can put it on a machine that has none, because a
|
||||
@@ -39,6 +44,7 @@ again at the next login.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -61,10 +67,25 @@ FEATURE_LINES = (
|
||||
("WONDERINK", "WONDERINK_ENABLED"),
|
||||
("DVD", "DVD_ENABLED"),
|
||||
("PARENT_CALL", "PARENT_CALL_ENABLED"),
|
||||
("PRESENTATIONS", "PRESENTATIONS_ENABLED"),
|
||||
)
|
||||
|
||||
MARKER = "/* CLASSROOM-FEATURES-BEGIN */"
|
||||
|
||||
# WHAT THE MANUAL IS ALLOWED TO KNOW ABOUT THIS DEVICE.
|
||||
#
|
||||
# This command already decides what the device offers, so it is also the
|
||||
# place to write that answer down where the classroom account can read
|
||||
# it. A list of setting NAMES, and nothing else: no values, no broker
|
||||
# password, no Wi-Fi, no update address, none of the rest of device.conf
|
||||
# — which the manual has no use for and the room should not be handed.
|
||||
#
|
||||
# In /run rather than /etc because it is derived, not configured: it is
|
||||
# rewritten from device.conf at every boot by the same service that
|
||||
# builds the bar, so it cannot go stale behind a setting somebody
|
||||
# changed.
|
||||
MANIFEST = "/run/ekklesia-classroom/features.json"
|
||||
|
||||
# The kernel names optical drives sr0, sr1, ... regardless of bus. Both
|
||||
# views are checked: /dev is what anything opening the drive will use, and
|
||||
# /sys/block is the kernel's own list, which cannot be left behind by a
|
||||
@@ -121,6 +142,32 @@ def rewrite(text, variable, answer):
|
||||
)
|
||||
|
||||
|
||||
def publish(answers, path=MANIFEST):
|
||||
"""Leave the enabled features where classroom-manual can read them.
|
||||
|
||||
Best effort on purpose. A room whose manual shows a page for
|
||||
something it has not got is a small confusion; a panel that failed to
|
||||
build because a directory was read-only is a room with no way of
|
||||
getting anywhere.
|
||||
"""
|
||||
enabled = sorted(
|
||||
setting for variable, setting in FEATURE_LINES if answers.get(variable)
|
||||
)
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
temporary = f"{path}.new"
|
||||
|
||||
with open(temporary, "w", encoding="utf-8") as handle:
|
||||
json.dump(enabled, handle)
|
||||
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, 0o644)
|
||||
say(f"the manual will show {', '.join(enabled) or 'no optional pages'}")
|
||||
except OSError as error:
|
||||
say(f"could not write {path}: {error}")
|
||||
|
||||
|
||||
def main():
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else LAYOUT
|
||||
|
||||
@@ -137,8 +184,11 @@ def main():
|
||||
return 0
|
||||
|
||||
after = before
|
||||
answers = wanted(read_device_config())
|
||||
|
||||
for variable, answer in wanted(read_device_config()).items():
|
||||
publish(answers)
|
||||
|
||||
for variable, answer in answers.items():
|
||||
after = rewrite(after, variable, answer)
|
||||
|
||||
if after == before:
|
||||
|
||||
@@ -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:]))
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Open this computer's own manual.
|
||||
|
||||
classroom-manual open it
|
||||
classroom-manual --features say what this device would show
|
||||
|
||||
One manual is installed on every machine, carrying every page. What makes
|
||||
it THIS room's manual is that the pages for features this device does not
|
||||
have are removed before a teacher ever sees them: a Bible study laptop
|
||||
opens it and reads about the Browser, Presentations and the volume, with
|
||||
no mention of Wonder Ink or Parent Call to wonder about.
|
||||
|
||||
WHAT REACHES THE CLASSROOM ACCOUNT, AND WHAT DOES NOT.
|
||||
|
||||
Only a list of setting NAMES — ["WONDERINK_ENABLED", ...] — and only
|
||||
those the package already defines as optional applications. No values
|
||||
from device.conf, no broker address, no broker password, no update URL,
|
||||
no Wi-Fi anything, no other key an administrator has put in that file.
|
||||
|
||||
That list is prepared by classroom-desktop-layout, which is already the
|
||||
one place that decides what this device offers, and left in the runtime
|
||||
directory where it is readable and carries nothing worth protecting. If
|
||||
it is not there, every page is shown — which is exactly the printed
|
||||
manual, and is never wrong, only less specific.
|
||||
|
||||
Shown through Chrome in application mode: no address bar, no tabs, one
|
||||
window that closes. Chrome is on every classroom machine already, the
|
||||
manual is a local file, and none of it needs the network.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
|
||||
)
|
||||
|
||||
from classroom_config import FEATURES, feature, read_device_config # noqa: E402
|
||||
|
||||
MANUAL = "/usr/local/share/ekklesia-classroom/manual/classroom-manual.html"
|
||||
|
||||
# Written by classroom-desktop-layout at boot. See the module docstring
|
||||
# for what is and is not in it.
|
||||
MANIFEST = "/run/ekklesia-classroom/features.json"
|
||||
|
||||
|
||||
def say(message):
|
||||
print(f"classroom-manual: {message}")
|
||||
|
||||
|
||||
def enabled_features():
|
||||
"""The optional applications this device offers, by setting name."""
|
||||
try:
|
||||
with open(MANIFEST, "r", encoding="utf-8") as handle:
|
||||
listed = json.load(handle)
|
||||
|
||||
if isinstance(listed, list):
|
||||
# Filtered against what this package knows, so a stale or
|
||||
# hand-edited manifest can only ever name a real feature.
|
||||
return [name for name in listed if name in FEATURES]
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
# No manifest. Fall back to asking device.conf directly for the
|
||||
# feature keys and nothing else — the same question, one step less
|
||||
# removed. A root-run --features does this; a classroom session
|
||||
# normally has the manifest.
|
||||
config = read_device_config()
|
||||
|
||||
return [name for name in sorted(FEATURES) if feature(config, name)]
|
||||
|
||||
|
||||
def page(features):
|
||||
"""The installed manual, with this device's features declared in it."""
|
||||
with open(MANUAL, "r", encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
|
||||
declaration = (
|
||||
"<script>window.CLASSROOM_FEATURES = "
|
||||
+ json.dumps(sorted(features))
|
||||
+ ";</script>"
|
||||
)
|
||||
|
||||
# Before the viewer script, which reads it. Nothing else in the file
|
||||
# is touched, so what a teacher reads is the file the package
|
||||
# installed.
|
||||
return text.replace("<body>", "<body>" + declaration, 1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Open this computer's manual.")
|
||||
parser.add_argument("--features", action="store_true",
|
||||
help="say what this device would show; opens nothing")
|
||||
arguments = parser.parse_args()
|
||||
|
||||
features = enabled_features()
|
||||
|
||||
if arguments.features:
|
||||
for name in sorted(FEATURES):
|
||||
print(f" {name:<24} {'shown' if name in features else 'hidden'}")
|
||||
|
||||
return 0
|
||||
|
||||
if not os.path.exists(MANUAL):
|
||||
say(f"the manual is not installed at {MANUAL}")
|
||||
return 1
|
||||
|
||||
# Composed into the teacher's own cache rather than beside the
|
||||
# installed file, which is root's and read-only.
|
||||
cache = os.path.join(
|
||||
os.environ.get("XDG_CACHE_HOME")
|
||||
or os.path.expanduser("~/.cache"),
|
||||
"ekklesia-classroom")
|
||||
os.makedirs(cache, exist_ok=True)
|
||||
|
||||
try:
|
||||
composed = os.path.join(cache, "manual.html")
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", encoding="utf-8", dir=cache, delete=False,
|
||||
suffix=".html") as handle:
|
||||
handle.write(page(features))
|
||||
temporary = handle.name
|
||||
|
||||
os.replace(temporary, composed)
|
||||
except OSError as error:
|
||||
say(f"could not prepare the manual: {error}")
|
||||
return 1
|
||||
|
||||
scale = "1"
|
||||
|
||||
try:
|
||||
scale = subprocess.run(
|
||||
["/usr/local/bin/classroom-ui-scale"], capture_output=True,
|
||||
text=True, timeout=10, check=False).stdout.strip() or "1"
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
try:
|
||||
# Application mode: the manual in a window of its own, with no
|
||||
# address bar for a teacher to end up somewhere else in. The
|
||||
# room's ordinary browser profile is deliberately not used, so
|
||||
# opening Help cannot disturb a lesson that is already open in
|
||||
# Chrome.
|
||||
os.execvp("google-chrome", [
|
||||
"google-chrome",
|
||||
"--ozone-platform=wayland",
|
||||
f"--force-device-scale-factor={scale}",
|
||||
f"--user-data-dir={os.path.join(cache, 'manual-profile')}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--password-store=basic",
|
||||
"--hide-crash-restore-bubble",
|
||||
f"--app=file://{composed}",
|
||||
])
|
||||
except OSError as error:
|
||||
say(f"could not open the browser: {error}")
|
||||
say(f"the manual itself is at {composed}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -105,95 +105,10 @@ OVERALL_TIMEOUT = 210.0
|
||||
CLOSE_AFTER_SUCCESS = 4.0
|
||||
|
||||
|
||||
def load_colours():
|
||||
"""The session's own palette, read from the colour scheme in use.
|
||||
|
||||
There is one set of design tokens in this classroom, and they live in
|
||||
a Plasma colour scheme. Reading them here rather than repeating them
|
||||
means this card follows classroom-theme light and dark without
|
||||
knowing that either exists.
|
||||
"""
|
||||
palette = {
|
||||
"window": "#141c20", "view": "#0f171a", "text": "#e6edef",
|
||||
"dim": "#93a5aa", "accent": "#4fb3a8", "on_accent": "#0e1518",
|
||||
"negative": "#e36a6a", "neutral": "#e0b15c", "positive": "#6fcf97",
|
||||
"line": "#25333a",
|
||||
}
|
||||
|
||||
scheme = "EkklesiaDark"
|
||||
|
||||
for path in (
|
||||
os.path.expanduser("~/.config/kdeglobals"),
|
||||
"/etc/ekklesia-classroom/xdg/kdeglobals",
|
||||
"/etc/xdg/kdeglobals",
|
||||
):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
section = None
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if line.startswith("["):
|
||||
section = line
|
||||
elif section and section.startswith("[General]") and \
|
||||
line.startswith("ColorScheme="):
|
||||
scheme = line.split("=", 1)[1].strip()
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
else:
|
||||
if scheme:
|
||||
break
|
||||
|
||||
def rgb(value):
|
||||
parts = [part.strip() for part in value.split(",")]
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
try:
|
||||
return "#%02x%02x%02x" % tuple(int(part) for part in parts)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(f"/usr/share/color-schemes/{scheme}.colors", "r",
|
||||
encoding="utf-8") as handle:
|
||||
group = None
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if line.startswith("["):
|
||||
group = line
|
||||
continue
|
||||
if "=" not in line or group is None:
|
||||
continue
|
||||
key, value = (part.strip() for part in line.split("=", 1))
|
||||
colour = rgb(value)
|
||||
if colour is None:
|
||||
continue
|
||||
if group == "[Colors:Window]":
|
||||
if key == "BackgroundNormal":
|
||||
palette["window"] = colour
|
||||
elif key == "ForegroundNormal":
|
||||
palette["text"] = colour
|
||||
elif key == "ForegroundInactive":
|
||||
palette["dim"] = colour
|
||||
elif key == "ForegroundNegative":
|
||||
palette["negative"] = colour
|
||||
elif key == "ForegroundNeutral":
|
||||
palette["neutral"] = colour
|
||||
elif key == "ForegroundPositive":
|
||||
palette["positive"] = colour
|
||||
elif key == "BackgroundAlternate":
|
||||
palette["line"] = colour
|
||||
elif group == "[Colors:View]" and key == "BackgroundNormal":
|
||||
palette["view"] = colour
|
||||
elif group == "[Colors:Selection]":
|
||||
if key == "BackgroundNormal":
|
||||
palette["accent"] = colour
|
||||
elif key == "ForegroundNormal":
|
||||
palette["on_accent"] = colour
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return palette
|
||||
# The palette lives in classroom_style, so this card, the Presentations
|
||||
# library and anything else the classroom draws cannot drift apart over
|
||||
# what "the accent colour" means.
|
||||
from classroom_style import load_colours # noqa: E402,F401
|
||||
|
||||
|
||||
STYLE = """
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Watch the teacher's downloads, and keep the library tidy.
|
||||
|
||||
A teacher downloads a session in ordinary Chrome and then looks for it in
|
||||
Presentations. Nothing in between is their job: no file manager, no
|
||||
import button, no "where did you save it". This is the in between.
|
||||
|
||||
classroom-presentation-import run as a service, forever
|
||||
classroom-presentation-import --once one pass, for a technician
|
||||
classroom-presentation-import --status what the library holds
|
||||
|
||||
WHAT IT WILL NOT DO.
|
||||
|
||||
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
|
||||
sessions; the cleanup will not remove one either.
|
||||
|
||||
It never imports a file that is still arriving. Chrome writes
|
||||
`Session 7.pptx.crdownload` and renames it when the transfer is
|
||||
done, so a finished name with that sibling beside it is a download in
|
||||
progress, not a presentation.
|
||||
|
||||
It never overwrites a presentation with a later one of the same
|
||||
name. Two downloads called "Bible Study.pdf" are two presentations.
|
||||
"""
|
||||
|
||||
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(
|
||||
0,
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
|
||||
)
|
||||
|
||||
import classroom_presentations as library # noqa: E402
|
||||
from classroom_config import feature, read_device_config # noqa: E402
|
||||
|
||||
# How often the downloads directory is looked at.
|
||||
#
|
||||
# Polling rather than inotify, deliberately. A teacher waits a few
|
||||
# seconds either way, this cannot leak a watch descriptor or miss an
|
||||
# event across a restart, and it is the kind of code somebody can still
|
||||
# read in two years.
|
||||
SCAN_SECONDS = 4
|
||||
|
||||
# How often expired presentations are cleared out.
|
||||
CLEANUP_SECONDS = 3600
|
||||
|
||||
|
||||
def say(message):
|
||||
print(f"classroom-presentation-import: {message}", flush=True)
|
||||
|
||||
|
||||
def new_directory(root):
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
directory = root / uuid.uuid4().hex
|
||||
|
||||
directory.mkdir()
|
||||
|
||||
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. 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)
|
||||
|
||||
if mark is None:
|
||||
return None
|
||||
|
||||
directory = new_directory(root)
|
||||
managed = directory / "original" / source.name
|
||||
|
||||
data = {
|
||||
"id": directory.name,
|
||||
"state": "importing",
|
||||
"title": library.readable_title(source.name),
|
||||
"original_name": source.name,
|
||||
"source_path": str(source),
|
||||
"managed_copy": str(managed.relative_to(directory)),
|
||||
"format": library.SUPPORTED.get(source.suffix.lower(), ""),
|
||||
"imported_at": library.stamp(),
|
||||
"last_presented_at": None,
|
||||
"times_presented": 0,
|
||||
"thumbnail_page": 1,
|
||||
"pages": 0,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
presentation = library.Presentation(directory, data)
|
||||
presentation.save()
|
||||
|
||||
# Recorded before the work, so that a file which crashes the
|
||||
# conversion every time cannot be retried in a loop forever. The
|
||||
# startup sweep is what un-records an import interrupted part way.
|
||||
library.remember(mark, directory.name, root)
|
||||
|
||||
say(f"importing {source.name}")
|
||||
|
||||
try:
|
||||
move_download(source, managed)
|
||||
except Exception as error: # noqa: BLE001
|
||||
# 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 fail(presentation, error, kind=None):
|
||||
"""Record a failure a teacher's card can say something useful about."""
|
||||
presentation.data["state"] = "failed"
|
||||
presentation.data["error"] = str(error)[:300]
|
||||
presentation.data["error_kind"] = kind or (
|
||||
"memory" if isinstance(error, NeedsMemory) else "unreadable")
|
||||
presentation.save()
|
||||
|
||||
|
||||
def attempt(presentation):
|
||||
"""prepare(), with any failure recorded rather than raised."""
|
||||
try:
|
||||
prepare(presentation)
|
||||
except Exception as error: # noqa: BLE001
|
||||
fail(presentation, error)
|
||||
say(f"could not import {presentation.title!r}: {error}")
|
||||
|
||||
|
||||
def retry_requested(root=None):
|
||||
"""Presentations a teacher pressed Try again on, taken up now."""
|
||||
root = root if root is not None else library.library_root()
|
||||
retried = 0
|
||||
|
||||
if not root.is_dir():
|
||||
return 0
|
||||
|
||||
for presentation in library.library(root):
|
||||
if presentation.state != "retry":
|
||||
continue
|
||||
|
||||
managed = presentation.managed_copy
|
||||
|
||||
if managed is None or not managed.is_file():
|
||||
fail(presentation, RuntimeError("the managed original is missing"), "unreadable")
|
||||
continue
|
||||
|
||||
say(f"trying again: {presentation.title!r}")
|
||||
attempt(presentation)
|
||||
retried += 1
|
||||
|
||||
return retried
|
||||
|
||||
|
||||
def sweep(root=None):
|
||||
"""Recover imports interrupted during a move, conversion or rendering.
|
||||
|
||||
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
|
||||
|
||||
stale = 0
|
||||
|
||||
for presentation in library.library(root):
|
||||
if presentation.state != "importing":
|
||||
continue
|
||||
|
||||
source_text = presentation.data.get("source_path")
|
||||
source = Path(source_text) if source_text else None
|
||||
managed = presentation.managed_copy
|
||||
|
||||
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
|
||||
|
||||
return stale
|
||||
|
||||
|
||||
def scan(root=None, downloads=None):
|
||||
"""One look at the downloads directory."""
|
||||
root = root if root is not None else library.library_root()
|
||||
known = library.ledger(root)
|
||||
imported = 0
|
||||
|
||||
for candidate in library.candidates(downloads):
|
||||
mark = library.fingerprint(candidate)
|
||||
|
||||
if mark is None or mark in known:
|
||||
continue
|
||||
|
||||
if import_one(candidate, root) is not None:
|
||||
imported += 1
|
||||
known = library.ledger(root)
|
||||
|
||||
return imported
|
||||
|
||||
|
||||
def cleanup(root=None, moment=None):
|
||||
"""Remove presentations whose time is up.
|
||||
|
||||
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()
|
||||
showing = ""
|
||||
|
||||
try:
|
||||
showing = lock.read_text().strip()
|
||||
except OSError:
|
||||
showing = ""
|
||||
|
||||
removed = 0
|
||||
|
||||
for presentation in library.library(root):
|
||||
if not presentation.expired(moment):
|
||||
continue
|
||||
|
||||
if presentation.id == showing:
|
||||
# A retention timer must never interrupt a room. It will
|
||||
# still be expired in an hour.
|
||||
say(f"{presentation.title!r} is expired but is being presented")
|
||||
continue
|
||||
|
||||
if presentation.last_presented_at is None:
|
||||
why = f"never presented, imported {presentation.data['imported_at']}"
|
||||
else:
|
||||
why = f"last presented {presentation.data['last_presented_at']}"
|
||||
|
||||
say(f"removing {presentation.title!r} — {why}")
|
||||
|
||||
if library.remove(presentation):
|
||||
removed += 1
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
def status(root=None):
|
||||
presentations = library.library(root)
|
||||
|
||||
if not presentations:
|
||||
print("the library is empty")
|
||||
return 0
|
||||
|
||||
for presentation in presentations:
|
||||
expires = presentation.expires_at()
|
||||
print(f"{presentation.title}")
|
||||
print(f" state {presentation.state}"
|
||||
f"{' — ' + presentation.error if presentation.error else ''}")
|
||||
print(f" slides {presentation.pages}"
|
||||
f" (card shows slide {presentation.thumbnail_page})")
|
||||
print(f" imported {presentation.data.get('imported_at')}")
|
||||
presented = presentation.data.get("last_presented_at") or "never"
|
||||
print(f" presented {presented} "
|
||||
f"({presentation.times_presented} times)")
|
||||
print(f" expires {library.stamp(expires) if expires else 'unknown'}")
|
||||
|
||||
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.")
|
||||
parser.add_argument("--once", action="store_true",
|
||||
help="one pass, then stop")
|
||||
parser.add_argument("--status", action="store_true",
|
||||
help="what the library holds; changes nothing")
|
||||
parser.add_argument("--cleanup", action="store_true",
|
||||
help="apply the retention rules now")
|
||||
arguments = parser.parse_args()
|
||||
|
||||
if arguments.status:
|
||||
return status()
|
||||
|
||||
# A device that does not offer Presentations does not run an importer
|
||||
# for them. Said out loud for the technician who started it by hand.
|
||||
if not feature(read_device_config(), "PRESENTATIONS_ENABLED"):
|
||||
say("PRESENTATIONS_ENABLED is false on this device; not starting")
|
||||
return 0
|
||||
|
||||
if arguments.cleanup:
|
||||
removed = cleanup()
|
||||
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()
|
||||
cleanup()
|
||||
say(f"imported {imported} presentation(s)")
|
||||
return 0
|
||||
|
||||
say(f"watching {library.downloads_root()}")
|
||||
|
||||
last_cleanup = 0.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
retry_requested()
|
||||
scan()
|
||||
|
||||
if time.monotonic() - last_cleanup > CLEANUP_SECONDS:
|
||||
cleanup()
|
||||
last_cleanup = time.monotonic()
|
||||
except Exception as error: # noqa: BLE001
|
||||
# One bad file must not stop the watcher. A teacher whose
|
||||
# next download never appears has no way to find out why.
|
||||
say(f"scan failed: {error}")
|
||||
|
||||
time.sleep(SCAN_SECONDS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,982 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""The Presentations library, as a teacher sees it.
|
||||
|
||||
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 opens this,
|
||||
recognises tonight's by its picture, and presses Present.
|
||||
|
||||
WHY THE PRESENTER IS IN THIS WINDOW AND NOT A SEPARATE PROGRAM.
|
||||
|
||||
The disc player is a separate process, because a disc plays until
|
||||
somebody stops it and the panel is how you get back to it. A
|
||||
presentation is the opposite: it is a thing a teacher steps into and
|
||||
must step out of, back to the library, reliably, with one key they
|
||||
already know.
|
||||
|
||||
Making the presenter a fullscreen window of this same application is the
|
||||
whole of that guarantee. Escape is handled here, the fullscreen window is
|
||||
taken down here, and the library is already on the screen underneath it.
|
||||
There is no second process to fail to start, no window to be left behind,
|
||||
and no path by which a teacher ends up looking at something that is not
|
||||
the library.
|
||||
|
||||
That is also why nothing here launches Impress, Okular or any other
|
||||
document application. Every one of them that was measured leaves its own
|
||||
editor or viewer on the screen when a slideshow ends — see
|
||||
docs/presentations.md for what each one actually did. The slides are
|
||||
pictures by the time they reach this window, rendered at import by
|
||||
LibreOffice and poppler, and a picture cannot open a file dialog.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("GdkPixbuf", "2.0")
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402
|
||||
|
||||
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
|
||||
)
|
||||
|
||||
import classroom_presentations as library # noqa: E402
|
||||
from classroom_config import feature, read_device_config # noqa: E402
|
||||
from classroom_style import load_colours # noqa: E402
|
||||
|
||||
APP_ID = "ekklesia-presentations"
|
||||
|
||||
# How often the library notices a newly imported presentation.
|
||||
#
|
||||
# The importer is a separate service, so a session downloaded while this
|
||||
# window is open appears without the teacher doing anything. Two seconds
|
||||
# is well under the time it takes to walk back to the machine.
|
||||
REFRESH_SECONDS = 2
|
||||
|
||||
CARD_WIDTH = 320
|
||||
|
||||
|
||||
STYLE = """
|
||||
window {{ background-color: {window}; }}
|
||||
.heading {{ color: {text}; font-size: 30px; font-weight: 700; }}
|
||||
.subtle {{ color: {dim}; font-size: 16px; }}
|
||||
.card {{
|
||||
background-color: {view};
|
||||
border: 1px solid {line};
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
}}
|
||||
.card-title {{ color: {text}; font-size: 19px; font-weight: 600; }}
|
||||
.card-note {{ color: {dim}; font-size: 14px; }}
|
||||
.thumb {{
|
||||
background-color: {window};
|
||||
border: 1px solid {line};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
button {{
|
||||
color: {text};
|
||||
background-image: none;
|
||||
background-color: rgba(255,255,255,0.06);
|
||||
border: 1px solid {line};
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
font-size: 16px;
|
||||
}}
|
||||
button:hover {{ background-color: rgba(255,255,255,0.12); }}
|
||||
button.primary {{
|
||||
color: {on_accent};
|
||||
background-color: {accent};
|
||||
border-color: {accent};
|
||||
font-weight: 700;
|
||||
}}
|
||||
button.danger:hover {{ background-color: {negative}; color: {on_accent}; }}
|
||||
.empty {{ color: {dim}; font-size: 20px; }}
|
||||
.slide-counter {{
|
||||
color: {text};
|
||||
background-color: rgba(0,0,0,0.65);
|
||||
border-radius: 10px;
|
||||
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, height=-1):
|
||||
try:
|
||||
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
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.
|
||||
|
||||
Everything a presentation remote and a keyboard can reasonably send
|
||||
is accepted, because a teacher in front of a room does not get to
|
||||
look up which key this one wants. The clicker in the cupboard sends
|
||||
Page Up and Page Down; the keyboard sends arrows and space.
|
||||
|
||||
Escape is the way out and it is the only way out, which is what makes
|
||||
this safe to put in front of a teacher: there is nothing else to
|
||||
press and nowhere else to end up.
|
||||
"""
|
||||
|
||||
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_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)
|
||||
|
||||
black = Gdk.RGBA()
|
||||
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.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.
|
||||
|
||||
A compositor may unfullscreen a window without being asked:
|
||||
measured in the lab, where focusing another window on the same
|
||||
workspace dropped the slideshow to half the screen with the
|
||||
library beside it, which is precisely the "teacher is left
|
||||
looking at something that is not the presentation" this whole
|
||||
design is built to prevent. KWin has its own occasions —
|
||||
a notification, a second output appearing.
|
||||
|
||||
So fullscreen is re-asserted rather than assumed, for as long as
|
||||
the presentation is running. It stops the moment Escape is
|
||||
pressed, so this can never fight the teacher's way out.
|
||||
|
||||
And it gives up after a few goes. A compositor that keeps
|
||||
granting fullscreen and taking it away again would otherwise have
|
||||
this asking forever, in the middle of a Bible study, which is a
|
||||
worse failure than the window being the wrong size. After that
|
||||
the slides are still on the screen and Escape still works — only
|
||||
the insisting stops.
|
||||
"""
|
||||
if self.finished:
|
||||
return False
|
||||
|
||||
if event.new_window_state & Gdk.WindowState.FULLSCREEN:
|
||||
self.refused = 0
|
||||
return False
|
||||
|
||||
if self.refused < 5:
|
||||
self.refused += 1
|
||||
self.fullscreen()
|
||||
|
||||
return False
|
||||
|
||||
# -- showing a slide ------------------------------------------
|
||||
|
||||
def _resized(self, _widget, _allocation):
|
||||
self.show_page()
|
||||
|
||||
def show_page(self):
|
||||
path = self.presentation.page_image(self.page)
|
||||
allocation = self.get_allocation()
|
||||
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
|
||||
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
pixbuf = self._fitted(path, width, height)
|
||||
|
||||
if pixbuf is None:
|
||||
return
|
||||
|
||||
self.image.set_from_pixbuf(pixbuf)
|
||||
self._drawn_at = want
|
||||
|
||||
# A SLIDE IS ON THE SCREEN, SO THIS IS A PRESENTATION.
|
||||
#
|
||||
# Counted here and nowhere earlier. Pressing Present is an
|
||||
# intention; a viewer that failed to start, a file that could not
|
||||
# be read and a window that never drew are all not presentations,
|
||||
# and none of them may buy another thirty days of retention.
|
||||
if not self.counted:
|
||||
self.counted = True
|
||||
self.presentation.mark_presented()
|
||||
|
||||
def _fitted(self, path, width, height):
|
||||
if width < 2 or height < 2:
|
||||
return None
|
||||
|
||||
try:
|
||||
original = GdkPixbuf.Pixbuf.new_from_file(str(path))
|
||||
except (GLib.Error, OSError):
|
||||
return None
|
||||
|
||||
# Letterboxed, never cropped and never stretched. A slide with a
|
||||
# verse on it cannot lose its edges to fit a television.
|
||||
ratio = min(width / original.get_width(),
|
||||
height / original.get_height())
|
||||
|
||||
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):
|
||||
target = self.page + delta
|
||||
|
||||
if target < 1:
|
||||
return
|
||||
|
||||
if target > self.presentation.pages:
|
||||
# PAST THE LAST SLIDE IS THE END, AND THE END IS THE LIBRARY.
|
||||
#
|
||||
# Not a black "click to exit" slide, and certainly not an
|
||||
# editor. A teacher who clicks once more than there are
|
||||
# slides is finished, and being finished means being back
|
||||
# where they started.
|
||||
self.finish()
|
||||
return
|
||||
|
||||
self.page = target
|
||||
self.show_page()
|
||||
|
||||
def _key(self, _widget, event):
|
||||
key = event.keyval
|
||||
forward = (
|
||||
Gdk.KEY_Right, Gdk.KEY_Down, Gdk.KEY_space, Gdk.KEY_Page_Down,
|
||||
Gdk.KEY_KP_Page_Down, Gdk.KEY_Return, Gdk.KEY_KP_Enter,
|
||||
Gdk.KEY_KP_Right, Gdk.KEY_KP_Down, Gdk.KEY_n,
|
||||
)
|
||||
backward = (
|
||||
Gdk.KEY_Left, Gdk.KEY_Up, Gdk.KEY_Page_Up, Gdk.KEY_KP_Page_Up,
|
||||
Gdk.KEY_BackSpace, Gdk.KEY_KP_Left, Gdk.KEY_KP_Up, Gdk.KEY_p,
|
||||
)
|
||||
|
||||
if key in forward:
|
||||
self.go(1)
|
||||
elif key in backward:
|
||||
self.go(-1)
|
||||
elif key in (Gdk.KEY_Home, Gdk.KEY_KP_Home):
|
||||
self.page = 1
|
||||
self.show_page()
|
||||
elif key in (Gdk.KEY_End, Gdk.KEY_KP_End):
|
||||
self.page = self.presentation.pages
|
||||
self.show_page()
|
||||
elif key in (Gdk.KEY_Escape, Gdk.KEY_q, Gdk.KEY_F5):
|
||||
self.finish()
|
||||
|
||||
return True
|
||||
|
||||
def finish(self):
|
||||
if self.finished:
|
||||
return True
|
||||
|
||||
self.finished = True
|
||||
|
||||
try:
|
||||
library.presenting_lock().unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
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
|
||||
|
||||
|
||||
class QuickLook(Gtk.Window):
|
||||
"""Look through the slides before showing them to a room.
|
||||
|
||||
Three things a teacher can do here, and they are kept apart on
|
||||
purpose because conflating any two of them would be wrong:
|
||||
|
||||
browse which slide is on this screen right now;
|
||||
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
|
||||
slide and leaving again must not mark the session as presented or
|
||||
move its retention timer by a second. Choosing the picture is not
|
||||
presenting either, and it does not change where a presentation
|
||||
starts.
|
||||
"""
|
||||
|
||||
def __init__(self, presentation, parent, on_close=None, on_present=None):
|
||||
super().__init__(title=presentation.title)
|
||||
|
||||
self.presentation = presentation
|
||||
self.on_close = on_close
|
||||
self.on_present = on_present
|
||||
# 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)
|
||||
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)
|
||||
self.add(outer)
|
||||
|
||||
heading = Gtk.Label(label=presentation.title, xalign=0)
|
||||
heading.get_style_context().add_class("heading")
|
||||
heading.set_line_wrap(True)
|
||||
outer.pack_start(heading, False, False, 0)
|
||||
|
||||
self.slide = Gtk.Image()
|
||||
frame = Gtk.EventBox()
|
||||
frame.get_style_context().add_class("thumb")
|
||||
frame.add(self.slide)
|
||||
outer.pack_start(frame, True, True, 0)
|
||||
|
||||
# Browsing.
|
||||
browse = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
browse.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
self.back = Gtk.Button(label="Previous")
|
||||
self.back.connect("clicked", lambda *_: self.go(-1))
|
||||
browse.pack_start(self.back, False, False, 0)
|
||||
|
||||
self.counter = Gtk.Label()
|
||||
self.counter.get_style_context().add_class("subtle")
|
||||
browse.pack_start(self.counter, False, False, 12)
|
||||
|
||||
self.forward = Gtk.Button(label="Next")
|
||||
self.forward.connect("clicked", lambda *_: self.go(1))
|
||||
browse.pack_start(self.forward, False, False, 0)
|
||||
|
||||
outer.pack_start(browse, False, False, 0)
|
||||
|
||||
# The three things this window is for.
|
||||
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
|
||||
self.choose = Gtk.Button(label="Use as cover")
|
||||
self.choose.connect("clicked", self._choose)
|
||||
|
||||
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)
|
||||
|
||||
close = Gtk.Button(label="Back to Presentations")
|
||||
close.connect("clicked", lambda *_: self.close_quietly())
|
||||
actions.pack_start(close, False, False, 0)
|
||||
|
||||
present = Gtk.Button(label="Present")
|
||||
present.get_style_context().add_class("primary")
|
||||
present.connect("clicked", self._present)
|
||||
actions.pack_start(present, False, False, 0)
|
||||
|
||||
outer.pack_start(actions, False, False, 0)
|
||||
|
||||
self.connect("key-press-event", self._key)
|
||||
self.connect("delete-event", lambda *_: self.close_quietly())
|
||||
self.connect("size-allocate", lambda *_: self.show_page(False))
|
||||
|
||||
self._drawn_at = (0, 0, 0)
|
||||
self.show_page()
|
||||
|
||||
def show_page(self, force=True):
|
||||
allocation = self.slide.get_allocation()
|
||||
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, height)
|
||||
if path.exists() else None)
|
||||
|
||||
if pixbuf is not None:
|
||||
self.slide.set_from_pixbuf(pixbuf)
|
||||
self._drawn_at = want
|
||||
|
||||
total = self.presentation.pages
|
||||
chosen = self.presentation.thumbnail_page
|
||||
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.cover_action.set_visible_child_name(
|
||||
"current" if self.page == chosen else "choose")
|
||||
|
||||
def go(self, delta):
|
||||
target = self.page + delta
|
||||
|
||||
if 1 <= target <= self.presentation.pages:
|
||||
self.page = target
|
||||
self.show_page()
|
||||
|
||||
def _choose(self, *_):
|
||||
# Writes the chosen slide and nothing else. Not a presentation,
|
||||
# and not a change to where the slideshow starts.
|
||||
self.presentation.set_thumbnail_page(self.page)
|
||||
self.show_page()
|
||||
|
||||
if self.on_close is not None:
|
||||
self.on_close()
|
||||
|
||||
def _present(self, *_):
|
||||
self.destroy()
|
||||
|
||||
if self.on_present is not None:
|
||||
self.on_present(self.presentation)
|
||||
|
||||
def close_quietly(self):
|
||||
"""Leave without having presented anything."""
|
||||
self.destroy()
|
||||
|
||||
if self.on_close is not None:
|
||||
self.on_close()
|
||||
|
||||
return True
|
||||
|
||||
def _key(self, _widget, event):
|
||||
key = event.keyval
|
||||
|
||||
if key in (Gdk.KEY_Right, Gdk.KEY_Down, Gdk.KEY_Page_Down,
|
||||
Gdk.KEY_space):
|
||||
self.go(1)
|
||||
elif key in (Gdk.KEY_Left, Gdk.KEY_Up, Gdk.KEY_Page_Up):
|
||||
self.go(-1)
|
||||
elif key == Gdk.KEY_Escape:
|
||||
self.close_quietly()
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class Library(Gtk.Window):
|
||||
"""The window the panel button opens."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(title="Presentations")
|
||||
|
||||
self.set_default_size(680, 420)
|
||||
self.presenter = None
|
||||
self._signature = None
|
||||
|
||||
self._apply_style()
|
||||
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
outer.set_border_width(20)
|
||||
self.add(outer)
|
||||
|
||||
heading = Gtk.Label(label="Presentations", xalign=0)
|
||||
heading.get_style_context().add_class("heading")
|
||||
outer.pack_start(heading, False, False, 0)
|
||||
|
||||
self.note = Gtk.Label(xalign=0)
|
||||
self.note.get_style_context().add_class("subtle")
|
||||
outer.pack_start(self.note, False, False, 0)
|
||||
|
||||
scroller = Gtk.ScrolledWindow()
|
||||
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
outer.pack_start(scroller, True, True, 8)
|
||||
|
||||
self.grid = Gtk.FlowBox()
|
||||
self.grid.set_valign(Gtk.Align.START)
|
||||
self.grid.set_max_children_per_line(4)
|
||||
self.grid.set_min_children_per_line(1)
|
||||
self.grid.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||
self.grid.set_row_spacing(16)
|
||||
self.grid.set_column_spacing(16)
|
||||
self.grid.set_homogeneous(False)
|
||||
scroller.add(self.grid)
|
||||
|
||||
self.connect("key-press-event", self._key)
|
||||
self.connect("destroy", lambda *_: Gtk.main_quit())
|
||||
|
||||
self.refresh()
|
||||
GLib.timeout_add_seconds(REFRESH_SECONDS, self._tick)
|
||||
|
||||
def _apply_style(self):
|
||||
css = Gtk.CssProvider()
|
||||
css.load_from_data(STYLE.format(**load_colours()).encode("utf-8"))
|
||||
Gtk.StyleContext.add_provider_for_screen(
|
||||
self.get_screen(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
||||
|
||||
# -- the wall of cards ----------------------------------------
|
||||
|
||||
def _tick(self):
|
||||
self.refresh()
|
||||
return True
|
||||
|
||||
def signature(self, presentations):
|
||||
"""What the library looks like, so it is only rebuilt when it changes.
|
||||
|
||||
Rebuilding a FlowBox under a teacher's pointer every two seconds
|
||||
would make the buttons unclickable.
|
||||
"""
|
||||
return tuple(
|
||||
(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)
|
||||
|
||||
if signature == self._signature:
|
||||
return
|
||||
|
||||
self._signature = signature
|
||||
|
||||
for child in self.grid.get_children():
|
||||
self.grid.remove(child)
|
||||
|
||||
if not presentations:
|
||||
self.note.set_text("")
|
||||
empty = Gtk.Label(
|
||||
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)
|
||||
else:
|
||||
# Three states, and a file that could not be opened is not
|
||||
# the same as one still being got ready. Telling a teacher
|
||||
# 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)
|
||||
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")
|
||||
|
||||
self.note.set_text(", ".join(parts) + ".")
|
||||
|
||||
for presentation in presentations:
|
||||
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_frame = Gtk.EventBox()
|
||||
picture_frame.get_style_context().add_class("thumb")
|
||||
picture_frame.set_size_request(CARD_WIDTH - 28, 180)
|
||||
|
||||
if presentation.thumbnail.exists():
|
||||
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)
|
||||
|
||||
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(32)
|
||||
title.set_size_request(CARD_WIDTH - 28, -1)
|
||||
box.pack_start(title, False, False, 0)
|
||||
|
||||
note = Gtk.Label(xalign=0)
|
||||
note.get_style_context().add_class("card-note")
|
||||
box.pack_start(note, False, False, 0)
|
||||
|
||||
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
|
||||
if presentation.ready:
|
||||
note.set_text(f"{presentation.pages} slides")
|
||||
|
||||
present = Gtk.Button(label="Present")
|
||||
present.get_style_context().add_class("primary")
|
||||
present.connect("clicked",
|
||||
lambda *_: self.start_presenting(presentation))
|
||||
buttons.pack_start(present, True, True, 0)
|
||||
|
||||
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, 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…")
|
||||
|
||||
remove = Gtk.Button(label="Remove")
|
||||
remove.get_style_context().add_class("danger")
|
||||
remove.connect("clicked", lambda *_: self.remove(presentation))
|
||||
buttons.pack_start(remove, True, True, 0)
|
||||
|
||||
box.pack_start(buttons, False, False, 0)
|
||||
|
||||
return box
|
||||
|
||||
# -- what the buttons do --------------------------------------
|
||||
|
||||
def start_presenting(self, presentation, start_page=1):
|
||||
if not presentation.ready:
|
||||
return
|
||||
|
||||
first = presentation.page_image(start_page)
|
||||
|
||||
if not first.exists():
|
||||
self.trouble(presentation,
|
||||
"This presentation is not ready yet.")
|
||||
return
|
||||
|
||||
try:
|
||||
lock = library.presenting_lock()
|
||||
lock.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock.write_text(presentation.id)
|
||||
except OSError:
|
||||
# A lock that could not be written only costs the cleanup its
|
||||
# politeness. It must not stop a teacher presenting.
|
||||
pass
|
||||
|
||||
self.presenter = Presenter(
|
||||
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
|
||||
self._signature = None
|
||||
self.refresh()
|
||||
self.present_window()
|
||||
|
||||
def present_window(self):
|
||||
self.show_all()
|
||||
self.present()
|
||||
|
||||
def quick_look(self, presentation):
|
||||
if not presentation.ready:
|
||||
return
|
||||
|
||||
window = QuickLook(
|
||||
presentation, self,
|
||||
on_close=self._looked,
|
||||
on_present=lambda p: self.start_presenting(p),
|
||||
)
|
||||
window.show_all()
|
||||
|
||||
def _looked(self):
|
||||
# Coming back from Quick Look is not a presentation. The only
|
||||
# thing that may have changed is which slide the card shows.
|
||||
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="Remove this presentation?",
|
||||
)
|
||||
dialog.format_secondary_text(
|
||||
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)
|
||||
|
||||
answer = dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
if answer != Gtk.ResponseType.OK:
|
||||
return
|
||||
|
||||
if not library.remove(presentation):
|
||||
self.trouble(
|
||||
presentation,
|
||||
"Finish presenting before removing this presentation.")
|
||||
return
|
||||
self._signature = None
|
||||
self.refresh()
|
||||
|
||||
def trouble(self, presentation, message):
|
||||
dialog = Gtk.MessageDialog(
|
||||
transient_for=self,
|
||||
modal=True,
|
||||
message_type=Gtk.MessageType.INFORMATION,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text=presentation.title,
|
||||
)
|
||||
dialog.format_secondary_text(message)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
def _key(self, _widget, event):
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
# Escape in the library closes the library, which is the same
|
||||
# thing it does everywhere else in this room.
|
||||
self.destroy()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
GLib.set_prgname(APP_ID)
|
||||
GLib.set_application_name("Presentations")
|
||||
|
||||
# A device that does not offer Presentations does not open them. The
|
||||
# panel has no button, so nothing should reach here; saying it out
|
||||
# loud is for the technician who ran the command by hand.
|
||||
if not feature(read_device_config(), "PRESENTATIONS_ENABLED"):
|
||||
print("classroom-presentations: PRESENTATIONS_ENABLED is false on "
|
||||
"this device; not starting", flush=True)
|
||||
return 0
|
||||
|
||||
window = Library()
|
||||
window.show_all()
|
||||
|
||||
Gtk.main()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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())
|
||||
@@ -77,6 +77,11 @@ def nmcli(*args, check=True):
|
||||
result = subprocess.run(
|
||||
["nmcli"] + list(args), capture_output=True, text=True, timeout=60,
|
||||
check=False,
|
||||
# nmcli translates its output and its terse field separator rules
|
||||
# are the only thing here that is parsed. C is the one locale in
|
||||
# which what this reads back is what nmcli is documented to
|
||||
# print, on a machine in any country.
|
||||
env={**os.environ, "LC_ALL": "C"},
|
||||
)
|
||||
|
||||
if check and result.returncode != 0:
|
||||
@@ -86,14 +91,57 @@ def nmcli(*args, check=True):
|
||||
return result
|
||||
|
||||
|
||||
def terse(line):
|
||||
"""One line of `nmcli -t` output, split into its fields.
|
||||
|
||||
nmcli escapes a colon inside a value as `\\:` and a backslash as
|
||||
`\\\\`, so splitting on ":" is wrong for any network whose name
|
||||
contains one. Everything here that compares a connection name to a
|
||||
field goes through this, because getting it wrong does not produce an
|
||||
error — it produces a migration that decides it failed and rolls a
|
||||
working network back.
|
||||
"""
|
||||
fields = []
|
||||
current = []
|
||||
escaped = False
|
||||
|
||||
for character in line:
|
||||
if escaped:
|
||||
current.append(character)
|
||||
escaped = False
|
||||
elif character == "\\":
|
||||
escaped = True
|
||||
elif character == ":":
|
||||
fields.append("".join(current))
|
||||
current = []
|
||||
else:
|
||||
current.append(character)
|
||||
|
||||
fields.append("".join(current))
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def connections():
|
||||
"""Every saved connection, as (name, uuid, type)."""
|
||||
result = nmcli("-t", "-f", "NAME,UUID,TYPE", "connection", "show",
|
||||
check=False)
|
||||
|
||||
found = []
|
||||
|
||||
for line in (result.stdout or "").splitlines():
|
||||
fields = terse(line)
|
||||
|
||||
if len(fields) >= 3:
|
||||
found.append((fields[0], fields[1], fields[2]))
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def listing():
|
||||
result = nmcli("-t", "-f", "NAME,TYPE,UUID", "connection", "show")
|
||||
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split(":")
|
||||
|
||||
if len(parts) >= 2 and "wireless" in parts[1]:
|
||||
print(f" {parts[0]}")
|
||||
for name, _uuid, kind in connections():
|
||||
if "wireless" in kind:
|
||||
print(f" {name}")
|
||||
|
||||
return 0
|
||||
|
||||
@@ -105,7 +153,7 @@ def forget(name):
|
||||
return 0
|
||||
|
||||
|
||||
def add(ssid, password, name=None):
|
||||
def add(ssid, password, name=None, hidden=False):
|
||||
name = name or ssid
|
||||
|
||||
# Deliberately no connection.permissions. That single omission is the
|
||||
@@ -121,6 +169,15 @@ def add(ssid, password, name=None):
|
||||
# Stored in the connection file, which is root-only, rather than
|
||||
# in a login keyring that belongs to whoever is signed in.
|
||||
"802-11-wireless-security.psk-flags", "0",
|
||||
# WHETHER THE ACCESS POINT BROADCASTS ITS NAME.
|
||||
#
|
||||
# Always passed, in both directions, rather than only when it is
|
||||
# true. NetworkManager's default is "no", and a default that
|
||||
# happens to be right is not the same as an answer: this is the
|
||||
# setting the prayer-room laptop's migration left at the default
|
||||
# and could therefore never associate. Written down either way so
|
||||
# that the connection file says what was meant.
|
||||
"802-11-wireless.hidden", "yes" if hidden else "no",
|
||||
]
|
||||
|
||||
if password:
|
||||
@@ -131,9 +188,13 @@ def add(ssid, password, name=None):
|
||||
|
||||
nmcli(*arguments)
|
||||
|
||||
path = os.path.join(CONNECTIONS, f"{name}.nmconnection")
|
||||
if hidden:
|
||||
say(f"{name} is a hidden network; NetworkManager will probe for it "
|
||||
"by name")
|
||||
|
||||
if os.path.exists(path):
|
||||
path = connection_file(name)
|
||||
|
||||
if path and os.path.exists(path):
|
||||
# NetworkManager already writes 0600 root:root. Said out loud and
|
||||
# enforced anyway, because this is the file the whole arrangement
|
||||
# rests on.
|
||||
@@ -148,6 +209,28 @@ def add(ssid, password, name=None):
|
||||
return 0
|
||||
|
||||
|
||||
def connection_file(name):
|
||||
"""Where NetworkManager actually put a connection, or None.
|
||||
|
||||
Asked rather than constructed. NetworkManager escapes characters in
|
||||
the filename it derives from a connection name, so
|
||||
f"{name}.nmconnection" is a guess that happens to be right for a
|
||||
one-word SSID and wrong for "ROGC Classroom" — which is the network
|
||||
this command exists to migrate, and the file whose permissions the
|
||||
whole arrangement rests on.
|
||||
"""
|
||||
result = nmcli("-t", "-f", "NAME,FILENAME", "connection", "show",
|
||||
check=False)
|
||||
|
||||
for line in (result.stdout or "").splitlines():
|
||||
fields = terse(line)
|
||||
|
||||
if len(fields) >= 2 and fields[0] == name and fields[-1]:
|
||||
return fields[-1]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def run(*command):
|
||||
"""Run a command, tolerating one that is not installed.
|
||||
|
||||
@@ -198,16 +281,78 @@ def carrying(name, device):
|
||||
"--active", check=False)
|
||||
|
||||
for line in (result.stdout or "").splitlines():
|
||||
parts = line.rsplit(":", 1)
|
||||
fields = terse(line)
|
||||
|
||||
if len(parts) == 2 and parts[0] == name and parts[1] == device:
|
||||
if len(fields) == 2 and fields[0] == name and fields[1] == device:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def device_state(device):
|
||||
"""NetworkManager's word for what it is doing with this interface."""
|
||||
result = nmcli("-t", "-f", "DEVICE,STATE", "device", "status", check=False)
|
||||
|
||||
for line in (result.stdout or "").splitlines():
|
||||
fields = terse(line)
|
||||
|
||||
if len(fields) >= 2 and fields[0] == device:
|
||||
return fields[1]
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def unmanaged_by_config(device):
|
||||
"""Configuration files that name this device as unmanaged.
|
||||
|
||||
Separate from the ifupdown plugin, and the reason the prayer-room
|
||||
laptop still needed `nmcli device set wlp2s0 managed yes` by hand
|
||||
after its stanza was gone. A keyfile saying `unmanaged-devices` is
|
||||
not undone by commenting out an interfaces stanza, and a hand-over
|
||||
that does not notice it hands the interface to nobody.
|
||||
"""
|
||||
naming = []
|
||||
candidates = ["/etc/NetworkManager/NetworkManager.conf"]
|
||||
|
||||
for directory in ("/etc/NetworkManager/conf.d",
|
||||
"/usr/lib/NetworkManager/conf.d",
|
||||
"/run/NetworkManager/conf.d"):
|
||||
try:
|
||||
candidates += sorted(
|
||||
os.path.join(directory, entry)
|
||||
for entry in os.listdir(directory)
|
||||
if entry.endswith(".conf")
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for path in candidates:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
||||
text = handle.read()
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
|
||||
if not stripped.startswith("unmanaged-devices"):
|
||||
continue
|
||||
|
||||
if device in stripped or "except:" in stripped:
|
||||
naming.append(path)
|
||||
break
|
||||
|
||||
return naming
|
||||
|
||||
|
||||
def find_stanza():
|
||||
"""The one wireless stanza ifupdown holds, or None, plus the file."""
|
||||
if not os.path.exists(INTERFACES):
|
||||
# A machine with no ifupdown configuration at all. Nothing is
|
||||
# wrong with that and nothing here has anything to do.
|
||||
return None, ""
|
||||
|
||||
try:
|
||||
with open(INTERFACES, "r", encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
@@ -244,20 +389,33 @@ def check():
|
||||
return 0
|
||||
|
||||
device = stanza["name"]
|
||||
ssid, _ = ifupdown.credentials(stanza)
|
||||
found = ifupdown.profile(stanza)
|
||||
|
||||
say(f"NOTE: {device} is configured in {INTERFACES}, so ifupdown owns "
|
||||
"it and")
|
||||
say(" NetworkManager will report it unmanaged — the room's panel")
|
||||
say(" will say there is no connection and will not find networks.")
|
||||
|
||||
if ssid:
|
||||
if found and found["hidden"]:
|
||||
say(" Its access point does not broadcast its name, which "
|
||||
"NetworkManager")
|
||||
say(" has to be told; --adopt carries that across.")
|
||||
|
||||
for path in unmanaged_by_config(device):
|
||||
say(f" {path} also names this device unmanaged; --adopt "
|
||||
"reports it")
|
||||
say(" rather than editing it.")
|
||||
|
||||
if found is None:
|
||||
say(" Its credentials are not in that file, so it has to be "
|
||||
"moved by hand.")
|
||||
elif found["key_mgmt"] is None:
|
||||
say(" Its security is not a pre-shared key, so it has to be "
|
||||
"moved by hand.")
|
||||
else:
|
||||
say(" To move it, while watching, over SSH:")
|
||||
say(" classroom-wifi --adopt --dry-run")
|
||||
say(" classroom-wifi --adopt")
|
||||
else:
|
||||
say(" Its credentials are not in that file, so it has to be "
|
||||
"moved by hand.")
|
||||
|
||||
return 0
|
||||
|
||||
@@ -307,17 +465,37 @@ def adopt(dry_run=False):
|
||||
return 0
|
||||
|
||||
device = stanza["name"]
|
||||
ssid, psk = ifupdown.credentials(stanza)
|
||||
found = ifupdown.profile(stanza)
|
||||
|
||||
if not ssid:
|
||||
if found is None:
|
||||
say(f"{device} is configured through a wpa_supplicant.conf rather "
|
||||
"than named in this file")
|
||||
say("that one is not ours to move; adopt it by hand")
|
||||
return 1
|
||||
|
||||
if found["key_mgmt"] is None:
|
||||
say(f"{device} uses a security this command does not recreate "
|
||||
f"({stanza['options'].get('wpa-key-mgmt')})")
|
||||
say("refusing to guess; a network moved wrong is a network that "
|
||||
"cannot come back")
|
||||
return 1
|
||||
|
||||
ssid = found["ssid"]
|
||||
psk = found["psk"]
|
||||
|
||||
# Never printed. The room's password is not diagnostic output.
|
||||
say(f"{device}: ifupdown holds a wireless network "
|
||||
f"({'with' if psk else 'without'} a saved password)")
|
||||
f"({'with' if psk else 'without'} a saved password"
|
||||
f"{', hidden' if found['hidden'] else ''})")
|
||||
|
||||
# A keyfile naming this device unmanaged survives the stanza going
|
||||
# away, so the hand-over would end with the interface belonging to
|
||||
# nobody. Said before anything is touched, and not edited: a file an
|
||||
# administrator wrote is not this command's to rewrite.
|
||||
for path in unmanaged_by_config(device):
|
||||
say(f"WARNING: {path} names {device} unmanaged")
|
||||
say(" NetworkManager will not keep it after a restart "
|
||||
"until that is changed")
|
||||
|
||||
# Refuse to start a hand-over that cannot be undone. ifdown releases
|
||||
# the interface and ifup is the whole of the rollback; without them
|
||||
@@ -337,17 +515,32 @@ def adopt(dry_run=False):
|
||||
"run would refuse")
|
||||
|
||||
say(f"would create a NetworkManager system connection for {ssid!r}")
|
||||
say(f"would comment the {device} stanza out of {INTERFACES}")
|
||||
say(" 802-11-wireless.hidden: "
|
||||
f"{'yes' if found['hidden'] else 'no'}")
|
||||
say(f" 802-11-wireless-security.key-mgmt: {found['key_mgmt']}")
|
||||
say(f"would run `ifdown {device}` while its stanza is still in "
|
||||
f"{INTERFACES}")
|
||||
say(f"would then comment that stanza out of {INTERFACES}")
|
||||
say("would hand the interface over, and put it back if that failed")
|
||||
return 0
|
||||
|
||||
existing = nmcli("-t", "-f", "NAME", "connection", "show", check=False)
|
||||
already = ssid in (existing.stdout or "").splitlines()
|
||||
already = any(name == ssid for name, _uuid, _kind in connections())
|
||||
|
||||
if already:
|
||||
# Reused as it stands. An existing connection of this name may be
|
||||
# a teacher's own, or one a previous attempt left behind with the
|
||||
# wrong hidden setting — which is exactly the state the
|
||||
# prayer-room laptop was in — so it is corrected rather than
|
||||
# trusted. Every setting the migration decides is written again.
|
||||
say(f"NetworkManager already has a connection named {ssid!r}")
|
||||
say("correcting it to match what ifupdown holds")
|
||||
nmcli("connection", "modify", ssid,
|
||||
"802-11-wireless.hidden", "yes" if found["hidden"] else "no",
|
||||
"connection.autoconnect", "yes",
|
||||
"802-11-wireless-security.psk-flags", "0",
|
||||
check=False)
|
||||
else:
|
||||
if add(ssid, psk) != 0:
|
||||
if add(ssid, psk, hidden=found["hidden"]) != 0:
|
||||
return 1
|
||||
|
||||
backup = f"{INTERFACES}.before-ekklesia-classroom"
|
||||
@@ -361,20 +554,51 @@ def adopt(dry_run=False):
|
||||
# timeout, an interrupt — the file goes back and ifupdown gets the
|
||||
# interface again. Nothing below may raise past this.
|
||||
try:
|
||||
# RELEASE THE INTERFACE BEFORE TAKING ITS STANZA AWAY.
|
||||
#
|
||||
# ifdown reads /etc/network/interfaces to find out how to bring
|
||||
# an interface down. Run after the stanza is commented out it
|
||||
# reports "Ignoring unknown interface" and does nothing at all —
|
||||
# so ifupdown's own wpa_supplicant and DHCP client were still
|
||||
# running, still holding the radio, when NetworkManager was asked
|
||||
# to associate with it. Two supplicants on one interface is a
|
||||
# fight NetworkManager loses, and it is the second half of why
|
||||
# the prayer-room laptop rolled back.
|
||||
#
|
||||
# The order is therefore: down first, while the file still says
|
||||
# how; rewrite the file second. The window in which the machine
|
||||
# is offline is the same either way.
|
||||
result = run("ifdown", device)
|
||||
|
||||
if result is not None and result.returncode != 0:
|
||||
# Not fatal. A machine whose interface is already down is a
|
||||
# perfectly good starting point, and ifdown says so with a
|
||||
# non-zero status.
|
||||
say(f"ifdown {device}: "
|
||||
f"{(result.stderr or result.stdout or '').strip()}")
|
||||
|
||||
say(f"released {device} from ifupdown")
|
||||
|
||||
with open(INTERFACES, "w", encoding="utf-8") as handle:
|
||||
handle.write(ifupdown.comment_out(text, stanza))
|
||||
|
||||
say(f"took {device} out of ifupdown's hands")
|
||||
say(f"took {device} out of ifupdown's hands in {INTERFACES}")
|
||||
|
||||
# NetworkManager only re-reads which interfaces ifupdown claims
|
||||
# when its configuration is reloaded. The link is still
|
||||
# ifupdown's at this point, and NetworkManager does not touch a
|
||||
# device it considers unmanaged, so this cannot drop it.
|
||||
# when its configuration is reloaded.
|
||||
nmcli("general", "reload", "conf", check=False)
|
||||
|
||||
run("ifdown", device)
|
||||
nmcli("device", "set", device, "managed", "yes", check=False)
|
||||
nmcli("connection", "up", ssid, check=False)
|
||||
|
||||
# On the device, by name. `connection up <name>` alone lets
|
||||
# NetworkManager choose an interface, and a machine with two
|
||||
# radios could bring the network up on the wrong one — which
|
||||
# `carrying` would then correctly report as a failure, and the
|
||||
# migration would roll back a network that was working.
|
||||
started = nmcli("connection", "up", ssid, "ifname", device,
|
||||
check=False)
|
||||
|
||||
if started.returncode != 0:
|
||||
say(f"nmcli: {(started.stderr or '').strip()}")
|
||||
|
||||
deadline = time.monotonic() + ADOPT_TIMEOUT
|
||||
|
||||
@@ -382,12 +606,15 @@ def adopt(dry_run=False):
|
||||
if carrying(ssid, device) and online():
|
||||
say(f"{device} is NetworkManager's now, and the room is "
|
||||
"online")
|
||||
say(f"NetworkManager reports {device} "
|
||||
f"{device_state(device)}")
|
||||
say("the panel will show the connection at the next login")
|
||||
return 0
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
say("NetworkManager did not get the network back")
|
||||
say("NetworkManager did not get the network back "
|
||||
f"({device} is {device_state(device)})")
|
||||
except BaseException as error: # noqa: BLE001 - see the comment above
|
||||
say(f"the migration failed part way through: {error}")
|
||||
|
||||
@@ -398,6 +625,48 @@ def adopt(dry_run=False):
|
||||
return 1
|
||||
|
||||
|
||||
def put_interfaces_back():
|
||||
"""Undo this command's edit to /etc/network/interfaces.
|
||||
|
||||
Un-commenting the marked lines where they are, rather than copying
|
||||
the backup over the whole file. The backup is written once, on the
|
||||
first attempt, and never refreshed — so a whole-file restore silently
|
||||
reverts anything an administrator changed about the WIRED network in
|
||||
between. The marker names exactly the lines this command commented
|
||||
out, and nothing else in the file is touched.
|
||||
|
||||
The backup is still the fallback, for a file that has been replaced
|
||||
since rather than edited.
|
||||
"""
|
||||
backup = f"{INTERFACES}.before-ekklesia-classroom"
|
||||
|
||||
try:
|
||||
with open(INTERFACES, "r", encoding="utf-8") as handle:
|
||||
current = handle.read()
|
||||
except OSError as error:
|
||||
say(f"cannot read {INTERFACES}: {error}")
|
||||
current = None
|
||||
|
||||
if current is not None:
|
||||
restored, count = ifupdown.uncomment(current)
|
||||
|
||||
if count:
|
||||
with open(INTERFACES, "w", encoding="utf-8") as handle:
|
||||
handle.write(restored)
|
||||
|
||||
say(f"put {count} line{'' if count == 1 else 's'} back in "
|
||||
f"{INTERFACES}, and changed nothing else in it")
|
||||
return True
|
||||
|
||||
if os.path.exists(backup):
|
||||
shutil.copy2(backup, INTERFACES)
|
||||
say(f"no marked lines to restore; copied {backup} back instead")
|
||||
return True
|
||||
|
||||
say(f"nothing of this command's is in {INTERFACES} to undo")
|
||||
return False
|
||||
|
||||
|
||||
def restore(device, ssid=None):
|
||||
"""Put the interface back under ifupdown.
|
||||
|
||||
@@ -405,11 +674,7 @@ def restore(device, ssid=None):
|
||||
decide what that means for an exit code: for a failed migration it is
|
||||
still a failure, and for `--rollback` it is a success.
|
||||
"""
|
||||
backup = f"{INTERFACES}.before-ekklesia-classroom"
|
||||
|
||||
if os.path.exists(backup):
|
||||
shutil.copy2(backup, INTERFACES)
|
||||
say(f"restored {INTERFACES} from {backup}")
|
||||
put_interfaces_back()
|
||||
|
||||
nmcli("general", "reload", "conf", check=False)
|
||||
|
||||
@@ -418,7 +683,12 @@ def restore(device, ssid=None):
|
||||
|
||||
if device:
|
||||
nmcli("device", "set", device, "managed", "no", check=False)
|
||||
run("ifup", device)
|
||||
result = run("ifup", device)
|
||||
|
||||
if result is not None and result.returncode != 0:
|
||||
say(f"ifup {device}: "
|
||||
f"{(result.stderr or result.stdout or '').strip()}")
|
||||
|
||||
say(f"ifupdown has {device} again")
|
||||
|
||||
for _ in range(10):
|
||||
@@ -428,6 +698,44 @@ def restore(device, ssid=None):
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
# THE ADAPTER IS NEVER LEFT BELONGING TO NOBODY.
|
||||
#
|
||||
# Above, the device has deliberately been handed back to ifupdown and
|
||||
# NetworkManager told to leave it alone. That is right while ifupdown
|
||||
# can actually bring it up. When ifup has failed too, the same two
|
||||
# lines are a wireless adapter that neither manager owns: no
|
||||
# connection, and a panel that cannot even scan for one. That is the
|
||||
# state the prayer-room laptop was found in after 0.2.5, and it is
|
||||
# the state that made `nmcli device set wlp2s0 managed yes` a manual
|
||||
# recovery step.
|
||||
#
|
||||
# So the last resort is to give it to NetworkManager rather than to
|
||||
# leave it with neither. A room able to pick its network from the
|
||||
# panel is recoverable by somebody standing in front of it; an
|
||||
# unmanaged radio is not.
|
||||
if device:
|
||||
say("WARNING: ifupdown did not bring the network back either")
|
||||
say(f" handing {device} to NetworkManager so the adapter "
|
||||
"is not left unmanaged")
|
||||
|
||||
nmcli("device", "set", device, "managed", "yes", check=False)
|
||||
|
||||
if ssid:
|
||||
nmcli("connection", "up", ssid, "ifname", device, check=False)
|
||||
|
||||
for _ in range(5):
|
||||
if online():
|
||||
say("the room is on the network, through NetworkManager")
|
||||
say(f"NOTE: {INTERFACES} still describes {device}, so "
|
||||
"ifupdown will")
|
||||
say(" claim it again at the next boot. Decide which "
|
||||
"one owns it.")
|
||||
return True
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
say(f" {device} is {device_state(device)}")
|
||||
|
||||
say("WARNING: the machine may be offline. The original "
|
||||
f"{INTERFACES} is restored; try `ifup {device}` or a reboot")
|
||||
return False
|
||||
@@ -444,6 +752,8 @@ def main():
|
||||
help="remove a saved connection")
|
||||
parser.add_argument("--open", action="store_true",
|
||||
help="the network has no password")
|
||||
parser.add_argument("--hidden", action="store_true",
|
||||
help="the access point does not broadcast its name")
|
||||
parser.add_argument("--adopt", action="store_true",
|
||||
help="move an installer-configured Wi-Fi from "
|
||||
"ifupdown into NetworkManager")
|
||||
@@ -489,7 +799,8 @@ def main():
|
||||
say("no password given")
|
||||
return 1
|
||||
|
||||
return add(arguments.ssid, password, arguments.name)
|
||||
return add(arguments.ssid, password, arguments.name,
|
||||
hidden=arguments.hidden)
|
||||
|
||||
|
||||
if __name__ == "__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
|
||||
@@ -0,0 +1,49 @@
|
||||
[Unit]
|
||||
Description=Ekklesia Classroom presentation import
|
||||
Documentation=file:///usr/local/bin/classroom-presentation-import
|
||||
# Nothing graphical: it copies, converts and renders files. It is a user
|
||||
# service rather than a system one because the downloads it watches and
|
||||
# the library it fills both belong to the classroom account, and a root
|
||||
# service writing into a teacher's home would have to be told about every
|
||||
# one of those permissions.
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/classroom-presentation-import
|
||||
# It exits 0 immediately on a device with PRESENTATIONS_ENABLED=false, and
|
||||
# on-failure means such a device is left alone rather than restarted in a
|
||||
# loop forever. A device that does have Presentations gets the watcher
|
||||
# back if it ever dies, because a teacher whose download never appears has
|
||||
# no way at all to find out why.
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
Slice=session.slice
|
||||
# LibreOffice converting a large deck must never be what a teacher notices
|
||||
# about the machine. The room's own applications come first; this is the
|
||||
# only thing here that is allowed to take a while.
|
||||
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
|
||||
|
||||
@@ -85,8 +85,16 @@ check("a desktop can have Wonder Ink taken away",
|
||||
check("and keeps everything else",
|
||||
desktop_no_ink["DVD"] is True and desktop_no_ink["PARENT_CALL"] is True)
|
||||
|
||||
check("an existing room that has never heard of these keeps them all",
|
||||
all(module.wanted({}).values()), str(module.wanted({})))
|
||||
unset = module.wanted({})
|
||||
check("an existing room that has never heard of these keeps all three",
|
||||
unset["WONDERINK"] and unset["DVD"] and unset["PARENT_CALL"],
|
||||
str(unset))
|
||||
# The one that must NOT follow that rule. Every existing classroom has
|
||||
# Wonder Ink, a drive and Parent Call, so an upgrade has to leave all
|
||||
# three on. No existing classroom has Presentations, so an upgrade has to
|
||||
# leave it off — a new button on a bar nobody asked for is a support call.
|
||||
check("but Presentations is off until a device asks for it",
|
||||
unset["PRESENTATIONS"] is False, str(unset))
|
||||
|
||||
|
||||
print("3. a drive can only ever take the player away")
|
||||
@@ -126,13 +134,18 @@ check("and an unconfigured machine keeps every application",
|
||||
print("5. the bar that comes out, evaluated in node")
|
||||
STUB = r"""
|
||||
const fs = require("fs");
|
||||
const captured = {widgets: [], config: {}};
|
||||
const captured = {widgets: [], config: {}, launchers: []};
|
||||
let current = null;
|
||||
function makeWidget(type) {
|
||||
const w = {
|
||||
type: type,
|
||||
currentConfigGroup: [],
|
||||
writeConfig: function (k, v) { captured.config[type + "." + k] = String(v); },
|
||||
writeConfig: function (k, v) {
|
||||
captured.config[type + "." + k] = String(v);
|
||||
// Quick Launch widgets share a config key, so the last one
|
||||
// written would otherwise be the only one anybody could see.
|
||||
if (k === "launcherUrls") { captured.launchers.push(String(v)); }
|
||||
},
|
||||
reloadConfig: function () {}
|
||||
};
|
||||
captured.widgets.push(type);
|
||||
@@ -172,8 +185,15 @@ else:
|
||||
check("a classroom gets Wonder Ink, the browser and the player, in order",
|
||||
every and every["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}",
|
||||
(every or {}).get("config", {}).get(LAUNCH, why))
|
||||
CALL = "file:///usr/share/applications/ekklesia-parent-call.desktop"
|
||||
HELP = "file:///usr/share/applications/ekklesia-manual.desktop"
|
||||
|
||||
check("and Parent Call in the corner",
|
||||
every and "org.kde.plasma.quicklaunch" in every["widgets"])
|
||||
every and CALL in every["launchers"],
|
||||
str((every or {}).get("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"})
|
||||
check("a Bible study desktop has the browser first and no gap",
|
||||
@@ -188,8 +208,49 @@ else:
|
||||
bible and bible["config"].get(LAUNCH) == WEB,
|
||||
(bible or {}).get("config", {}).get(LAUNCH, why))
|
||||
check("with no Parent Call button on it",
|
||||
bible and "org.kde.plasma.quicklaunch" not in bible["widgets"],
|
||||
str((bible or {}).get("widgets")))
|
||||
bible and CALL not in bible["launchers"],
|
||||
str((bible or {}).get("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"
|
||||
|
||||
# THE DOCK ORDER IS THE THING NOT TO BREAK.
|
||||
#
|
||||
# Every existing room has Wonder Ink, the browser and the player in
|
||||
# that order, and teachers have been pressing them in that order for
|
||||
# months. Presentations is appended, so all three keep their place on
|
||||
# every machine that upgrades into this release.
|
||||
module.has_optical_drive = lambda: True
|
||||
slides, why = bar({"PRESENTATIONS_ENABLED": "true"})
|
||||
check("Presentations goes on the END of the row, after the player",
|
||||
slides and slides["config"].get(LAUNCH)
|
||||
== f"{INK},{WEB},{DISC},{SLIDES}",
|
||||
(slides or {}).get("config", {}).get(LAUNCH, why))
|
||||
|
||||
without, why = bar({"PRESENTATIONS_ENABLED": "false"})
|
||||
check("and is absent entirely when the device does not offer it",
|
||||
without and SLIDES not in without["config"].get(LAUNCH, ""),
|
||||
(without or {}).get("config", {}).get(LAUNCH, why))
|
||||
check("which leaves the other three exactly where they were",
|
||||
without and without["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}",
|
||||
(without or {}).get("config", {}).get(LAUNCH, why))
|
||||
|
||||
# The prayer-room laptop: the browser and Presentations, nothing else.
|
||||
module.has_optical_drive = lambda: False
|
||||
study, why = bar({"PRESENTATIONS_ENABLED": "true",
|
||||
"WONDERINK_ENABLED": "false",
|
||||
"PARENT_CALL_ENABLED": "false",
|
||||
"DEVICE_TYPE": "laptop"})
|
||||
check("the Bible study laptop gets the browser and Presentations",
|
||||
study and study["config"].get(LAUNCH) == f"{WEB},{SLIDES}",
|
||||
(study or {}).get("config", {}).get(LAUNCH, why))
|
||||
check("and still no Parent Call on it",
|
||||
study and CALL not in study["launchers"],
|
||||
str((study or {}).get("launchers")))
|
||||
|
||||
check("and the status corner is untouched",
|
||||
bible and "org.kde.plasma.volume" in bible["widgets"]
|
||||
and "org.kde.plasma.digitalclock" in bible["widgets"]
|
||||
@@ -227,12 +288,28 @@ print("7. a disabled application does not start either")
|
||||
# talked into running it by a stale desktop entry or a technician's
|
||||
# muscle memory.
|
||||
for script, setting in (("classroom-wonderink", "WONDERINK_ENABLED"),
|
||||
("classroom-parent-call", "PARENT_CALL_ENABLED")):
|
||||
("classroom-parent-call", "PARENT_CALL_ENABLED"),
|
||||
("classroom-presentations", "PRESENTATIONS_ENABLED"),
|
||||
("classroom-presentation-import",
|
||||
"PRESENTATIONS_ENABLED")):
|
||||
text = (root / "scripts" / script).read_text()
|
||||
check(f"{script} refuses when {setting} is false",
|
||||
setting in text and "not starting" in text)
|
||||
check(f"{script} defaults to starting, so existing rooms are unaffected",
|
||||
'default=True' in text or 'feature(' in text)
|
||||
check(f"{script} asks the shared helper rather than reading the file",
|
||||
"feature(" in text and "read_device_config()" in text)
|
||||
|
||||
# And each of them defaults the way its own room needs, which is not the
|
||||
# same answer for all four. Asserting one rule for all of them would pass
|
||||
# for the wrong reason on the one that differs.
|
||||
from classroom_config import feature as _feature # noqa: E402
|
||||
|
||||
for setting, default, why in (
|
||||
("WONDERINK_ENABLED", True, "every existing classroom has it"),
|
||||
("DVD_ENABLED", True, "every existing classroom has it"),
|
||||
("PARENT_CALL_ENABLED", True, "every existing classroom has it"),
|
||||
("PRESENTATIONS_ENABLED", False, "no existing classroom has it")):
|
||||
check(f"{setting} defaults to {default} — {why}",
|
||||
_feature({}, setting) is default)
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/bin/bash
|
||||
# The teacher's manual: one source, two versions, and no drift.
|
||||
#
|
||||
# Two halves, because the manual has two failure modes and they are not
|
||||
# alike. The source can be wrong — a page for an optional feature with
|
||||
# no conditional sentence makes the PRINTED manual state it
|
||||
# unconditionally, which is wrong in every room that does not have it.
|
||||
# And the built page can be wrong — it can show a teacher a feature
|
||||
# their computer does not have, or hide one it does, or carry something
|
||||
# out of device.conf that the classroom account has no business seeing.
|
||||
#
|
||||
# The second half runs the manual's own viewer script in node against a
|
||||
# stub DOM, the same way test-classroom-network.sh evaluates the polkit
|
||||
# rule: it is the actual shipped code, not a description of it.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
echo "A. the source, and the release gate"
|
||||
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
sys.path.insert(0, str(root / "lib"))
|
||||
|
||||
from classroom_config import FEATURES
|
||||
|
||||
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
|
||||
|
||||
|
||||
source = root / "docs" / "manual"
|
||||
build = source / "build"
|
||||
pages = sorted(p for p in source.glob("*.md") if p.name != "README.md")
|
||||
|
||||
check("there is a manual source directory", source.is_dir())
|
||||
check("with pages in it", len(pages) >= 5, str(len(pages)))
|
||||
|
||||
headers = {}
|
||||
|
||||
for path in pages:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
header = {}
|
||||
|
||||
if text.startswith("---\n"):
|
||||
end = text.index("\n---\n", 3)
|
||||
for line in text[4:end].splitlines():
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
header[key.strip()] = value.strip()
|
||||
|
||||
headers[path.name] = header
|
||||
|
||||
for name, header in headers.items():
|
||||
check(f"{name} has a title", bool(header.get("title")))
|
||||
|
||||
# THE RULE THAT KEEPS THE PRINTED MANUAL TRUE.
|
||||
for name, header in headers.items():
|
||||
if header.get("feature"):
|
||||
check(f"{name} says when it applies, for the printed manual",
|
||||
bool(header.get("printed-if")),
|
||||
"without it the printed manual states an optional feature "
|
||||
"as though every room had it")
|
||||
check(f"{name} names a real device.conf feature",
|
||||
header["feature"] in FEATURES,
|
||||
f"{header.get('feature')} is not one of "
|
||||
f"{', '.join(sorted(FEATURES))}")
|
||||
|
||||
documented = {h.get("feature") for h in headers.values() if h.get("feature")}
|
||||
|
||||
for name in FEATURES:
|
||||
check(f"{name} has a manual page", name in documented,
|
||||
"a room would get a button the manual never mentions")
|
||||
|
||||
orders = [h.get("order") for h in headers.values()]
|
||||
check("no two pages claim the same place", len(orders) == len(set(orders)),
|
||||
str(sorted(orders)))
|
||||
|
||||
print(" -- the builder itself")
|
||||
builder = root / "tools" / "build-manual"
|
||||
check("it exists and is runnable", builder.exists()
|
||||
and builder.stat().st_mode & 0o111)
|
||||
|
||||
result = subprocess.run([str(builder)], capture_output=True, text=True,
|
||||
timeout=120)
|
||||
check("it builds without complaint", result.returncode == 0,
|
||||
result.stderr.strip())
|
||||
check("it produced the digital manual",
|
||||
(build / "classroom-manual.html").exists())
|
||||
check("and the printable one", (build / "classroom-manual.pdf").exists())
|
||||
|
||||
pdf = (build / "classroom-manual.pdf").read_bytes()
|
||||
check("the PDF is a real PDF", pdf.startswith(b"%PDF-")
|
||||
and pdf.rstrip().endswith(b"%%EOF"), str(pdf[:8]))
|
||||
check("with more than one page", pdf.count(b"/Type /Page ") > 1,
|
||||
str(pdf.count(b"/Type /Page ")))
|
||||
check("and a revision date on it, so an old printout can be spotted",
|
||||
b"Revision" in pdf)
|
||||
|
||||
# Every conditional sentence has to actually reach the printed manual.
|
||||
for name, header in headers.items():
|
||||
if header.get("printed-if"):
|
||||
words = header["printed-if"].encode("latin-1", "replace")
|
||||
check(f"{name}'s conditional sentence is in the PDF",
|
||||
words in pdf, header["printed-if"])
|
||||
|
||||
check("the check mode passes on what was just built",
|
||||
subprocess.run([str(builder), "--check"], capture_output=True,
|
||||
timeout=120).returncode == 0)
|
||||
|
||||
html = (build / "classroom-manual.html").read_text(encoding="utf-8")
|
||||
|
||||
for name, header in headers.items():
|
||||
if header.get("feature"):
|
||||
check(f"{name} is marked in the HTML with its feature",
|
||||
f'data-feature="{header["feature"]}"' in html)
|
||||
|
||||
check("the digital manual needs nothing from the network",
|
||||
"http://" not in html and "https://" not in html,
|
||||
"it has to work in a building with no internet")
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
raise SystemExit(1 if failed else 0)
|
||||
PY
|
||||
|
||||
status=$?
|
||||
[[ $status -ne 0 ]] && exit $status
|
||||
|
||||
echo
|
||||
echo "B. what a device actually shows"
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo " SKIP node is not installed; the viewer script was not evaluated"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
node - "$ROOT" <<'JS'
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const root = process.argv[2];
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function check(label, condition, detail) {
|
||||
if (condition) { console.log(" ok " + label); passed++; }
|
||||
else { console.log(" FAIL " + label); if (detail) console.log(" " + detail); failed++; }
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(
|
||||
path.join(root, "docs/manual/build/classroom-manual.html"), "utf8");
|
||||
|
||||
// The shipped viewer, pulled out of the built page and run for real.
|
||||
const script = html.slice(html.lastIndexOf("<script>") + 8,
|
||||
html.lastIndexOf("</script>"));
|
||||
|
||||
// Enough DOM for it. Sections carry the feature they need, exactly as
|
||||
// the builder wrote them.
|
||||
function sectionsOf(text) {
|
||||
const found = [];
|
||||
const re = /<section id="([^"]+)"(?: data-feature="([^"]+)")?>/g;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
found.push({id: m[1], feature: m[2] || null});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function show(features) {
|
||||
const sections = sectionsOf(html).map(s => ({
|
||||
id: s.id,
|
||||
removed: false,
|
||||
getAttribute: function (name) {
|
||||
return name === "data-feature" ? s.feature : null;
|
||||
},
|
||||
remove: function () { this.removed = true; },
|
||||
querySelector: function () { return {textContent: s.id}; },
|
||||
}));
|
||||
const contents = {children: [], appendChild: function (c) { this.children.push(c); }};
|
||||
const window = {CLASSROOM_FEATURES: features};
|
||||
const document = {
|
||||
querySelectorAll: () => sections,
|
||||
getElementById: () => contents,
|
||||
createElement: () => ({}),
|
||||
};
|
||||
new Function("window", "document", script)(window, document);
|
||||
return sections.filter(s => !s.removed).map(s => s.id);
|
||||
}
|
||||
|
||||
const all = sectionsOf(html).map(s => s.id);
|
||||
const optional = sectionsOf(html).filter(s => s.feature);
|
||||
check("the built manual carries every page",
|
||||
all.length >= 5, all.join(", "));
|
||||
check("and some of them are optional", optional.length >= 3,
|
||||
optional.map(s => s.feature).join(", "));
|
||||
|
||||
console.log(" -- the Bible study laptop: Presentations, and nothing else optional");
|
||||
const laptop = show(["PRESENTATIONS_ENABLED"]);
|
||||
check("the Presentations page is shown",
|
||||
laptop.some(id => id.includes("presentations")), laptop.join(", "));
|
||||
check("the Browser page is shown, because every room has one",
|
||||
laptop.some(id => id.includes("browser")));
|
||||
check("sound and volume are shown", laptop.some(id => id.includes("sound")));
|
||||
check("Wonder Ink is NOT shown",
|
||||
!laptop.some(id => id.includes("wonderink")), laptop.join(", "));
|
||||
check("Parent Call is NOT shown",
|
||||
!laptop.some(id => id.includes("parent-call")), laptop.join(", "));
|
||||
check("the DVD page is NOT shown",
|
||||
!laptop.some(id => id.includes("dvd")), laptop.join(", "));
|
||||
|
||||
console.log(" -- a children's classroom: everything but Presentations");
|
||||
const classroom = show(["WONDERINK_ENABLED", "PARENT_CALL_ENABLED", "DVD_ENABLED"]);
|
||||
check("Wonder Ink is shown", classroom.some(id => id.includes("wonderink")));
|
||||
check("Parent Call is shown", classroom.some(id => id.includes("parent-call")));
|
||||
check("the DVD page is shown", classroom.some(id => id.includes("dvd")));
|
||||
check("Presentations is NOT shown",
|
||||
!classroom.some(id => id.includes("presentations")), classroom.join(", "));
|
||||
|
||||
console.log(" -- a device that could not say what it has");
|
||||
// No manifest: show everything, which is the printed manual and is never
|
||||
// wrong, only less specific.
|
||||
const unknown = show(null);
|
||||
check("every page is shown rather than none",
|
||||
unknown.length === all.length, unknown.length + " of " + all.length);
|
||||
|
||||
console.log(" -- nothing optional at all");
|
||||
const bare = show([]);
|
||||
check("only the common pages remain",
|
||||
bare.length === all.length - optional.length,
|
||||
bare.join(", "));
|
||||
|
||||
console.log(" -- what the page is allowed to know about the device");
|
||||
const manual = fs.readFileSync(path.join(root, "scripts/classroom-manual"), "utf8");
|
||||
check("the manual is handed a list of setting NAMES, not device.conf",
|
||||
/window\.CLASSROOM_FEATURES/.test(manual)
|
||||
&& /json\.dumps\(sorted\(features\)\)/.test(manual));
|
||||
check("and the names are filtered against the features the package knows",
|
||||
/name for name in listed if name in FEATURES/.test(manual),
|
||||
"a stale manifest must not be able to name anything else");
|
||||
const layout = fs.readFileSync(path.join(root, "scripts/classroom-desktop-layout"), "utf8");
|
||||
check("the manifest is written from the enabled features alone",
|
||||
/setting for variable, setting in FEATURE_LINES if answers\.get\(variable\)/.test(layout));
|
||||
check("no value from device.conf is written into it",
|
||||
!/read_device_config\(\)\[/.test(layout));
|
||||
check("it lives in the runtime directory, so it cannot go stale",
|
||||
/\/run\/ekklesia-classroom\/features\.json/.test(layout));
|
||||
|
||||
console.log();
|
||||
console.log(passed + " passed, " + failed + " failed");
|
||||
process.exit(failed ? 1 : 0);
|
||||
JS
|
||||
@@ -166,7 +166,15 @@ check("it refuses to start when it could not undo itself",
|
||||
"shutil.which" in wifi
|
||||
and "leaving the network exactly as it is" in wifi)
|
||||
|
||||
print("9. the upgrade does not do any of this on its own")
|
||||
print("9. a machine with no ifupdown configuration at all")
|
||||
# A container, and any machine installed without ifupdown. Found by the
|
||||
# release validator: --check said nothing at all, because a missing file
|
||||
# was being treated as a read error.
|
||||
check("a missing interfaces file is not an error, it is just nothing to do",
|
||||
"not os.path.exists(INTERFACES)" in wifi
|
||||
and 'return None, ""' in wifi)
|
||||
|
||||
print("10. the upgrade does not do any of this on its own")
|
||||
# Moving a machine's only link during an unattended upgrade is fine until
|
||||
# the once it is not, and the machine it would strand is a laptop in a
|
||||
# locked room.
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
#!/bin/bash
|
||||
# The Presentations library: what it imports, what it refuses, what it
|
||||
# keeps and what it is never allowed to delete.
|
||||
#
|
||||
# No LibreOffice and no poppler here. Those are exercised by the lab and
|
||||
# by the release validator against a real Debian; this is the part that
|
||||
# has to be right whatever is installed — when a download counts as
|
||||
# finished, whose files may be touched, what a teacher's choice of
|
||||
# thumbnail does and does not change, and which of the two retention
|
||||
# clocks is running.
|
||||
set -uo pipefail
|
||||
|
||||
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])
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
work = Path(tempfile.mkdtemp(prefix="classroom-presentations-"))
|
||||
downloads = work / "Downloads"
|
||||
wonderink = downloads / "Wonder Ink"
|
||||
shelf = work / "library"
|
||||
wonderink.mkdir(parents=True)
|
||||
shelf.mkdir(parents=True)
|
||||
|
||||
|
||||
def download(name, contents="x" * 400, age=60):
|
||||
"""A file sitting in Downloads, finished `age` seconds ago."""
|
||||
path = downloads / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(contents)
|
||||
when = time.time() - age
|
||||
os.utime(path, (when, when))
|
||||
return path
|
||||
|
||||
|
||||
def entry(**data):
|
||||
"""A presentation in the library, without running an import."""
|
||||
directory = shelf / data.get("id", "one")
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
base = {
|
||||
"id": directory.name,
|
||||
"state": "ready",
|
||||
"title": "A session",
|
||||
"pages": 10,
|
||||
"thumbnail_page": 1,
|
||||
"imported_at": library.stamp(),
|
||||
"last_presented_at": None,
|
||||
"times_presented": 0,
|
||||
}
|
||||
base.update(data)
|
||||
presentation = library.Presentation(directory, base)
|
||||
presentation.save()
|
||||
(directory / "slides.pdf").write_text("%PDF-1.4")
|
||||
return presentation
|
||||
|
||||
|
||||
print("1. a download that has finished, and ones that have not")
|
||||
done = download("Session 7 - Romans.pptx")
|
||||
check("a settled PowerPoint is importable", library.importable(done))
|
||||
|
||||
arriving = download("Still arriving.pptx")
|
||||
partial = downloads / "Still arriving.pptx.crdownload"
|
||||
partial.write_text("half")
|
||||
check("one with a .crdownload beside it is not",
|
||||
not library.importable(arriving),
|
||||
"Chrome renames the .crdownload away when the transfer completes")
|
||||
|
||||
check("and the .crdownload itself is never a candidate",
|
||||
not library.importable(partial))
|
||||
|
||||
fresh = download("Just landed.pdf", age=0)
|
||||
check("one still being written is not",
|
||||
not library.importable(fresh),
|
||||
"a file copied from a USB stick has no .crdownload to go by")
|
||||
|
||||
check("something that is not a presentation is not",
|
||||
not library.importable(download("notes.txt")))
|
||||
|
||||
empty = download("Nothing.pdf", contents="", age=60)
|
||||
check("a nought-byte file is not", not library.importable(empty))
|
||||
|
||||
print("2. Wonder Ink's worksheets are not presentations")
|
||||
worksheet = wonderink / "Colouring page.pdf"
|
||||
worksheet.write_text("%PDF-1.4" + "x" * 400)
|
||||
os.utime(worksheet, (time.time() - 60, time.time() - 60))
|
||||
check("a file in the Wonder Ink folder is recognised as Wonder Ink's",
|
||||
library.within_wonderink(worksheet, downloads))
|
||||
check("and one beside it in Downloads is not",
|
||||
not library.within_wonderink(done, downloads))
|
||||
|
||||
names = [p.name for p in library.candidates(downloads)]
|
||||
check("so it is not offered for import", "Colouring page.pdf" not in names,
|
||||
str(names))
|
||||
check("the finished session is", "Session 7 - Romans.pptx" in names, str(names))
|
||||
check("and nothing unfinished is",
|
||||
"Still arriving.pptx" not in names and "Just landed.pdf" not in names,
|
||||
str(names))
|
||||
|
||||
print("3. titles a teacher would recognise")
|
||||
check("an ordinary name is kept",
|
||||
library.readable_title("Session 7 - Romans.pptx") == "Session 7 - Romans",
|
||||
library.readable_title("Session 7 - Romans.pptx"))
|
||||
check("underscores become spaces",
|
||||
library.readable_title("Session_7_Romans.pdf") == "Session 7 Romans",
|
||||
library.readable_title("Session_7_Romans.pdf"))
|
||||
check("a browser's duplicate counter goes",
|
||||
library.readable_title("Bible Study (1).pdf") == "Bible Study",
|
||||
library.readable_title("Bible Study (1).pdf"))
|
||||
check("a percent-escaped space is decoded",
|
||||
library.readable_title("Session%207.pdf") == "Session 7",
|
||||
library.readable_title("Session%207.pdf"))
|
||||
hashed = library.readable_title(
|
||||
"a4f39c2e91bb4d7e.pdf", "Session 7 of the autumn study\nmore")
|
||||
check("a hash falls back to the document's own first words",
|
||||
hashed == "Session 7 of the autumn study", hashed)
|
||||
check("and something with no name at all still gets one",
|
||||
library.readable_title("...pdf", "") == "Presentation",
|
||||
library.readable_title("...pdf", ""))
|
||||
|
||||
print("4. two downloads of the same name are two presentations")
|
||||
first = entry(id="first", title="Session 7")
|
||||
second = entry(id="second", title="Session 7")
|
||||
# The bug this replaces: excluding the import being named BY TITLE also
|
||||
# removed the other presentation's identical title from the set, so a
|
||||
# PDF and a PPTX of one session both came out called "Session 7".
|
||||
taken = library.titles_in_use(shelf, exclude="second")
|
||||
check("the other presentation's title is still in the way",
|
||||
"Session 7" in taken, str(taken))
|
||||
check("so the second gets a title of its own",
|
||||
library.unique_title("Session 7", taken) == "Session 7 (2)",
|
||||
library.unique_title("Session 7", taken))
|
||||
check("and a name nothing else has is left alone",
|
||||
library.unique_title("Session 8", taken) == "Session 8")
|
||||
shutil.rmtree(shelf / "first")
|
||||
shutil.rmtree(shelf / "second")
|
||||
|
||||
print("5. the thumbnail is the teacher's choice, and only that")
|
||||
# The first slide of a Bible study session is a generic opening shared by
|
||||
# every session; the one that names the session is usually the second.
|
||||
p = entry(id="thumb", pages=12)
|
||||
before = dict(p.data)
|
||||
p.set_thumbnail_page(2)
|
||||
after = library.load(shelf / "thumb")
|
||||
check("the chosen slide is remembered", after.thumbnail_page == 2,
|
||||
str(after.thumbnail_page))
|
||||
check("it survives being read back from disk, so it survives a restart",
|
||||
library.load(shelf / "thumb").thumbnail_page == 2)
|
||||
check("choosing it did not mark the session as presented",
|
||||
after.last_presented_at is None and after.times_presented == 0)
|
||||
check("and did not move the retention clock",
|
||||
after.data["imported_at"] == before["imported_at"]
|
||||
and after.data.get("last_presented_at") is None)
|
||||
check("a slide past the end is clamped rather than stored",
|
||||
entry(id="thumb2", pages=5, thumbnail_page=99).thumbnail_page == 5)
|
||||
check("and a nonsense one does not crash the card",
|
||||
entry(id="thumb3", pages=5, thumbnail_page="banana").thumbnail_page == 1)
|
||||
|
||||
print("6. the two retention clocks")
|
||||
old = library.now() - timedelta(days=181)
|
||||
recent = library.now() - timedelta(days=100)
|
||||
|
||||
never = entry(id="never", imported_at=library.stamp(old))
|
||||
check("never presented, imported over 180 days ago, is expired",
|
||||
never.expired(), str(never.expires_at()))
|
||||
|
||||
young = entry(id="young", imported_at=library.stamp(recent))
|
||||
check("never presented but only 100 days old is not",
|
||||
not young.expired(), str(young.expires_at()))
|
||||
|
||||
shown = entry(id="shown",
|
||||
imported_at=library.stamp(old),
|
||||
last_presented_at=library.stamp(library.now() - timedelta(days=2)),
|
||||
times_presented=3)
|
||||
check("presented two days ago is kept, however old the import is",
|
||||
not shown.expired(),
|
||||
"presenting has to start the 30-day clock and stop the 180-day one")
|
||||
|
||||
stale = entry(id="stale",
|
||||
imported_at=library.stamp(old),
|
||||
last_presented_at=library.stamp(library.now() - timedelta(days=31)),
|
||||
times_presented=1)
|
||||
check("presented 31 days ago is expired", stale.expired())
|
||||
|
||||
print("7. presenting is the only thing that resets the clock")
|
||||
p = entry(id="counted", pages=4)
|
||||
check("a fresh import has never been presented",
|
||||
p.last_presented_at is None and p.times_presented == 0)
|
||||
p.mark_presented()
|
||||
reread = library.load(shelf / "counted")
|
||||
check("presenting is recorded", reread.last_presented_at is not None)
|
||||
check("and counted", reread.times_presented == 1)
|
||||
first_due = reread.expires_at()
|
||||
time.sleep(1.1)
|
||||
reread.mark_presented()
|
||||
again = library.load(shelf / "counted")
|
||||
check("presenting again starts the thirty days over",
|
||||
again.expires_at() > first_due, f"{first_due} -> {again.expires_at()}")
|
||||
check("and counts again", again.times_presented == 2)
|
||||
|
||||
print("8. an import that was interrupted is not shown to a teacher")
|
||||
half = entry(id="half", state="importing")
|
||||
check("it is in the library's own list",
|
||||
any(x.id == "half" for x in library.library(shelf)))
|
||||
check("but it is not ready, so no card offers Present",
|
||||
not library.load(shelf / "half").ready)
|
||||
broken = shelf / "broken"
|
||||
broken.mkdir()
|
||||
(broken / "presentation.json").write_text("{ this is not json")
|
||||
check("a truncated metadata file is skipped rather than crashing the library",
|
||||
all(x.id != "broken" for x in library.library(shelf)),
|
||||
"a card a teacher cannot open is worse than no card")
|
||||
|
||||
print("9. the presenting lock cannot outlive a reboot")
|
||||
check("it lives in the runtime directory",
|
||||
"/run" in str(library.presenting_lock())
|
||||
or str(library.presenting_lock()).startswith("/tmp"),
|
||||
str(library.presenting_lock()))
|
||||
|
||||
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 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()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
raise SystemExit(1 if failed else 0)
|
||||
PY
|
||||
@@ -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,411 @@
|
||||
#!/bin/bash
|
||||
# The Wi-Fi hand-over, driven end to end against stubbed tools.
|
||||
#
|
||||
# tests/test-classroom-network-adopt.sh covers the parsing. This covers
|
||||
# the part that actually failed on the prayer-room laptop during the
|
||||
# 0.2.5 setup: the ORDER of the hand-over, and what is carried across
|
||||
# with the network.
|
||||
#
|
||||
# The laptop's own configuration was
|
||||
#
|
||||
# allow-hotplug wlp2s0
|
||||
# iface wlp2s0 inet dhcp
|
||||
# wpa-ssid "ROGC Classroom"
|
||||
# wpa-psk <secret>
|
||||
# wpa-scan-ssid 1
|
||||
#
|
||||
# and the migration produced a NetworkManager connection with
|
||||
# 802-11-wireless.hidden: no, which can never associate with an access
|
||||
# point that does not broadcast its name. It also ran `ifdown` after the
|
||||
# stanza had been commented out, where ifdown has nothing left to read
|
||||
# and does nothing, so ifupdown's own supplicant was still holding the
|
||||
# radio. Then the rollback left the adapter unmanaged.
|
||||
#
|
||||
# nmcli, ifup, ifdown and curl are stubs that record what they were
|
||||
# asked and can be told to fail, so the sequence is asserted rather than
|
||||
# described. No real interface, no real NetworkManager, no laptop.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
python3 - "$ROOT" <<'PY'
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
|
||||
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 load():
|
||||
"""classroom-wifi as a module. It has no .py, so name the loader."""
|
||||
path = root / "scripts" / "classroom-wifi"
|
||||
loader = importlib.machinery.SourceFileLoader("classroom_wifi", str(path))
|
||||
spec = importlib.util.spec_from_loader("classroom_wifi", loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
# The laptop's file, with the hidden network and an SSID with a space in
|
||||
# it. Also a wired stanza, which nothing here may touch.
|
||||
LAPTOP = """# This file describes the network interfaces available on your system
|
||||
source /etc/network/interfaces.d/*
|
||||
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
auto enp3s0
|
||||
iface enp3s0 inet static
|
||||
\taddress 10.40.0.90/24
|
||||
\tgateway 10.40.0.1
|
||||
|
||||
allow-hotplug wlp2s0
|
||||
iface wlp2s0 inet dhcp
|
||||
\twpa-ssid "ROGC Classroom"
|
||||
\twpa-psk s#cret-with-a-hash
|
||||
\twpa-scan-ssid 1
|
||||
"""
|
||||
|
||||
BROADCAST = """allow-hotplug wlp2s0
|
||||
iface wlp2s0 inet dhcp
|
||||
\twpa-ssid Sanctuary
|
||||
\twpa-psk ordinary-secret
|
||||
"""
|
||||
|
||||
ENTERPRISE = """allow-hotplug wlp2s0
|
||||
iface wlp2s0 inet dhcp
|
||||
\twpa-ssid Campus
|
||||
\twpa-key-mgmt WPA-EAP
|
||||
"""
|
||||
|
||||
STUB = r"""#!/bin/bash
|
||||
# Records the call, and the state of the interfaces file at the moment of
|
||||
# it, so the test can assert ordering rather than trust it.
|
||||
printf '%s' "$(basename "$0")" >> "$LOG"
|
||||
for argument in "$@"; do printf ' %s' "$argument" >> "$LOG"; done
|
||||
printf '\n' >> "$LOG"
|
||||
|
||||
if [[ -f "$INTERFACES" ]]; then
|
||||
cp "$INTERFACES" "$SNAPSHOTS/$(basename "$0").$$.interfaces"
|
||||
fi
|
||||
|
||||
case "$(basename "$0")" in
|
||||
nmcli)
|
||||
case "$*" in
|
||||
*"-f NAME,UUID,TYPE connection show"*) cat "$FAKE_CONNECTIONS" ;;
|
||||
*"-f NAME,FILENAME connection show"*) cat "$FAKE_FILENAMES" ;;
|
||||
*"connection show --active"*) cat "$FAKE_ACTIVE" ;;
|
||||
*"device status"*) cat "$FAKE_DEVICES" ;;
|
||||
*"networking connectivity"*) cat "$FAKE_CONNECTIVITY" ;;
|
||||
*"connection up"*) exit "${UP_STATUS:-0}" ;;
|
||||
esac
|
||||
exit 0
|
||||
;;
|
||||
ifdown) exit "${IFDOWN_STATUS:-0}" ;;
|
||||
ifup) exit "${IFUP_STATUS:-0}" ;;
|
||||
curl) exit "${CURL_STATUS:-0}" ;;
|
||||
esac
|
||||
exit 0
|
||||
"""
|
||||
|
||||
|
||||
class Rig:
|
||||
"""A machine mid-migration, with every external tool stubbed."""
|
||||
|
||||
def __init__(self, interfaces_text, **environment):
|
||||
self.directory = Path(tempfile.mkdtemp(prefix="classroom-wifi-"))
|
||||
self.bin = self.directory / "bin"
|
||||
self.snapshots = self.directory / "snapshots"
|
||||
self.bin.mkdir()
|
||||
self.snapshots.mkdir()
|
||||
|
||||
self.interfaces = self.directory / "interfaces"
|
||||
self.interfaces.write_text(interfaces_text)
|
||||
|
||||
self.log = self.directory / "calls.log"
|
||||
self.log.write_text("")
|
||||
|
||||
for tool in ("nmcli", "ifdown", "ifup", "curl"):
|
||||
target = self.bin / tool
|
||||
target.write_text(STUB)
|
||||
target.chmod(0o755)
|
||||
|
||||
# What the stubbed nmcli answers with. Written as files so a test
|
||||
# can change an answer partway through if it needs to.
|
||||
self.answers = {
|
||||
"FAKE_CONNECTIONS": "",
|
||||
"FAKE_FILENAMES": "",
|
||||
"FAKE_ACTIVE": "ROGC Classroom:wlp2s0\n",
|
||||
"FAKE_DEVICES": "wlp2s0:connected\n",
|
||||
"FAKE_CONNECTIVITY": "full\n",
|
||||
}
|
||||
|
||||
self.environment = {
|
||||
"LOG": str(self.log),
|
||||
"INTERFACES": str(self.interfaces),
|
||||
"SNAPSHOTS": str(self.snapshots),
|
||||
}
|
||||
|
||||
for key, value in self.answers.items():
|
||||
path = self.directory / key
|
||||
path.write_text(value)
|
||||
self.environment[key] = str(path)
|
||||
|
||||
self.environment.update({k: str(v) for k, v in environment.items()})
|
||||
|
||||
def answer(self, key, value):
|
||||
Path(self.environment[key]).write_text(value)
|
||||
|
||||
def run(self, action, **kwargs):
|
||||
saved_path = os.environ.get("PATH", "")
|
||||
saved = {k: os.environ.get(k) for k in self.environment}
|
||||
os.environ["PATH"] = f"{self.bin}:{saved_path}"
|
||||
os.environ.update(self.environment)
|
||||
|
||||
module = load()
|
||||
module.INTERFACES = str(self.interfaces)
|
||||
# The poll loops are real time; nothing here needs to wait.
|
||||
module.time.sleep = lambda _seconds: None
|
||||
module.ADOPT_TIMEOUT = 0.2
|
||||
|
||||
try:
|
||||
return getattr(module, action)(**kwargs), module
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
for key, value in saved.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
@property
|
||||
def calls(self):
|
||||
return self.log.read_text().splitlines()
|
||||
|
||||
def snapshot_at(self, tool):
|
||||
"""The interfaces file as it was when `tool` ran."""
|
||||
for path in sorted(self.snapshots.glob(f"{tool}.*.interfaces")):
|
||||
return path.read_text()
|
||||
return None
|
||||
|
||||
def clean(self):
|
||||
shutil.rmtree(self.directory, ignore_errors=True)
|
||||
|
||||
|
||||
print("1. the hidden network survives the hand-over")
|
||||
# The 0.2.5 regression itself. wpa-scan-ssid 1 was sitting in the parsed
|
||||
# options and the only accessor threw it away.
|
||||
rig = Rig(LAPTOP)
|
||||
status, module = rig.run("adopt")
|
||||
added = [c for c in rig.calls if c.startswith("nmcli connection add")]
|
||||
check("the migration reports success", status == 0, str(status))
|
||||
check("a connection is created", len(added) == 1, str(rig.calls))
|
||||
check("and it is told the access point does not broadcast its name",
|
||||
added and "802-11-wireless.hidden yes" in added[0],
|
||||
added[0] if added else "no connection was added")
|
||||
check("the SSID keeps its space",
|
||||
added and "ssid ROGC Classroom" in added[0], added[0] if added else "")
|
||||
check("it is still a system connection, with no permissions",
|
||||
added and "connection.permissions" not in added[0])
|
||||
check("the secret is stored in the connection file",
|
||||
added and "802-11-wireless-security.psk-flags 0" in added[0])
|
||||
check("the password is not truncated at the # inside it",
|
||||
added and "wifi-sec.psk s#cret-with-a-hash" in added[0],
|
||||
added[0] if added else "")
|
||||
rig.clean()
|
||||
|
||||
print("2. the interface is released BEFORE its stanza is taken away")
|
||||
# ifdown reads /etc/network/interfaces to know how to bring an interface
|
||||
# down. Run after the stanza is commented out it reports "Ignoring
|
||||
# unknown interface" and does nothing, so ifupdown's wpa_supplicant kept
|
||||
# the radio and NetworkManager could not have it.
|
||||
rig = Rig(LAPTOP)
|
||||
rig.run("adopt")
|
||||
order = [c.split()[0] for c in rig.calls]
|
||||
at_ifdown = rig.snapshot_at("ifdown")
|
||||
check("ifdown is called at all", "ifdown" in order, str(order))
|
||||
check("the stanza is still live in the file when ifdown runs",
|
||||
at_ifdown is not None
|
||||
and "\niface wlp2s0 inet dhcp\n" in at_ifdown,
|
||||
"ifdown ran against a file whose stanza was already commented out")
|
||||
check("and the file is commented out afterwards",
|
||||
"# ekklesia-classroom: iface wlp2s0" in rig.interfaces.read_text())
|
||||
check("NetworkManager is only told to manage it after the file changed",
|
||||
order.index("ifdown") < len(order) - 1)
|
||||
managed = [c for c in rig.calls if "device set" in c]
|
||||
check("the device is explicitly handed to NetworkManager",
|
||||
any("managed yes" in c for c in managed), str(managed))
|
||||
up = [c for c in rig.calls if "connection up" in c]
|
||||
check("the connection is brought up on that interface by name",
|
||||
up and "ifname wlp2s0" in up[0], str(up))
|
||||
rig.clean()
|
||||
|
||||
print("3. an ordinary broadcast network is not marked hidden")
|
||||
# The flag has to be an answer in both directions, not something only
|
||||
# ever switched on.
|
||||
rig = Rig(BROADCAST)
|
||||
rig.run("adopt")
|
||||
added = [c for c in rig.calls if c.startswith("nmcli connection add")]
|
||||
check("hidden is written as no", added and "802-11-wireless.hidden no" in added[0],
|
||||
added[0] if added else "")
|
||||
rig.clean()
|
||||
|
||||
print("4. a failed hand-over puts the file back, and only the file")
|
||||
# The backup is written once and never refreshed, so a whole-file restore
|
||||
# would silently revert a wired change made in between. The rollback
|
||||
# un-comments its own marker instead.
|
||||
rig = Rig(LAPTOP, UP_STATUS="1", FAKE_CONNECTIVITY="none\n", CURL_STATUS="1")
|
||||
status, _ = rig.run("adopt")
|
||||
after = rig.interfaces.read_text()
|
||||
check("the migration reports failure", status == 1, str(status))
|
||||
check("the wireless stanza is live again",
|
||||
"\niface wlp2s0 inet dhcp\n" in after, after)
|
||||
check("the allow-hotplug line came back too",
|
||||
"\nallow-hotplug wlp2s0\n" in after)
|
||||
check("nothing of the marker is left behind",
|
||||
"ekklesia-classroom:" not in after, after)
|
||||
check("the file is byte for byte what it was", after == LAPTOP,
|
||||
"restored file differs from the original")
|
||||
check("the wired stanza was never touched",
|
||||
"\taddress 10.40.0.90/24\n" in after)
|
||||
rig.clean()
|
||||
|
||||
print("5. a rollback preserves an unrelated change made since")
|
||||
# The case a whole-file copy gets wrong: the wired address is corrected
|
||||
# after the migration, and then the migration fails.
|
||||
rig = Rig(LAPTOP, UP_STATUS="1", FAKE_CONNECTIVITY="none\n", CURL_STATUS="1")
|
||||
|
||||
|
||||
def edit_between(module):
|
||||
original = module.put_interfaces_back
|
||||
|
||||
def wrapped():
|
||||
text = rig.interfaces.read_text()
|
||||
rig.interfaces.write_text(
|
||||
text.replace("10.40.0.90/24", "10.40.0.91/24"))
|
||||
return original()
|
||||
|
||||
module.put_interfaces_back = wrapped
|
||||
|
||||
|
||||
saved_run = Rig.run
|
||||
|
||||
|
||||
def run_with_edit(self, action, **kwargs):
|
||||
saved_path = os.environ.get("PATH", "")
|
||||
os.environ["PATH"] = f"{self.bin}:{saved_path}"
|
||||
os.environ.update(self.environment)
|
||||
module = load()
|
||||
module.INTERFACES = str(self.interfaces)
|
||||
module.time.sleep = lambda _seconds: None
|
||||
module.ADOPT_TIMEOUT = 0.2
|
||||
edit_between(module)
|
||||
try:
|
||||
return getattr(module, action)(**kwargs), module
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
|
||||
|
||||
run_with_edit(rig, "adopt")
|
||||
after = rig.interfaces.read_text()
|
||||
check("the corrected wired address survived the rollback",
|
||||
"10.40.0.91/24" in after, after)
|
||||
check("and the wireless stanza is live again",
|
||||
"\niface wlp2s0 inet dhcp\n" in after)
|
||||
rig.clean()
|
||||
|
||||
print("6. a failed hand-over never leaves the adapter unmanaged")
|
||||
# The state the prayer-room laptop was found in: ifupdown had been told
|
||||
# to take the interface back, ifup failed too, and NetworkManager had
|
||||
# been told to leave it alone. Nobody owned the radio, and recovering it
|
||||
# needed `nmcli device set wlp2s0 managed yes` typed by hand.
|
||||
rig = Rig(LAPTOP, UP_STATUS="1", IFUP_STATUS="1",
|
||||
FAKE_CONNECTIVITY="none\n", CURL_STATUS="1")
|
||||
status, _ = rig.run("adopt")
|
||||
# Only what the rollback did. The hand-over itself sets managed yes on
|
||||
# the way in, which is not the question here.
|
||||
rollback_calls = rig.calls[
|
||||
max(index for index, call in enumerate(rig.calls)
|
||||
if call.startswith("ifup ")):]
|
||||
sets = [c for c in rollback_calls if "device set" in c]
|
||||
check("ifupdown is offered the interface first",
|
||||
any("managed no" in c for c in rig.calls), str(rig.calls))
|
||||
check("and when ifup fails too it is given to NetworkManager",
|
||||
sets and "managed yes" in sets[-1],
|
||||
"the last word on the adapter was 'managed no', which owns nothing")
|
||||
check("so the adapter is never left belonging to nobody",
|
||||
"managed yes" in rig.calls[-1] or any("managed yes" in c
|
||||
for c in sets),
|
||||
str(rollback_calls))
|
||||
check("and the migration still reports failure", status == 1, str(status))
|
||||
rig.clean()
|
||||
|
||||
print("7. a network this cannot recreate is refused before anything moves")
|
||||
# WPA-Enterprise has no pre-shared key to carry across. Guessing would
|
||||
# take the interface away from ifupdown to find that out.
|
||||
rig = Rig(ENTERPRISE)
|
||||
status, _ = rig.run("adopt")
|
||||
check("it refuses", status == 1, str(status))
|
||||
check("nothing was added", not any("connection add" in c for c in rig.calls),
|
||||
str(rig.calls))
|
||||
check("the file is untouched", rig.interfaces.read_text() == ENTERPRISE)
|
||||
check("ifdown was never run", not any(c.startswith("ifdown")
|
||||
for c in rig.calls), str(rig.calls))
|
||||
rig.clean()
|
||||
|
||||
print("8. a machine already migrated is not migrated again")
|
||||
# The prayer-room laptop, after tomorrow. Its stanza is gone, so there is
|
||||
# nothing to adopt and --adopt must be a no-op rather than an error.
|
||||
MIGRATED = LAPTOP.replace("allow-hotplug wlp2s0",
|
||||
"# ekklesia-classroom: allow-hotplug wlp2s0")
|
||||
MIGRATED = "\n".join(
|
||||
("# ekklesia-classroom: " + line)
|
||||
if line.startswith("iface wlp2s0") or line.startswith("\twpa-")
|
||||
else line
|
||||
for line in MIGRATED.splitlines()
|
||||
) + "\n"
|
||||
rig = Rig(MIGRATED)
|
||||
status, _ = rig.run("adopt")
|
||||
check("it reports nothing to do, and succeeds", status == 0, str(status))
|
||||
check("no connection was created",
|
||||
not any("connection add" in c for c in rig.calls), str(rig.calls))
|
||||
check("nothing was brought down", not any(c.startswith("ifdown")
|
||||
for c in rig.calls))
|
||||
check("the file is untouched", rig.interfaces.read_text() == MIGRATED)
|
||||
rig.clean()
|
||||
|
||||
print("9. --check changes nothing at all")
|
||||
rig = Rig(LAPTOP)
|
||||
status, _ = rig.run("check")
|
||||
check("it succeeds", status == 0)
|
||||
check("the file is untouched", rig.interfaces.read_text() == LAPTOP)
|
||||
check("it runs no command that could change anything",
|
||||
not any(c.startswith(("ifup", "ifdown")) for c in rig.calls),
|
||||
str(rig.calls))
|
||||
check("and no nmcli that writes",
|
||||
not any("connection add" in c or "connection modify" in c
|
||||
or "device set" in c for c in rig.calls), str(rig.calls))
|
||||
rig.clean()
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
raise SystemExit(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,33 +197,112 @@ 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()
|
||||
print("5. bounded Trixie validation")
|
||||
check("container has an outer deadline", "720s" in validation_script)
|
||||
check("package installs have deadlines", "candidate-install 360" in validation_script)
|
||||
# THE PROPERTY, NOT THE NUMBER.
|
||||
#
|
||||
# These used to pin "720s" and "candidate-install 360" exactly. When
|
||||
# 0.2.6 added LibreOffice the budgets had to grow, and the outer-deadline
|
||||
# check went on passing anyway — because the commit message explaining
|
||||
# the change still contained the string "720s" in a comment. A test that
|
||||
# a comment can satisfy is not testing anything. What matters is that
|
||||
# nothing in the gate runs unbounded.
|
||||
import re as _re
|
||||
|
||||
outer = _re.search(r"timeout --signal=TERM --kill-after=\S+ (\d+)s", validation_script)
|
||||
check("container has an outer deadline", outer is not None)
|
||||
check("and it is a real one, not a token",
|
||||
outer is not None and int(outer.group(1)) >= 600)
|
||||
|
||||
installs = _re.findall(r"run_logged (\S+-install\S*) (\d+) ", validation_script)
|
||||
check("every package install has a deadline", len(installs) >= 2)
|
||||
check("and each is long enough to install a desktop",
|
||||
all(int(seconds) >= 600 for _name, seconds in installs))
|
||||
check("nothing in the gate runs unbounded",
|
||||
"run_logged" in validation_script
|
||||
and validation_script.count("run_logged") >= 4)
|
||||
check("container service startup is suppressed", "/usr/sbin/policy-rc.d" in validation_script and "exit 101" in validation_script)
|
||||
|
||||
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",
|
||||
@@ -267,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)
|
||||
|
||||
@@ -304,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
|
||||
@@ -336,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")
|
||||
@@ -456,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 ]]
|
||||
@@ -0,0 +1,736 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Build the teacher's manual, both of it, from docs/manual.
|
||||
|
||||
tools/build-manual build everything into docs/manual/build
|
||||
tools/build-manual --check verify only; what the release runs
|
||||
|
||||
TWO OUTPUTS, ONE SOURCE.
|
||||
|
||||
classroom-manual.html installed on every classroom computer. It
|
||||
carries every page, each marked with the
|
||||
device.conf setting that decides whether that
|
||||
computer shows it, and hides the ones this
|
||||
device does not have when it is opened.
|
||||
|
||||
classroom-manual.pdf the universal printed manual. Every page,
|
||||
every feature, with a conditional sentence
|
||||
above the optional ones so that it stays true
|
||||
in a room that does not have them all.
|
||||
|
||||
They cannot drift apart because neither is written: both are generated
|
||||
from the same Markdown, and --check fails the release if a page is
|
||||
missing what the printed version needs.
|
||||
|
||||
WHY THE PDF IS WRITTEN HERE RATHER THAN CONVERTED.
|
||||
|
||||
A manual is headings, paragraphs, bullets, numbered steps and one small
|
||||
table. Writing that into a PDF directly is a couple of hundred lines with
|
||||
the standard fonts, no embedding and no dependency — and it means a
|
||||
release can be built on any machine with Python. Converting the HTML
|
||||
through LibreOffice was the alternative: it needs LibreOffice on whatever
|
||||
builds the release, it lays a web page out like a web page, and its
|
||||
output changes with the version installed. This does not.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import html
|
||||
import mimetypes
|
||||
import re
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SOURCE = ROOT / "docs" / "manual"
|
||||
BUILD = SOURCE / "build"
|
||||
|
||||
sys.path.insert(0, str(ROOT / "lib"))
|
||||
|
||||
from classroom_config import FEATURES # noqa: E402
|
||||
|
||||
|
||||
# ------------------------------------------------------------ reading
|
||||
|
||||
def parse(path):
|
||||
"""One manual page: its header, and its body as a list of blocks."""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
header = {}
|
||||
|
||||
if text.startswith("---\n"):
|
||||
end = text.index("\n---\n", 3)
|
||||
for line in text[4:end].splitlines():
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
header[key.strip()] = value.strip()
|
||||
text = text[end + 5:]
|
||||
|
||||
return header, blocks(text)
|
||||
|
||||
|
||||
_IMAGE = re.compile(r"^!\[([^\]]*)\]\(([^)]+)\)\s*$")
|
||||
|
||||
|
||||
def starts_block(line):
|
||||
"""Whether this line begins something that is not a paragraph.
|
||||
|
||||
One test, used by every branch below AND by the paragraph loop, so
|
||||
the two cannot disagree about a line. They did: the paragraph loop
|
||||
stopped at any line beginning with "*", while the bullet rule needed
|
||||
"* " — so a paragraph opening with a bold word consumed nothing,
|
||||
advanced nothing, and the build hung. Every page whose first line is
|
||||
bold hit it.
|
||||
"""
|
||||
return (line.startswith(("## ", "### ", "* ", "|"))
|
||||
or bool(re.match(r"^\d+\. ", line))
|
||||
or bool(_IMAGE.match(line)))
|
||||
|
||||
|
||||
def gather(lines, index, marker):
|
||||
"""A whole list, with each item's wrapped continuation lines folded in.
|
||||
|
||||
A step written across two lines in the source is one step, not a step
|
||||
and a paragraph. Without this the list ended at the first wrapped
|
||||
item and the next marker started a NEW list — so a three-step
|
||||
procedure printed as three separate steps all numbered 1, which is
|
||||
the worst possible way to get an instruction wrong.
|
||||
"""
|
||||
items = []
|
||||
pattern = re.compile(marker)
|
||||
|
||||
while index < len(lines):
|
||||
line = lines[index]
|
||||
|
||||
if pattern.match(line):
|
||||
items.append(re.sub(marker, "", line).strip())
|
||||
index += 1
|
||||
elif items and line.strip() and line[0] in " \t":
|
||||
# An indented line under an item belongs to that item.
|
||||
items[-1] += " " + line.strip()
|
||||
index += 1
|
||||
else:
|
||||
break
|
||||
|
||||
return items, index
|
||||
|
||||
|
||||
def blocks(text):
|
||||
"""Markdown, as much of it as a teacher's manual needs.
|
||||
|
||||
Headings, paragraphs, bullets, numbered steps and a simple table.
|
||||
Deliberately not a Markdown library: the same text has to come out
|
||||
right as a web page and as typeset print, and the small fixed set
|
||||
that does is easier to keep honest than a general one.
|
||||
"""
|
||||
out = []
|
||||
lines = text.splitlines()
|
||||
index = 0
|
||||
|
||||
while index < len(lines):
|
||||
line = lines[index].rstrip()
|
||||
|
||||
if not line.strip():
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if _IMAGE.match(line):
|
||||
match = _IMAGE.match(line)
|
||||
out.append(("image", (match.group(1), match.group(2))))
|
||||
index += 1
|
||||
elif line.startswith("### "):
|
||||
out.append(("h3", line[4:].strip()))
|
||||
index += 1
|
||||
elif line.startswith("## "):
|
||||
out.append(("h2", line[3:].strip()))
|
||||
index += 1
|
||||
elif line.startswith("| "):
|
||||
rows = []
|
||||
while index < len(lines) and lines[index].startswith("|"):
|
||||
cells = [c.strip() for c in lines[index].strip().strip("|").split("|")]
|
||||
# The |---|---| rule under a header row is not data.
|
||||
if not all(set(c) <= set("-: ") for c in cells):
|
||||
rows.append(cells)
|
||||
index += 1
|
||||
out.append(("table", rows))
|
||||
elif line.startswith("* "):
|
||||
items, index = gather(lines, index, r"^\* ")
|
||||
out.append(("bullets", items))
|
||||
elif re.match(r"^\d+\. ", line):
|
||||
items, index = gather(lines, index, r"^\d+\. ")
|
||||
out.append(("steps", items))
|
||||
else:
|
||||
# Always consumes the line it was given, whatever it is, so
|
||||
# this loop can never fail to advance.
|
||||
paragraph = [line.strip()]
|
||||
index += 1
|
||||
|
||||
while index < len(lines) and lines[index].strip() and \
|
||||
not starts_block(lines[index]):
|
||||
paragraph.append(lines[index].strip())
|
||||
index += 1
|
||||
|
||||
out.append(("p", " ".join(paragraph)))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def pages():
|
||||
found = []
|
||||
|
||||
for path in sorted(SOURCE.glob("*.md")):
|
||||
if path.name == "README.md":
|
||||
continue
|
||||
|
||||
header, body = parse(path)
|
||||
header["path"] = path
|
||||
found.append((header, body))
|
||||
|
||||
found.sort(key=lambda page: int(page[0].get("order", "99")))
|
||||
|
||||
return found
|
||||
|
||||
|
||||
# ------------------------------------------------------------ marking
|
||||
|
||||
_BOLD = re.compile(r"\*\*(.+?)\*\*")
|
||||
|
||||
|
||||
def inline_html(text):
|
||||
return _BOLD.sub(r"<strong>\1</strong>", html.escape(text))
|
||||
|
||||
|
||||
def plain(text):
|
||||
"""The same text with its marking removed, for the PDF."""
|
||||
return _BOLD.sub(r"\1", text)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- HTML
|
||||
|
||||
STYLE = """
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 0 24px 80px;
|
||||
background: #141c20; color: #e6edef;
|
||||
font-family: "Noto Sans", "DejaVu Sans", sans-serif;
|
||||
font-size: 19px; line-height: 1.65;
|
||||
}
|
||||
.sheet { max-width: 46rem; margin: 0 auto; }
|
||||
h1 { font-size: 2.1rem; margin: 2rem 0 0.2rem; }
|
||||
h2 { font-size: 1.5rem; margin: 2.4rem 0 0.4rem;
|
||||
border-bottom: 1px solid #25333a; padding-bottom: .3rem; }
|
||||
h3 { font-size: 1.15rem; margin: 1.6rem 0 0.3rem; color: #9fd8d1; }
|
||||
p, li { margin: .5rem 0; }
|
||||
ol, ul { padding-left: 1.4rem; }
|
||||
strong { color: #ffffff; }
|
||||
table { border-collapse: collapse; margin: 1rem 0; width: 100%; }
|
||||
td, th { border: 1px solid #25333a; padding: .5rem .7rem; text-align: left; }
|
||||
th { background: #0f171a; }
|
||||
nav { margin: 1.5rem 0 2.5rem; padding: 1rem 1.2rem;
|
||||
background: #0f171a; border: 1px solid #25333a; border-radius: 14px; }
|
||||
nav a { color: #7fd0c6; text-decoration: none; display: block; padding: .2rem 0; }
|
||||
nav a:hover { text-decoration: underline; }
|
||||
figure { margin: 1.2rem 0; }
|
||||
figure img { max-width: 100%; height: auto; display: block;
|
||||
border: 1px solid #25333a; border-radius: 12px; }
|
||||
figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
|
||||
.revision { color: #93a5aa; font-size: .95rem; }
|
||||
.top { color: #7fd0c6; text-decoration: none; font-size: .95rem; }
|
||||
@media print {
|
||||
body { background: #fff; color: #000; font-size: 11pt; }
|
||||
nav, .top { display: none; }
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def embedded(source):
|
||||
"""A picture as a data: URI, so the manual stays ONE file.
|
||||
|
||||
The installed manual is composed into the teacher's cache with this
|
||||
device's features declared in it, so a relative path would point at a
|
||||
directory that is not beside the copy being opened. Embedding means
|
||||
the file works wherever it is put, with nothing to install alongside
|
||||
it and nothing to go missing — which is also what makes it work with
|
||||
the network unplugged.
|
||||
"""
|
||||
path = SOURCE / source
|
||||
kind = mimetypes.guess_type(path.name)[0] or "image/png"
|
||||
data = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
|
||||
return f"data:{kind};base64,{data}"
|
||||
|
||||
|
||||
def render_html(all_pages, revision):
|
||||
parts = [
|
||||
"<!DOCTYPE html>", '<html lang="en">', "<head>",
|
||||
'<meta charset="utf-8">',
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||
"<title>Classroom manual</title>",
|
||||
f"<style>{STYLE}</style>", "</head>", "<body>", '<div class="sheet">',
|
||||
"<h1>Classroom manual</h1>",
|
||||
f'<p class="revision">River of Glory · {revision}</p>',
|
||||
"<nav id='contents'></nav>",
|
||||
]
|
||||
|
||||
for header, body in all_pages:
|
||||
feature = header.get("feature", "")
|
||||
anchor = header["path"].stem
|
||||
# The one thing the page carries into the browser: which setting
|
||||
# decides whether this device shows it. No value, no other
|
||||
# setting, and nothing about the machine.
|
||||
attribute = f' data-feature="{html.escape(feature)}"' if feature else ""
|
||||
parts.append(f'<section id="{anchor}"{attribute}>')
|
||||
parts.append(f'<h2>{inline_html(header["title"])}</h2>')
|
||||
|
||||
for kind, content in body:
|
||||
if kind == "h2":
|
||||
parts.append(f"<h3>{inline_html(content)}</h3>")
|
||||
elif kind == "h3":
|
||||
parts.append(f"<h4>{inline_html(content)}</h4>")
|
||||
elif kind == "p":
|
||||
parts.append(f"<p>{inline_html(content)}</p>")
|
||||
elif kind == "bullets":
|
||||
parts.append("<ul>" + "".join(
|
||||
f"<li>{inline_html(i)}</li>" for i in content) + "</ul>")
|
||||
elif kind == "steps":
|
||||
parts.append("<ol>" + "".join(
|
||||
f"<li>{inline_html(i)}</li>" for i in content) + "</ol>")
|
||||
elif kind == "image":
|
||||
alt, source = content
|
||||
parts.append(
|
||||
f'<figure><img src="{embedded(source)}" '
|
||||
f'alt="{html.escape(alt)}">'
|
||||
f'<figcaption>{inline_html(alt)}</figcaption></figure>')
|
||||
elif kind == "table":
|
||||
rows = []
|
||||
for number, row in enumerate(content):
|
||||
tag = "th" if number == 0 else "td"
|
||||
rows.append("<tr>" + "".join(
|
||||
f"<{tag}>{inline_html(c)}</{tag}>" for c in row) +
|
||||
"</tr>")
|
||||
parts.append("<table>" + "".join(rows) + "</table>")
|
||||
|
||||
parts.append('<p><a class="top" href="#contents">Back to the top</a></p>')
|
||||
parts.append("</section>")
|
||||
|
||||
parts.append("</div>")
|
||||
parts.append(f"<script>{VIEWER}</script>")
|
||||
parts.append("</body></html>")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# What the manual does when a teacher opens it.
|
||||
#
|
||||
# classroom-manual writes the device's enabled features into
|
||||
# window.CLASSROOM_FEATURES before this runs. It is a list of setting
|
||||
# names and nothing else — no values from device.conf, no broker
|
||||
# password, no addresses, nothing an administrator set. With no list at
|
||||
# all, every page is shown, which is the printed manual's behaviour and
|
||||
# is always true, just less specific.
|
||||
VIEWER = """
|
||||
(function () {
|
||||
var have = window.CLASSROOM_FEATURES || null;
|
||||
var sections = document.querySelectorAll('section');
|
||||
var contents = document.getElementById('contents');
|
||||
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
var needs = sections[i].getAttribute('data-feature');
|
||||
|
||||
if (needs && have && have.indexOf(needs) === -1) {
|
||||
sections[i].remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
var link = document.createElement('a');
|
||||
link.href = '#' + sections[i].id;
|
||||
link.textContent = sections[i].querySelector('h2').textContent;
|
||||
contents.appendChild(link);
|
||||
}
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- PDF
|
||||
|
||||
# The 14 fonts every PDF reader has. Nothing is embedded, so the file is
|
||||
# small and identical everywhere.
|
||||
BODY_FONT = "Helvetica"
|
||||
BOLD_FONT = "Helvetica-Bold"
|
||||
|
||||
PAGE_WIDTH, PAGE_HEIGHT = 595, 842 # A4 in points
|
||||
MARGIN_X, MARGIN_TOP, MARGIN_BOTTOM = 62, 780, 64
|
||||
|
||||
# Helvetica's own widths, per 1000 units, for the printable ASCII range.
|
||||
# Wrapping by character count puts a line of capitals off the page and
|
||||
# leaves a line of commas half empty, and this is a manual people print.
|
||||
_WIDTHS = {
|
||||
" ": 278, "!": 278, '"': 355, "#": 556, "$": 556, "%": 889, "&": 667,
|
||||
"'": 191, "(": 333, ")": 333, "*": 389, "+": 584, ",": 278, "-": 333,
|
||||
".": 278, "/": 278, ":": 278, ";": 278, "<": 584, "=": 584, ">": 584,
|
||||
"?": 556, "@": 1015, "[": 278, "\\": 278, "]": 278, "^": 469, "_": 556,
|
||||
"`": 333, "{": 334, "|": 260, "}": 334, "~": 584,
|
||||
}
|
||||
|
||||
|
||||
def width_of(text, size, bold=False):
|
||||
total = 0
|
||||
|
||||
for character in text:
|
||||
if character.isdigit():
|
||||
total += 556
|
||||
elif character.isupper():
|
||||
total += 722 if bold else 667
|
||||
elif character.islower():
|
||||
total += 556 if bold else 500
|
||||
else:
|
||||
total += _WIDTHS.get(character, 556)
|
||||
|
||||
return total * size / 1000.0
|
||||
|
||||
|
||||
def wrap(text, size, available, bold=False):
|
||||
lines = []
|
||||
current = ""
|
||||
|
||||
for word in text.split():
|
||||
candidate = f"{current} {word}".strip()
|
||||
|
||||
if current and width_of(candidate, size, bold) > available:
|
||||
lines.append(current)
|
||||
current = word
|
||||
else:
|
||||
current = candidate
|
||||
|
||||
if current:
|
||||
lines.append(current)
|
||||
|
||||
return lines or [""]
|
||||
|
||||
|
||||
def escape_pdf(text):
|
||||
out = []
|
||||
|
||||
for character in text:
|
||||
if character in "()\\":
|
||||
out.append("\\" + character)
|
||||
elif ord(character) < 128:
|
||||
out.append(character)
|
||||
else:
|
||||
# The standard encoding has no place for a curly quote or a
|
||||
# dash a writer typed without thinking. Replaced rather than
|
||||
# dropped, so a printed line never loses a word.
|
||||
out.append({
|
||||
"’": "'", "‘": "'", "“": '"', "”": '"',
|
||||
"—": "-", "–": "-", " ": " ", "·": "-",
|
||||
"→": "->", "←": "<-", "↑": "up",
|
||||
"↓": "down", "×": "x", "⏏": "eject",
|
||||
"✕": "X", "◀": "<", "▶": ">",
|
||||
}.get(character, "?"))
|
||||
|
||||
return "".join(out)
|
||||
|
||||
|
||||
class Pdf:
|
||||
"""A very small PDF writer: text, in two weights, on A4 pages."""
|
||||
|
||||
def __init__(self):
|
||||
self.pages = []
|
||||
self.current = []
|
||||
self.y = MARGIN_TOP
|
||||
|
||||
def _show(self, text, x, y, size, bold):
|
||||
font = "F2" if bold else "F1"
|
||||
self.current.append(
|
||||
f"BT /{font} {size} Tf 1 0 0 1 {x:.1f} {y:.1f} Tm "
|
||||
f"({escape_pdf(text)}) Tj ET")
|
||||
|
||||
def break_page(self):
|
||||
if self.current:
|
||||
self.pages.append(self.current)
|
||||
|
||||
self.current = []
|
||||
self.y = MARGIN_TOP
|
||||
|
||||
def space(self, amount):
|
||||
self.y -= amount
|
||||
|
||||
def need(self, amount):
|
||||
if self.y - amount < MARGIN_BOTTOM:
|
||||
self.break_page()
|
||||
|
||||
def text(self, content, size=10.5, bold=False, indent=0, leading=None):
|
||||
leading = leading or size * 1.42
|
||||
available = PAGE_WIDTH - 2 * MARGIN_X - indent
|
||||
|
||||
for line in wrap(content, size, available, bold):
|
||||
self.need(leading)
|
||||
self._show(line, MARGIN_X + indent, self.y, size, bold)
|
||||
self.y -= leading
|
||||
|
||||
def bullet(self, mark, content, size=10.5, indent=16):
|
||||
leading = size * 1.42
|
||||
available = PAGE_WIDTH - 2 * MARGIN_X - indent - 14
|
||||
lines = wrap(content, size, available)
|
||||
|
||||
self.need(leading * len(lines))
|
||||
self._show(mark, MARGIN_X + indent, self.y, size, False)
|
||||
|
||||
for number, line in enumerate(lines):
|
||||
if number:
|
||||
self.need(leading)
|
||||
self._show(line, MARGIN_X + indent + 16, self.y, size, False)
|
||||
self.y -= leading
|
||||
|
||||
def rule(self):
|
||||
self.need(10)
|
||||
self.current.append(
|
||||
f"0.75 w 0.6 0.6 0.6 RG {MARGIN_X} {self.y + 4:.1f} m "
|
||||
f"{PAGE_WIDTH - MARGIN_X} {self.y + 4:.1f} l S")
|
||||
self.y -= 8
|
||||
|
||||
def build(self):
|
||||
self.break_page()
|
||||
|
||||
objects = []
|
||||
count = len(self.pages)
|
||||
# 1 catalog, 2 pages, 3..(2+n) page objects, then the contents,
|
||||
# then the two fonts.
|
||||
kids = " ".join(f"{3 + i} 0 R" for i in range(count))
|
||||
|
||||
objects.append("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
objects.append(
|
||||
f"<< /Type /Pages /Kids [{kids}] /Count {count} >>")
|
||||
|
||||
for index in range(count):
|
||||
contents = 3 + count + index
|
||||
objects.append(
|
||||
f"<< /Type /Page /Parent 2 0 R "
|
||||
f"/MediaBox [0 0 {PAGE_WIDTH} {PAGE_HEIGHT}] "
|
||||
f"/Resources << /Font << /F1 {3 + 2 * count} 0 R "
|
||||
f"/F2 {4 + 2 * count} 0 R >> >> "
|
||||
f"/Contents {contents} 0 R >>")
|
||||
|
||||
for page in self.pages:
|
||||
stream = "\n".join(page)
|
||||
objects.append(
|
||||
f"<< /Length {len(stream.encode('latin-1', 'replace'))} >>\n"
|
||||
f"stream\n{stream}\nendstream")
|
||||
|
||||
for font in (BODY_FONT, BOLD_FONT):
|
||||
objects.append(
|
||||
f"<< /Type /Font /Subtype /Type1 /BaseFont /{font} "
|
||||
f"/Encoding /WinAnsiEncoding >>")
|
||||
|
||||
out = ["%PDF-1.4"]
|
||||
offsets = []
|
||||
position = len(out[0]) + 1
|
||||
|
||||
for number, body in enumerate(objects, start=1):
|
||||
chunk = f"{number} 0 obj\n{body}\nendobj"
|
||||
offsets.append(position)
|
||||
out.append(chunk)
|
||||
position += len(chunk.encode("latin-1", "replace")) + 1
|
||||
|
||||
table = [f"xref\n0 {len(objects) + 1}", "0000000000 65535 f "]
|
||||
table += [f"{offset:010d} 00000 n " for offset in offsets]
|
||||
out.append("\n".join(table))
|
||||
out.append(
|
||||
f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
|
||||
f"startxref\n{position}\n%%EOF")
|
||||
|
||||
return ("\n".join(out)).encode("latin-1", "replace")
|
||||
|
||||
|
||||
def render_pdf(all_pages, revision):
|
||||
pdf = Pdf()
|
||||
|
||||
pdf.space(60)
|
||||
pdf.text("Classroom manual", size=26, bold=True)
|
||||
pdf.space(6)
|
||||
pdf.text("River of Glory", size=13)
|
||||
pdf.space(2)
|
||||
pdf.text(revision, size=10.5)
|
||||
pdf.space(18)
|
||||
pdf.text(
|
||||
"This manual covers every classroom computer in the church. Not "
|
||||
"every room has every button, so some pages begin by saying when "
|
||||
"they apply. If your computer does not have what a page "
|
||||
"describes, that page is not for your room.", size=10.5)
|
||||
pdf.space(10)
|
||||
pdf.text(
|
||||
"Keep this copy in the room. If the date above is older than the "
|
||||
"one on the computer's own Help, ask the office for a new one.",
|
||||
size=10.5)
|
||||
pdf.break_page()
|
||||
|
||||
for header, body in all_pages:
|
||||
pdf.space(6)
|
||||
pdf.text(plain(header["title"]), size=17, bold=True)
|
||||
pdf.space(2)
|
||||
pdf.rule()
|
||||
pdf.space(6)
|
||||
|
||||
# THE CONDITIONAL SENTENCE.
|
||||
#
|
||||
# This is what lets one printed manual be true in every room. A
|
||||
# page for something optional says so before it says anything
|
||||
# else, so a teacher in a room without it knows within one line.
|
||||
if header.get("printed-if"):
|
||||
pdf.text(plain(header["printed-if"]) + ".", size=10.5, bold=True)
|
||||
pdf.space(6)
|
||||
|
||||
for kind, content in body:
|
||||
if kind == "h2":
|
||||
pdf.space(8)
|
||||
pdf.text(plain(content), size=12.5, bold=True)
|
||||
pdf.space(3)
|
||||
elif kind == "h3":
|
||||
pdf.space(5)
|
||||
pdf.text(plain(content), size=11, bold=True)
|
||||
elif kind == "p":
|
||||
pdf.text(plain(content))
|
||||
pdf.space(5)
|
||||
elif kind == "bullets":
|
||||
for item in content:
|
||||
pdf.bullet("-", plain(item))
|
||||
pdf.space(5)
|
||||
elif kind == "steps":
|
||||
for number, item in enumerate(content, start=1):
|
||||
pdf.bullet(f"{number}.", plain(item))
|
||||
pdf.space(5)
|
||||
elif kind == "image":
|
||||
pdf.text(f"[ Picture: {plain(content[0])} — see the "
|
||||
"computer's own Help ]", size=9.5)
|
||||
pdf.space(5)
|
||||
elif kind == "table":
|
||||
for number, row in enumerate(content):
|
||||
pdf.bullet(
|
||||
" ", " ".join(plain(c) for c in row)
|
||||
if number else
|
||||
" ".join(plain(c).upper() for c in row))
|
||||
pdf.space(5)
|
||||
|
||||
pdf.break_page()
|
||||
|
||||
return pdf.build()
|
||||
|
||||
|
||||
# -------------------------------------------------------------- check
|
||||
|
||||
def check(all_pages):
|
||||
"""Everything that would let the two versions drift apart."""
|
||||
problems = []
|
||||
|
||||
if not all_pages:
|
||||
problems.append("there are no manual pages at all")
|
||||
|
||||
seen = set()
|
||||
|
||||
for header, body in all_pages:
|
||||
name = header["path"].name
|
||||
|
||||
for required in ("title", "order"):
|
||||
if not header.get(required):
|
||||
problems.append(f"{name}: no {required}")
|
||||
|
||||
if header.get("order") in seen:
|
||||
problems.append(f"{name}: another page already has order "
|
||||
f"{header.get('order')}")
|
||||
|
||||
seen.add(header.get("order"))
|
||||
|
||||
feature = header.get("feature")
|
||||
|
||||
if feature:
|
||||
# A page for an optional feature MUST carry the sentence the
|
||||
# printed manual puts above it, or the printed manual quietly
|
||||
# becomes wrong for every room without that feature.
|
||||
if not header.get("printed-if"):
|
||||
problems.append(
|
||||
f"{name}: declares feature {feature} but has no "
|
||||
"printed-if, so the printed manual would state it "
|
||||
"unconditionally")
|
||||
|
||||
if feature not in FEATURES:
|
||||
problems.append(
|
||||
f"{name}: {feature} is not a device.conf feature "
|
||||
f"(known: {', '.join(sorted(FEATURES))})")
|
||||
|
||||
if not body:
|
||||
problems.append(f"{name}: no content")
|
||||
|
||||
for kind, content in body:
|
||||
if kind != "image":
|
||||
continue
|
||||
|
||||
picture = SOURCE / content[1]
|
||||
|
||||
if not picture.exists():
|
||||
problems.append(f"{name}: {content[1]} is not there")
|
||||
elif not content[0].strip():
|
||||
# The printed manual prints the alt text as the caption,
|
||||
# so a picture with none leaves a bare "[ Picture: ]".
|
||||
problems.append(
|
||||
f"{name}: {content[1]} has no caption, which the "
|
||||
"printed manual needs")
|
||||
|
||||
# Every optional feature the package has should be documented, or a
|
||||
# room gets a button the manual never mentions.
|
||||
documented = {h.get("feature") for h, _ in all_pages if h.get("feature")}
|
||||
|
||||
for feature in FEATURES:
|
||||
if feature not in documented:
|
||||
problems.append(
|
||||
f"{feature} is an optional feature with no manual page")
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build the teacher's manual, digital and printed.")
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="verify the source and the built files only")
|
||||
arguments = parser.parse_args()
|
||||
|
||||
all_pages = pages()
|
||||
problems = check(all_pages)
|
||||
|
||||
for problem in problems:
|
||||
print(f"build-manual: {problem}", file=sys.stderr)
|
||||
|
||||
if problems:
|
||||
return 1
|
||||
|
||||
revision = f"Revision {date.today():%d %B %Y}"
|
||||
|
||||
if arguments.check:
|
||||
for name in ("classroom-manual.html", "classroom-manual.pdf"):
|
||||
if not (BUILD / name).exists():
|
||||
print(f"build-manual: {name} has not been built; run "
|
||||
"tools/build-manual", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"build-manual: {len(all_pages)} pages, "
|
||||
f"{len([h for h, _ in all_pages if h.get('feature')])} of them "
|
||||
"optional, and both versions are built")
|
||||
return 0
|
||||
|
||||
BUILD.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(BUILD / "classroom-manual.html").write_text(
|
||||
render_html(all_pages, revision), encoding="utf-8")
|
||||
(BUILD / "classroom-manual.pdf").write_bytes(
|
||||
render_pdf(all_pages, revision))
|
||||
|
||||
print(f"build-manual: {BUILD / 'classroom-manual.html'}")
|
||||
print(f"build-manual: {BUILD / 'classroom-manual.pdf'}")
|
||||
print(f"build-manual: {revision}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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"
|
||||
@@ -52,6 +51,15 @@ done
|
||||
find "$ROOT/scripts" "$ROOT/lib" "$ROOT/tests" \
|
||||
-name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# THE MANUAL IS PART OF THE RELEASE, NOT A JOB FOR AFTERWARDS.
|
||||
#
|
||||
# Built from docs/manual before the tests run, so that the test which
|
||||
# checks the digital and printed versions against each other is checking
|
||||
# what this package is about to ship rather than whatever was built last.
|
||||
# A page that would make the printed manual wrong fails the build here.
|
||||
echo "Building the teacher's manual"
|
||||
"$ROOT/tools/build-manual"
|
||||
|
||||
for test in "$ROOT"/tests/test-*.sh; do
|
||||
[[ -x "$test" ]] || continue
|
||||
|
||||
@@ -168,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"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -185,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" \
|
||||
@@ -198,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"
|
||||
@@ -348,9 +391,10 @@ install -Dm644 \
|
||||
"$ROOT/config/dbus/org.kde.LogoutPrompt.service" \
|
||||
"$STAGE/usr/local/share/dbus-1/services/org.kde.LogoutPrompt.service"
|
||||
|
||||
# Two marks the classroom draws itself, because nothing in Breeze says
|
||||
# either thing: a lesson, and fetching a child's grown-up.
|
||||
for icon in ekklesia-wonderink ekklesia-parent-call; do
|
||||
# Three marks the classroom draws itself, because nothing in Breeze says
|
||||
# any of them: a lesson, fetching a child's grown-up, and tonight's
|
||||
# slides.
|
||||
for icon in ekklesia-wonderink ekklesia-parent-call ekklesia-presentations; do
|
||||
install -Dm644 \
|
||||
"$ROOT/config/icons/${icon}.svg" \
|
||||
"$STAGE/usr/share/icons/hicolor/scalable/apps/${icon}.svg"
|
||||
@@ -374,6 +418,60 @@ 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
|
||||
# downloads. Both are installed on every machine; PRESENTATIONS_ENABLED
|
||||
# in device.conf is what decides whether either ever runs, and it
|
||||
# defaults to false so an existing classroom upgrades unchanged.
|
||||
# THE MANUAL.
|
||||
#
|
||||
# One HTML file carrying every page, and the command that opens it with
|
||||
# the pages this device has no use for taken out. Installed on every
|
||||
# machine including ones with no optional features at all, because the
|
||||
# common pages — the bar, the browser, the volume, what to do when
|
||||
# something is wrong — are the ones a new teacher needs most.
|
||||
install -Dm644 \
|
||||
"$ROOT/docs/manual/build/classroom-manual.html" \
|
||||
"$STAGE/usr/local/share/ekklesia-classroom/manual/classroom-manual.html"
|
||||
|
||||
# The printable one goes on the machine too. A room whose printed copy has
|
||||
# gone missing can print another without anybody fetching a file from a
|
||||
# laptop, and its revision date is the same one the digital manual shows.
|
||||
install -Dm644 \
|
||||
"$ROOT/docs/manual/build/classroom-manual.pdf" \
|
||||
"$STAGE/usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf"
|
||||
|
||||
install -Dm755 \
|
||||
"$ROOT/scripts/classroom-manual" \
|
||||
"$STAGE/usr/local/bin/classroom-manual"
|
||||
|
||||
install -Dm755 \
|
||||
"$ROOT/scripts/classroom-presentations" \
|
||||
"$STAGE/usr/local/bin/classroom-presentations"
|
||||
|
||||
install -Dm755 \
|
||||
"$ROOT/scripts/classroom-presentation-import" \
|
||||
"$STAGE/usr/local/bin/classroom-presentation-import"
|
||||
|
||||
install -Dm644 \
|
||||
"$ROOT/lib/classroom_presentations.py" \
|
||||
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_presentations.py"
|
||||
|
||||
install -Dm644 \
|
||||
"$ROOT/systemd/user/ekklesia-classroom-presentations.service" \
|
||||
"$STAGE/etc/systemd/user/ekklesia-classroom-presentations.service"
|
||||
|
||||
install -Dm755 \
|
||||
"$ROOT/scripts/classroom-audio" \
|
||||
"$STAGE/usr/local/bin/classroom-audio"
|
||||
@@ -394,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"
|
||||
@@ -482,6 +584,8 @@ install -Dm644 \
|
||||
"$ROOT/lib/classroom_icons.py" \
|
||||
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_icons.py"
|
||||
|
||||
# The palette and the overlay measurements. The DVD control bar, Parent
|
||||
# Call and the Presentations library all draw themselves from this.
|
||||
install -Dm644 \
|
||||
"$ROOT/lib/classroom_style.py" \
|
||||
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_style.py"
|
||||
@@ -512,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"
|
||||
|
||||