Compare commits

10 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5 ec1e0d1cd7 release: 0.2.6, for the canary machine only
Presentations, the teacher's manual, and the Wi-Fi hand-over fix.

Canary only. The three classroom desktops are on CHANNEL=stable, which
still answers 0.2.2 and which nothing here has written to.

The prayer-room laptop is the one machine this is for, and its Wi-Fi is
already NetworkManager's — `classroom-wifi --adopt` reports "nothing to
adopt" on it and changes nothing, which is asserted by a test rather
than remembered.

Note for whoever installs it: this release adds LibreOffice and the font
set, so the upgrade fetches 216MB across 186 packages from Debian's
mirrors. It needs the network up at the time and it is not a five
minutes before the study job.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 21:03:55 -05:00
ekklesiaandClaude Opus 5 32ee0b544a fix: check the fonts by the files LibreOffice opens, not by fc-list
The validator asked fc-list whether Liberation Sans, Carlito and Caladea
were installed. fc-list belongs to the fontconfig package, which nothing
in this classroom pulls in, so the check could only ever fail — and it
did, twenty-odd minutes into a release run, for a reason that had
nothing to do with the fonts. They were all present the whole time.

Asserted by path now, which is what LibreOffice actually looks for.

Also depends on fonts-liberation rather than fonts-liberation2. The
latter is a transitional package in Trixie that exists only to depend on
the former, and a release should not name a package whose whole purpose
is to go away.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 20:47:57 -05:00
ekklesiaandClaude Opus 5 9c864a1e44 test: the release gate is bounded, which is not the same as a number
These pinned "720s" and "candidate-install 360" as literal strings. When
0.2.6 raised those budgets for LibreOffice, the install check failed —
and the outer-deadline check went on passing, because the comment
explaining the change still contained "720s".

A test a comment can satisfy is not testing anything. What matters is
that nothing in the gate runs unbounded and that each budget is long
enough to install a desktop, so that is what it now reads.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 20:37:06 -05:00
ekklesiaandClaude Opus 5 89b24acff0 fix: the release gate has time to install what the package now depends on
The validator installs the previous package and then the candidate, two
full desktop installs in one container, on a 720s cap with 360s per
install. 0.2.6 adds LibreOffice and a dozen font packages — 216MB and
186 packages on top of Plasma — and the run was killed during the
PREVIOUS install, before it reached a single assertion.

Raised generously rather than to just-enough. This is the gate that has
to say "the package is wrong"; one that says "the machine was busy"
instead teaches everybody to re-run it until it passes, which is how a
gate stops being one.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 20:33:51 -05:00
ekklesiaandClaude Opus 5 95ae054f6f fix: the presenter stops insisting on fullscreen after a few refusals
The slideshow re-asserts fullscreen when a compositor takes it away,
which is what keeps a teacher from being left looking at the library
beside their slides. A compositor that granted it and revoked it
repeatedly would have had this asking forever, in the middle of a Bible
study.

It gives up after five goes. The slides are still on the screen and
Escape still works; only the insisting stops, which is a far better
failure than a window fighting a compositor in front of a room.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 20:24:06 -05:00
ekklesiaandClaude Opus 5 f2942a2946 docs: one manual, the pages a device has and a printable copy of all of them
Documentation becomes part of the release rather than a job afterwards.
docs/manual is now the only place teacher instructions are written, and
tools/build-manual produces both versions from it:

  classroom-manual.html   installed on every machine, showing only the
                          features THAT device has
  classroom-manual.pdf    the universal printed manual, every feature,
                          with a conditional sentence above each
                          optional page so it stays true in a room that
                          does not have them all

They cannot drift apart because neither is written by hand, and
build-release.sh builds and checks them on every release: a page that
declares a feature with no printed-if fails the build, because that is
exactly how the printed manual comes to state an optional feature as
though every room had it. So does a page naming a feature device.conf
does not have, and an optional feature with no page at all.

WHAT REACHES THE CLASSROOM ACCOUNT. Only a list of setting NAMES, and
only ones the package already defines as optional applications.
classroom-desktop-layout — already the one place that decides what a
device offers — writes them to /run/ekklesia-classroom/features.json,
and classroom-manual filters that against FEATURES before using it. No
values from device.conf, no broker password, no addresses. With no
manifest at all every page is shown, which is the printed manual and is
never wrong, only less specific.

Help is a Quick Launch button beside Parent Call, deliberately not in
the row with the applications: putting it there would move buttons
teachers reach for without looking. Every room gets it, because a
teacher who needs the manual is by definition one who does not know
where to look.

The PDF is written directly rather than converted. A manual is
headings, paragraphs, bullets, steps and one table; writing that with
the standard fonts is a couple of hundred lines and no dependency, so a
release can be built on any machine with Python. Pictures are embedded
in the digital manual as data URIs so it stays one file that works
wherever it is copied and with the network unplugged; the printed
manual names them instead, which is the one thing this builder does not
do and is noted where somebody would look for it.

Two defects found building it, both of which hung or corrupted the
output rather than failing: a paragraph opening with a bold word
matched the bullet guard but not the bullet rule, so nothing consumed
it and the build spun; and a list item wrapped across two source lines
ended the list, so a three-step procedure printed as three steps all
numbered 1.

Also adds the 0.2.6 physical checklist to docs/testing.md and the
release validator's checks for the Wi-Fi regression, the conversion
pipeline on a real Debian, the fonts, the manual and the dock order.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 20:21:39 -05:00
ekklesiaandClaude Opus 5 55f4c183c7 feat: Presentations, for a teacher who downloaded tonight's session
PRESENTATIONS_ENABLED, off by default, independent of everything else. A
teacher downloads a session in ordinary Chrome and it is on the
Presentations screen a few seconds later, ready for the television. No
file manager, no import button, no Impress.

WHY NOTHING LAUNCHES A DOCUMENT APPLICATION. The brief was to use
existing presentation software, so the existing software was tried
first, on Debian 13, before any of this was designed. `soffice --show`
does start a real fullscreen slideshow — and needs XDG_SESSION_TYPE plus
a VCL plugin or it dies with "Failed to open display", shows a splash,
opened a "Tip of the Day: 1/225" dialog over the slides, and leaves the
Impress editor when the show ends. `okular --presentation` opens THREE
windows: the presentation, the ordinary document window behind it, and
an information dialog. zathura is not a presenter. pdfpc is the closest
and is another dependency with a presenter-console model. Every one of
them leaves an application on screen that can open any file on the
machine, and none of them knows there is a library to go back to.

So LibreOffice converts once, headless, at import, with no window on any
screen; poppler renders the pages; the classroom shows the pictures.
Measured: 0.34s for a ten-slide PPTX, 0.03s for its ten thumbnails,
0.10s for one slide at 1920px. That is not a new renderer — it is the
same engine Impress would have used live — and it means Escape is ours,
presenting is instant, and a PDF exported from Google Slides skips the
conversion entirely and keeps the appearance Google gave it.

THE THREE THINGS KEPT APART. The library thumbnail, the starting slide
and the retention clock are independent, because the first slide of a
Bible study session is a generic opening shared by every session and the
one that names it is usually the second. Quick Look chooses the picture;
it does not change where the slideshow starts and it does not count as
having presented anything. mark_presented() is called when a slide is
actually drawn, not when Present is pressed: an intention is not a
presentation, and a viewer that failed to start must not buy another
thirty days.

IMPORTING. The teacher's download is copied, never moved, renamed or
deleted. A file with a .crdownload beside it is still arriving. Wonder
Ink's own folder is not looked at. Two downloads of the same name are
two presentations, the second titled (2) — found in the lab, where a PDF
and a PPTX of one session both came out with the same name because the
import being named was excluded from the taken-titles set BY TITLE, and
a set holds one copy of a repeated string.

Metadata is written saying "importing" before any work, so a reboot
mid-conversion leaves a directory the next startup sweeps and imports
again. A file that cannot be converted gets a card saying so, offering
only Remove, with the teacher's download untouched.

Retention: 180 days never presented, 30 days from the last presentation,
every presentation starting the thirty over. The cleanup never touches a
presentation being shown, never leaves the library directory, and never
looks at Downloads.

The dock order is unchanged: Presentations is appended after the disc
player, so Wonder Ink, the browser and the player keep the places
teachers have been reaching for. There is a test for exactly that.

classroom-wonderink now asks feature() rather than carrying its own copy
of the default, so the panel and the command cannot disagree.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 20:21:19 -05:00
ekklesiaandClaude Opus 5 30d2d99230 fix: a hidden Wi-Fi survives the hand-over, and a failed one leaves an owner
The prayer-room laptop's 0.2.5 migration built a NetworkManager
connection, could not bring it up, rolled back, and left the wireless
adapter belonging to nobody. Four separate defects, each of which would
have been enough on its own.

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
profile came out with 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; profile() now returns
the whole network, and add() writes the flag in BOTH directions rather
than only when true, because a default that happens to be right is not
the same as an answer.

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, which is a
fight NetworkManager loses. Down first, while the file still says how;
rewrite the file second.

THE ROLLBACK LEFT THE ADAPTER UNMANAGED. restore() ended with
`device set <dev> managed no`, which is right while ifupdown can bring
the interface up and is a radio nobody owns when ifup has failed too.
That is the state the laptop was found in, and why `nmcli device set
wlp2s0 managed yes` was a manual recovery step. It now escalates: if
ifupdown cannot get the network back either, the interface goes to
NetworkManager rather than to neither, and it says so loudly.

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, so a pre-shared key containing one became a prefix of itself —
and the only symptom is an association failing exactly the way a wrong
password fails.

Two smaller ones went with them. The rollback un-comments its own marked
lines in place rather than copying the backup over the whole file, so a
wired change made between the migration and the rollback is no longer
silently reverted. And nmcli terse output is split on its own escaping
rules under LC_ALL=C, so a network whose name contains a colon cannot
make a successful migration look like a failed one.

--adopt now also refuses a network it cannot recreate before taking the
interface away from ifupdown to find out, and reports an
unmanaged-devices line in NetworkManager's configuration, which survives
the stanza going away.

Verified against a real NetworkManager 1.52.1: the keyfile carries
hidden=true, the SSID keeps its space, and a psk containing '#' is
intact, at 0600 root:root with no connection.permissions. The new test
drives the whole hand-over against stubbed tools and asserts the
ordering, the rollback and the managed state — including that a machine
already migrated, which is the laptop, is not migrated again.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 20:20:53 -05:00
ekklesiaandClaude Opus 5 fe08363534 fix: a machine with no ifupdown configuration is not an error
The release validator caught it: in a container there is no
/etc/network/interfaces at all, and a missing file was being treated as
a read failure, so --check printed nothing and said nothing was wrong
rather than saying there was nothing to do.

A machine with no ifupdown configuration is the ordinary case for a
fresh install, and the answer is simply that this command has no work.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 21:23:30 -05:00
ekklesiaandClaude Opus 5 b8688d00a7 docs: the Wi-Fi hand-over is a physical check, so write down its steps
No lab has the laptop's radio, so the one thing that cannot be proven
here is the hand-over succeeding. These are the ten steps that prove it
on the machine, including the four that prove the classroom account
still cannot read the password afterwards.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 21:11:49 -05:00
51 changed files with 6543 additions and 166 deletions
+1 -1
View File
@@ -1 +1 @@
0.2.4 0.2.6
+35
View File
@@ -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
@@ -12,19 +12,22 @@
// WHICH APPLICATIONS THIS DEVICE OFFERS. // WHICH APPLICATIONS THIS DEVICE OFFERS.
// //
// classroom-desktop-layout rewrites the three lines between the markers // classroom-desktop-layout rewrites the lines between the markers to
// to match device.conf and this machine's hardware, and nothing else in // 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 // 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 // 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 // used for Bible study does not, so the shape of the machine is never
// allowed to decide what it is for. // allowed to decide what it is for.
// //
// All true here is the shipped default, which is what an existing // The first three are true here, which is what an existing classroom must
// classroom must come up as. // 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 */ /* CLASSROOM-FEATURES-BEGIN */
var WONDERINK = true; var WONDERINK = true;
var DVD = true; var DVD = true;
var PARENT_CALL = true; var PARENT_CALL = true;
var PRESENTATIONS = false;
/* CLASSROOM-FEATURES-END */ /* CLASSROOM-FEATURES-END */
var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png"; var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
@@ -84,6 +87,27 @@ if (PARENT_CALL) {
parentCall.writeConfig("maxSectionCount", 1); parentCall.writeConfig("maxSectionCount", 1);
} }
// HELP, beside Parent Call and not in the row with the applications.
//
// Every room gets it: the manual is the one thing that is never optional,
// because a teacher who needs it is by definition a teacher who does not
// know where to look. It shows only the pages for the features THIS
// device has — see classroom-manual — so the button means the same thing
// in every room while what it opens does not.
//
// On the left, with Parent Call, for the same reason Parent Call is
// there: it is not one of the room's applications, and putting it in the
// middle row would move buttons teachers reach for without looking.
var help = panel.addWidget("org.kde.plasma.quicklaunch");
help.currentConfigGroup = ["General"];
help.writeConfig(
"launcherUrls",
"file:///usr/share/applications/ekklesia-manual.desktop"
);
help.writeConfig("showLauncherNames", false);
help.writeConfig("enablePopup", false);
help.writeConfig("maxSectionCount", 1);
var leftGap = panel.addWidget("org.kde.plasma.panelspacer"); var leftGap = panel.addWidget("org.kde.plasma.panelspacer");
leftGap.currentConfigGroup = ["General"]; leftGap.currentConfigGroup = ["General"];
leftGap.writeConfig("expanding", true); leftGap.writeConfig("expanding", true);
@@ -114,6 +138,17 @@ if (DVD) {
launchers.push("applications:ekklesia-dvd.desktop"); 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(",")); tasks.writeConfig("launchers", launchers.join(","));
// THE ORDER STAYS PUT WHEN AN APPLICATION OPENS. // THE ORDER STAYS PUT WHEN AN APPLICATION OPENS.
+24
View File
@@ -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.
+28
View File
@@ -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.
+27
View File
@@ -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.
+118
View File
@@ -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.
![The Presentations library, with a card for each session](images/library.png)
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.
![Quick Look, showing one slide with Next and Previous underneath](images/quick-look.png)
* **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.
+35
View File
@@ -0,0 +1,35 @@
---
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.
## Worksheets and colouring pages
Click the worksheet link in the lesson. It opens in a new tab, ready to
read or print.
Worksheets you download are kept separately from everything else on the
computer, so they never get mixed up with anything.
## 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.
+28
View File
@@ -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.
+30
View File
@@ -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.
+23
View File
@@ -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.
+35
View File
@@ -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.
+85
View File
@@ -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, `![caption](images/x.png)` 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.
File diff suppressed because one or more lines are too long
+362
View File
@@ -0,0 +1,362 @@
%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] /Count 11 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 14 0 R >>
endobj
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 15 0 R >>
endobj
5 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 16 0 R >>
endobj
6 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 17 0 R >>
endobj
7 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 18 0 R >>
endobj
8 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 19 0 R >>
endobj
9 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 20 0 R >>
endobj
10 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 21 0 R >>
endobj
11 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 22 0 R >>
endobj
12 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 23 0 R >>
endobj
13 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 24 0 R >>
endobj
14 0 obj
<< /Length 765 >>
stream
BT /F2 26 Tf 1 0 0 1 62.0 720.0 Tm (Classroom manual) Tj ET
BT /F1 13 Tf 1 0 0 1 62.0 677.1 Tm (River of Glory) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 656.6 Tm (Revision 22 September 2026) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 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
15 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
16 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
17 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
18 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
19 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
20 0 obj
<< /Length 1838 >>
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 (Worksheets and colouring pages) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 531.1 Tm (Click the worksheet link in the lesson. It opens in a new tab, ready to read or print.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 511.2 Tm (Worksheets you download are kept separately from everything else on the computer, so they never) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 496.3 Tm (get mixed up with anything.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 468.4 Tm (When you have finished) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 447.6 Tm (Close Wonder Ink at the end of your session. Closing it signs you out, so the next teacher in) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 432.7 Tm (this room starts with their own account and not yours.) Tj ET
endstream
endobj
21 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
22 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
23 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
24 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
25 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
endobj
26 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
endobj
xref
0 27
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000180 00000 n
0000000319 00000 n
0000000458 00000 n
0000000597 00000 n
0000000736 00000 n
0000000875 00000 n
0000001014 00000 n
0000001153 00000 n
0000001293 00000 n
0000001433 00000 n
0000001573 00000 n
0000001713 00000 n
0000002530 00000 n
0000003833 00000 n
0000005294 00000 n
0000007041 00000 n
0000011178 00000 n
0000014320 00000 n
0000016211 00000 n
0000017739 00000 n
0000019292 00000 n
0000020511 00000 n
0000022330 00000 n
0000022428 00000 n
trailer
<< /Size 27 /Root 1 0 R >>
startxref
22531
%%EOF
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+68
View File
@@ -154,6 +154,74 @@ that would be a one-way door.
The original is kept at `/etc/network/interfaces.before-ekklesia-classroom`. 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 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 room can scan, and the credential protection below applies to it like any
other system connection. other system connection.
+220
View File
@@ -0,0 +1,220 @@
# 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.
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/ the teacher's own download. NEVER touched.
~/Downloads/Wonder Ink/ Wonder Ink's worksheets. NEVER touched.
~/.local/share/ekklesia-classroom/presentations/
imported.json which downloads have been dealt with
<id>/
presentation.json title, pages, thumbnail slide, timers
Session 7 - Romans.pptx the managed copy, original name kept
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. The second gets
`(2)` after its title; neither overwrites the other.
**Crash safety.** The metadata is written saying `importing` *before* any
work, and the fingerprint is recorded at the same moment so a file that
crashes the converter cannot be retried forever. On startup the importer
sweeps every directory still saying `importing`, deletes it and forgets
its fingerprint, so an import interrupted by a reboot simply happens
again. A presentation becomes visible to a teacher only once every file
it needs is on disk.
A file that cannot be converted gets a card saying so, offering only
**Remove**. The teacher's download is untouched, which is what the card
says.
## The three things kept apart
This is the part most worth not breaking.
| | Changed by | Never changed by |
| --- | --- | --- |
| **Library thumbnail** | *Use this slide as the picture* in Quick Look | presenting, importing |
| **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;
* never touches `~/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.** Measured in the lab: focusing
another window on the same workspace dropped the slideshow to half the
screen with the library beside it. `window-state-event` puts it back for
as long as the presentation is running, and stops the moment Escape is
pressed so it can never fight the teacher's way out.
## 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".
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+129
View File
@@ -323,6 +323,35 @@ DVD_ENABLED=false
Nothing about `DEVICE_TYPE=laptop` turns those applications off; they are Nothing about `DEVICE_TYPE=laptop` turns those applications off; they are
off because those lines say so. 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 ### 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 The lab has no lid and no HDMI hotplug, so none of this is proven by a
@@ -444,3 +473,103 @@ With `LIVE_ENABLED=true`:
single after reinstall single after reinstall
- [ ] `tools/validate-trixie-release.sh` passes against the 0.1.37 package and - [ ] `tools/validate-trixie-release.sh` passes against the 0.1.37 package and
the 0.1.36 package 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 original download is still in Chrome's downloads, untouched
- [ ] Download the **PDF and the PPTX** of the same session: two cards,
the second titled `(2)`, neither overwriting the other
### Quick Look
- [ ] **Quick Look** opens and shows slide 1
- [ ] **Next** and **Previous** move through every slide
- [ ] The text and layout are right — check a slide with a Bible verse
- [ ] Go to the session-number slide, usually slide 2
- [ ] **Use this slide as the picture** changes the card
- [ ] **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`
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**
- [ ] 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
- [ ] The original download in Chrome is untouched
- [ ] It does not come back on the next scan or after a reboot
### The manual
- [ ] **Help** is on the bar, bottom left
- [ ] It opens with no address bar and no tabs
- [ ] It shows Browser, Presentations, sound and the common pages
- [ ] It does **not** mention Wonder Ink, Parent Call or the DVD
- [ ] It works with the network unplugged
- [ ] On a children's classroom machine it shows the opposite set
- [ ] Print `/usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf`
and check the conditional wording reads sensibly in a room that
does not have every feature
- [ ] The revision date is on the printed copy
### 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
+35
View File
@@ -101,6 +101,19 @@ DVD_ENABLED=true
# in the corner is then one more thing to explain. # in the corner is then one more thing to explain.
PARENT_CALL_ENABLED=true 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. # Which network this device is meant to be on.
# #
# wired an Ethernet cable is the intended connection. # 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 # 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. # in the corner is then one more thing to explain.
PARENT_CALL_ENABLED=true 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, "WONDERINK_ENABLED": True,
"DVD_ENABLED": True, "DVD_ENABLED": True,
"PARENT_CALL_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,
} }
+103 -4
View File
@@ -36,10 +36,24 @@ import re
# ifupdown's own spelling. Values may be quoted, and a psk may be a # ifupdown's own spelling. Values may be quoted, and a psk may be a
# 64-character hex key rather than a passphrase. # 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+(.*)$") _STANZA = re.compile(r"^\s*(iface|auto|allow-hotplug|source|source-directory)\s+(.*)$")
TRUTHY = {"1", "yes", "true", "on"}
def _unquote(value): def _unquote(value):
value = (value or "").strip() value = (value or "").strip()
@@ -63,7 +77,19 @@ def parse(text):
current = None current = None
for number, raw in enumerate(lines): 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) match = _STANZA.match(line)
if match: if match:
@@ -129,12 +155,57 @@ def credentials(stanza):
network inline returns no SSID: that one is not ours to move, and the network inline returns no SSID: that one is not ours to move, and the
caller must leave it alone rather than guess. 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 {} options = stanza.get("options") or {}
if "wpa-conf" in options and "wpa-ssid" not in options: 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: "): def comment_out(text, stanza, marker="# ekklesia-classroom: "):
@@ -160,3 +231,31 @@ def comment_out(text, stanza, marker="# ekklesia-classroom: "):
break break
return "".join(lines) 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
+807
View File
@@ -0,0 +1,807 @@
"""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 THERE IS A MANAGED COPY AT ALL.
A teacher downloads a session through ordinary Chrome, into ordinary
~/Downloads. That file stays exactly where Chrome put it and is never
touched again — not renamed, not moved, not deleted, not by the library
and not by the cleanup. The library keeps its OWN copy, and everything
after the import happens to that copy. So a teacher who tidies their
downloads cannot break a presentation, and a cleanup that removes a
presentation cannot take a teacher's file with it.
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 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 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.
Kept even after a teacher removes the presentation, so that Remove
means removed rather than removed-until-the-next-scan. The original
download is still in Downloads — untouched, as always — and without
this the importer would put it straight back.
"""
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
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)
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:
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
+98 -3
View File
@@ -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: The DVD control bar, Parent Call and the Presentations library all draw
one template, one set of measurements, one look. 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 pathlib import Path
from classroom_config import scaled from classroom_config import scaled
@@ -34,3 +36,96 @@ def render(scale, template=None):
css = css.replace(f"@{token}@", str(scaled(value, scale))) css = css.replace(f"@{token}@", str(scaled(value, scale)))
return css 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
+23 -1
View File
@@ -4,7 +4,7 @@ Section: misc
Priority: optional Priority: optional
Architecture: amd64 Architecture: amd64
Maintainer: Ekklesia Maintainer: Ekklesia
Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6 Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6, libreoffice-impress, libreoffice-core, poppler-utils, fonts-liberation, fonts-liberation-sans-narrow, fonts-croscore, fonts-crosextra-carlito, fonts-crosextra-caladea, fonts-urw-base35, fonts-roboto, fonts-open-sans, fonts-lato, fonts-montserrat, fonts-noto-extra
Recommends: vlc Recommends: vlc
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup
Description: Ekklesia managed classroom environment Description: Ekklesia managed classroom environment
@@ -36,6 +36,28 @@ Description: Ekklesia managed classroom environment
of this build. Chrome stays exactly where it was, for the ordinary of this build. Chrome stays exactly where it was, for the ordinary
browser. 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.
.
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 The Conflicts line is deliberate. A welcome tour, a software centre and
a phone pairing daemon are all things plasma-desktop recommends and a a phone pairing daemon are all things plasma-desktop recommends and a
room a teacher walks into should not have. room a teacher walks into should not have.
+7
View File
@@ -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-audio.service >/dev/null 2>&1 || true
systemctl --global enable ekklesia-classroom-desktop.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 # 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 # at the same time as it. Enabled rather than started: this install has
# just run the same command directly, below. # just run the same command directly, below.
+52 -2
View File
@@ -3,7 +3,7 @@
"""Match the classroom's panel to the machine it is on. """Match the classroom's panel to the machine it is on.
Which applications a device offers is decided here, once, and written into 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. from that layout at the next login.
Each application is its own question, and the shape of the machine is 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. 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 the disc DVD_ENABLED *and* a drive being present. The setting can
player take the player away from a machine that has a drive; player take the player away from a machine that has a drive;
nothing can put it on a machine that has none, because a nothing can put it on a machine that has none, because a
@@ -39,6 +44,7 @@ again at the next login.
""" """
import glob import glob
import json
import os import os
import re import re
import sys import sys
@@ -61,10 +67,25 @@ FEATURE_LINES = (
("WONDERINK", "WONDERINK_ENABLED"), ("WONDERINK", "WONDERINK_ENABLED"),
("DVD", "DVD_ENABLED"), ("DVD", "DVD_ENABLED"),
("PARENT_CALL", "PARENT_CALL_ENABLED"), ("PARENT_CALL", "PARENT_CALL_ENABLED"),
("PRESENTATIONS", "PRESENTATIONS_ENABLED"),
) )
MARKER = "/* CLASSROOM-FEATURES-BEGIN */" 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 # The kernel names optical drives sr0, sr1, ... regardless of bus. Both
# views are checked: /dev is what anything opening the drive will use, and # 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 # /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(): def main():
path = sys.argv[1] if len(sys.argv) > 1 else LAYOUT path = sys.argv[1] if len(sys.argv) > 1 else LAYOUT
@@ -137,8 +184,11 @@ def main():
return 0 return 0
after = before 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) after = rewrite(after, variable, answer)
if after == before: if after == before:
+172
View File
@@ -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())
+4 -89
View File
@@ -105,95 +105,10 @@ OVERALL_TIMEOUT = 210.0
CLOSE_AFTER_SUCCESS = 4.0 CLOSE_AFTER_SUCCESS = 4.0
def load_colours(): # The palette lives in classroom_style, so this card, the Presentations
"""The session's own palette, read from the colour scheme in use. # library and anything else the classroom draws cannot drift apart over
# what "the accent colour" means.
There is one set of design tokens in this classroom, and they live in from classroom_style import load_colours # noqa: E402,F401
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
STYLE = """ STYLE = """
+339
View File
@@ -0,0 +1,339 @@
#!/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 touches the teacher's download. The file Chrome wrote stays
where Chrome wrote it, with the name Chrome gave it, for as long as
the teacher leaves it there. Everything the library does is done to
its own copy.
It never 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 os
import shutil
import sys
import time
import uuid
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 import_one(source, root=None):
"""Take one finished download into the library.
Ordered so that an interruption is always recoverable. The metadata
is written first, saying "importing"; the sweep at startup throws
away anything still saying that and lets it be imported again. A
presentation only becomes visible to a teacher once every file it
needs is on disk.
"""
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 / source.name
data = {
"id": directory.name,
"state": "importing",
"title": library.readable_title(source.name),
"original_name": source.name,
"source_path": str(source),
"managed_copy": source.name,
"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:
# THE TEACHER'S OWN DOWNLOAD IS COPIED, NEVER MOVED.
shutil.copy2(source, managed)
if data["format"] == "pdf":
# Already the thing everything else is converted into. A
# session exported from Google Slides as a PDF keeps exactly
# the appearance Google gave it, with no conversion at all.
shutil.copy2(managed, presentation.slides)
else:
converted, error = library.convert_to_pdf(
managed, presentation.slides)
if not converted:
raise RuntimeError(error or "the file could not be converted")
pages, error = library.render_pages(
presentation.slides, presentation.pages_directory)
if not pages:
raise RuntimeError(error or "the slides could not be rendered")
data["pages"] = pages
data["title"] = library.unique_title(
library.readable_title(
source.name, library.first_text(presentation.slides)),
library.titles_in_use(root, exclude=presentation.id),
)
library.render_thumbnail(presentation.page_image(1),
presentation.thumbnail)
data["state"] = "ready"
presentation.save()
say(f"imported {data['title']!r} ({pages} slides)")
return presentation
except Exception as error: # noqa: BLE001
# A card that says it could not be opened, rather than a card
# that opens onto nothing. The teacher's download is untouched
# either way, which is what the message says.
data["state"] = "failed"
data["error"] = str(error)[:300]
presentation.save()
say(f"could not import {source.name}: {error}")
return presentation
def sweep(root=None):
"""Throw away imports that were interrupted, so they can happen again.
A reboot in the middle of a conversion leaves a directory that says
"importing" and a ledger entry claiming the download is dealt with.
Both go, and the next scan imports it properly.
"""
root = root if root is not None else library.library_root()
if not root.is_dir():
return 0
ledger = library.ledger(root)
stale = 0
for presentation in library.library(root):
if presentation.state != "importing":
continue
say(f"clearing an import that did not finish: {presentation.title!r}")
for mark, value in list(ledger.items()):
if value == presentation.id:
ledger.pop(mark, None)
shutil.rmtree(presentation.directory, ignore_errors=True)
stale += 1
if stale:
library.write_json(library.ledger_path(root), ledger)
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 library's own directory, and never the presentation on
the screen. The teacher's download is not part of this and is not
looked at.
"""
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}")
shutil.rmtree(presentation.directory, ignore_errors=True)
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 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
sweep()
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:
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())
+780
View File
@@ -0,0 +1,780 @@
#!/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 in Chrome 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 shutil
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;
}}
"""
def scaled_pixbuf(path, width):
try:
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(path), width, -1, True)
except (GLib.Error, OSError):
return None
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):
super().__init__(title=presentation.title)
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
# How many times fullscreen has been asked for without it
# sticking. See _state.
self.refused = 0
self.set_app_paintable(True)
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)
self.image = Gtk.Image()
self.add(self.image)
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._drawn_at = (0, 0, 0)
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()
want = (self.page, allocation.width, allocation.height)
if want == self._drawn_at:
return
if not path.exists():
return
pixbuf = self._fitted(path, allocation.width, allocation.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())
return original.scale_simple(
max(1, int(original.get_width() * ratio)),
max(1, int(original.get_height() * ratio)),
GdkPixbuf.InterpType.BILINEAR,
)
# -- 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
if self.on_finish is not None:
self.on_finish()
self.destroy()
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 this slide 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
self.page = 1
self.set_transient_for(parent)
self.set_modal(True)
self.set_default_size(1000, 760)
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 this slide as the picture")
self.choose.connect("clicked", self._choose)
actions.pack_start(self.choose, 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 = max(320, allocation.width or 900)
want = (self.page, width, 0)
if want == self._drawn_at and not force:
return
path = self.presentation.page_image(self.page)
pixbuf = scaled_pixbuf(path, width) if path.exists() else None
if pixbuf is not None:
self.slide.set_from_pixbuf(pixbuf)
self._drawn_at = want
total = self.presentation.pages
chosen = self.presentation.thumbnail_page
note = " · this is the library picture" if self.page == chosen else ""
self.counter.set_text(f"Slide {self.page} of {total}{note}")
self.back.set_sensitive(self.page > 1)
self.forward.set_sensitive(self.page < total)
self.choose.set_sensitive(self.page != chosen)
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(1180, 820)
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(True)
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)
for p in presentations
)
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(
"Download a presentation in Chrome and it will appear here.")
empty = Gtk.Label(
label="Nothing here yet.\n\n"
"Download a PowerPoint or PDF in Chrome, and it will "
"appear on this screen by itself.")
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)
broken = sum(1 for p in presentations if p.failed)
working = len(presentations) - ready - broken
parts = [f"{ready} ready to present"]
if working:
parts.append(f"{working} still being prepared")
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()
def card(self, presentation):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
box.get_style_context().add_class("card")
box.set_size_request(CARD_WIDTH, -1)
picture = Gtk.Image()
picture.get_style_context().add_class("thumb")
pixbuf = None
if presentation.thumbnail.exists():
pixbuf = scaled_pixbuf(presentation.thumbnail, CARD_WIDTH - 28)
if pixbuf is not None:
picture.set_from_pixbuf(pixbuf)
else:
picture.set_from_icon_name("view-presentation", Gtk.IconSize.DIALOG)
picture.set_pixel_size(96)
box.pack_start(picture, False, False, 0)
title = Gtk.Label(label=presentation.title, xalign=0)
title.get_style_context().add_class("card-title")
title.set_line_wrap(True)
title.set_max_width_chars(26)
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.failed:
# SHORT, AND IN WORDS A TEACHER CAN ACT ON.
#
# Never the converter's error text, never a path, never a
# terminal. The one useful fact is that their own download is
# still where they left it.
note.set_text("This file could not be opened.\n"
"Your download in Chrome is still there.")
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)
self.presenter.show_all()
self.presenter.present()
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 remove(self, presentation):
dialog = Gtk.MessageDialog(
transient_for=self,
modal=True,
message_type=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.NONE,
text=f"Remove “{presentation.title}”?",
)
dialog.format_secondary_text(
"It will be taken off this screen.\n"
"Your download in Chrome is not affected.")
dialog.add_button("Keep it", Gtk.ResponseType.CANCEL)
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
shutil.rmtree(presentation.directory, ignore_errors=True)
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())
+350 -39
View File
@@ -77,6 +77,11 @@ def nmcli(*args, check=True):
result = subprocess.run( result = subprocess.run(
["nmcli"] + list(args), capture_output=True, text=True, timeout=60, ["nmcli"] + list(args), capture_output=True, text=True, timeout=60,
check=False, 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: if check and result.returncode != 0:
@@ -86,14 +91,57 @@ def nmcli(*args, check=True):
return result 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(): def listing():
result = nmcli("-t", "-f", "NAME,TYPE,UUID", "connection", "show") for name, _uuid, kind in connections():
if "wireless" in kind:
for line in result.stdout.splitlines(): print(f" {name}")
parts = line.split(":")
if len(parts) >= 2 and "wireless" in parts[1]:
print(f" {parts[0]}")
return 0 return 0
@@ -105,7 +153,7 @@ def forget(name):
return 0 return 0
def add(ssid, password, name=None): def add(ssid, password, name=None, hidden=False):
name = name or ssid name = name or ssid
# Deliberately no connection.permissions. That single omission is the # 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 # Stored in the connection file, which is root-only, rather than
# in a login keyring that belongs to whoever is signed in. # in a login keyring that belongs to whoever is signed in.
"802-11-wireless-security.psk-flags", "0", "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: if password:
@@ -131,9 +188,13 @@ def add(ssid, password, name=None):
nmcli(*arguments) 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 # NetworkManager already writes 0600 root:root. Said out loud and
# enforced anyway, because this is the file the whole arrangement # enforced anyway, because this is the file the whole arrangement
# rests on. # rests on.
@@ -148,6 +209,28 @@ def add(ssid, password, name=None):
return 0 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): def run(*command):
"""Run a command, tolerating one that is not installed. """Run a command, tolerating one that is not installed.
@@ -198,16 +281,78 @@ def carrying(name, device):
"--active", check=False) "--active", check=False)
for line in (result.stdout or "").splitlines(): 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 True
return False 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(): def find_stanza():
"""The one wireless stanza ifupdown holds, or None, plus the file.""" """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: try:
with open(INTERFACES, "r", encoding="utf-8") as handle: with open(INTERFACES, "r", encoding="utf-8") as handle:
text = handle.read() text = handle.read()
@@ -244,20 +389,33 @@ def check():
return 0 return 0
device = stanza["name"] device = stanza["name"]
ssid, _ = ifupdown.credentials(stanza) found = ifupdown.profile(stanza)
say(f"NOTE: {device} is configured in {INTERFACES}, so ifupdown owns " say(f"NOTE: {device} is configured in {INTERFACES}, so ifupdown owns "
"it and") "it and")
say(" NetworkManager will report it unmanaged — the room's panel") say(" NetworkManager will report it unmanaged — the room's panel")
say(" will say there is no connection and will not find networks.") 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(" To move it, while watching, over SSH:")
say(" classroom-wifi --adopt --dry-run") say(" classroom-wifi --adopt --dry-run")
say(" classroom-wifi --adopt") say(" classroom-wifi --adopt")
else:
say(" Its credentials are not in that file, so it has to be "
"moved by hand.")
return 0 return 0
@@ -307,17 +465,37 @@ def adopt(dry_run=False):
return 0 return 0
device = stanza["name"] 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 " say(f"{device} is configured through a wpa_supplicant.conf rather "
"than named in this file") "than named in this file")
say("that one is not ours to move; adopt it by hand") say("that one is not ours to move; adopt it by hand")
return 1 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. # Never printed. The room's password is not diagnostic output.
say(f"{device}: ifupdown holds a wireless network " 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 # Refuse to start a hand-over that cannot be undone. ifdown releases
# the interface and ifup is the whole of the rollback; without them # the interface and ifup is the whole of the rollback; without them
@@ -337,17 +515,32 @@ def adopt(dry_run=False):
"run would refuse") "run would refuse")
say(f"would create a NetworkManager system connection for {ssid!r}") 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") say("would hand the interface over, and put it back if that failed")
return 0 return 0
existing = nmcli("-t", "-f", "NAME", "connection", "show", check=False) already = any(name == ssid for name, _uuid, _kind in connections())
already = ssid in (existing.stdout or "").splitlines()
if already: 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(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: else:
if add(ssid, psk) != 0: if add(ssid, psk, hidden=found["hidden"]) != 0:
return 1 return 1
backup = f"{INTERFACES}.before-ekklesia-classroom" 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 # timeout, an interrupt — the file goes back and ifupdown gets the
# interface again. Nothing below may raise past this. # interface again. Nothing below may raise past this.
try: 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: with open(INTERFACES, "w", encoding="utf-8") as handle:
handle.write(ifupdown.comment_out(text, stanza)) 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 # NetworkManager only re-reads which interfaces ifupdown claims
# when its configuration is reloaded. The link is still # when its configuration is reloaded.
# ifupdown's at this point, and NetworkManager does not touch a
# device it considers unmanaged, so this cannot drop it.
nmcli("general", "reload", "conf", check=False) nmcli("general", "reload", "conf", check=False)
run("ifdown", device)
nmcli("device", "set", device, "managed", "yes", check=False) 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 deadline = time.monotonic() + ADOPT_TIMEOUT
@@ -382,12 +606,15 @@ def adopt(dry_run=False):
if carrying(ssid, device) and online(): if carrying(ssid, device) and online():
say(f"{device} is NetworkManager's now, and the room is " say(f"{device} is NetworkManager's now, and the room is "
"online") "online")
say(f"NetworkManager reports {device} "
f"{device_state(device)}")
say("the panel will show the connection at the next login") say("the panel will show the connection at the next login")
return 0 return 0
time.sleep(3) 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 except BaseException as error: # noqa: BLE001 - see the comment above
say(f"the migration failed part way through: {error}") say(f"the migration failed part way through: {error}")
@@ -398,6 +625,48 @@ def adopt(dry_run=False):
return 1 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): def restore(device, ssid=None):
"""Put the interface back under ifupdown. """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 decide what that means for an exit code: for a failed migration it is
still a failure, and for `--rollback` it is a success. still a failure, and for `--rollback` it is a success.
""" """
backup = f"{INTERFACES}.before-ekklesia-classroom" put_interfaces_back()
if os.path.exists(backup):
shutil.copy2(backup, INTERFACES)
say(f"restored {INTERFACES} from {backup}")
nmcli("general", "reload", "conf", check=False) nmcli("general", "reload", "conf", check=False)
@@ -418,7 +683,12 @@ def restore(device, ssid=None):
if device: if device:
nmcli("device", "set", device, "managed", "no", check=False) 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") say(f"ifupdown has {device} again")
for _ in range(10): for _ in range(10):
@@ -428,6 +698,44 @@ def restore(device, ssid=None):
time.sleep(3) 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 " say("WARNING: the machine may be offline. The original "
f"{INTERFACES} is restored; try `ifup {device}` or a reboot") f"{INTERFACES} is restored; try `ifup {device}` or a reboot")
return False return False
@@ -444,6 +752,8 @@ def main():
help="remove a saved connection") help="remove a saved connection")
parser.add_argument("--open", action="store_true", parser.add_argument("--open", action="store_true",
help="the network has no password") 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", parser.add_argument("--adopt", action="store_true",
help="move an installer-configured Wi-Fi from " help="move an installer-configured Wi-Fi from "
"ifupdown into NetworkManager") "ifupdown into NetworkManager")
@@ -489,7 +799,8 @@ def main():
say("no password given") say("no password given")
return 1 return 1
return add(arguments.ssid, password, arguments.name) return add(arguments.ssid, password, arguments.name,
hidden=arguments.hidden)
if __name__ == "__main__": if __name__ == "__main__":
+6 -2
View File
@@ -62,7 +62,7 @@ sys.path.insert(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"), os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
) )
from classroom_config import enabled, read_device_config # noqa: E402 from classroom_config import feature, read_device_config # noqa: E402
HOME_PAGE = "https://app.wonderink.org/" HOME_PAGE = "https://app.wonderink.org/"
@@ -682,7 +682,11 @@ def main():
# A device that has no Wonder Ink has no Wonder Ink. The panel does not # A device that has no Wonder Ink has no Wonder Ink. The panel does not
# offer it, so nothing should reach here; saying it out loud is for the # offer it, so nothing should reach here; saying it out loud is for the
# technician who ran the command by hand and wants to know why. # technician who ran the command by hand and wants to know why.
if not enabled(config, "WONDERINK_ENABLED", default=True): # feature(), not enabled(..., default=True). The default belongs in
# classroom_config.FEATURES with every other application's, so that
# the panel and the command can never disagree about whether a device
# offers this — which is the whole reason that table exists.
if not feature(config, "WONDERINK_ENABLED"):
say("WONDERINK_ENABLED is false on this device; not starting") say("WONDERINK_ENABLED is false on this device; not starting")
return 0 return 0
@@ -0,0 +1,28 @@
[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
[Install]
WantedBy=plasma-workspace.target
+88 -10
View File
@@ -85,8 +85,16 @@ check("a desktop can have Wonder Ink taken away",
check("and keeps everything else", check("and keeps everything else",
desktop_no_ink["DVD"] is True and desktop_no_ink["PARENT_CALL"] is True) 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", unset = module.wanted({})
all(module.wanted({}).values()), str(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") 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") print("5. the bar that comes out, evaluated in node")
STUB = r""" STUB = r"""
const fs = require("fs"); const fs = require("fs");
const captured = {widgets: [], config: {}}; const captured = {widgets: [], config: {}, launchers: []};
let current = null; let current = null;
function makeWidget(type) { function makeWidget(type) {
const w = { const w = {
type: type, type: type,
currentConfigGroup: [], 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 () {} reloadConfig: function () {}
}; };
captured.widgets.push(type); captured.widgets.push(type);
@@ -172,8 +185,15 @@ else:
check("a classroom gets Wonder Ink, the browser and the player, in order", check("a classroom gets Wonder Ink, the browser and the player, in order",
every and every["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}", every and every["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}",
(every or {}).get("config", {}).get(LAUNCH, why)) (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", 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 Help beside it",
every and HELP in every["launchers"],
str((every or {}).get("launchers")))
no_ink, why = bar({"WONDERINK_ENABLED": "false"}) no_ink, why = bar({"WONDERINK_ENABLED": "false"})
check("a Bible study desktop has the browser first and no gap", check("a Bible study desktop has the browser first and no gap",
@@ -188,8 +208,50 @@ else:
bible and bible["config"].get(LAUNCH) == WEB, bible and bible["config"].get(LAUNCH) == WEB,
(bible or {}).get("config", {}).get(LAUNCH, why)) (bible or {}).get("config", {}).get(LAUNCH, why))
check("with no Parent Call button on it", check("with no Parent Call button on it",
bible and "org.kde.plasma.quicklaunch" not in bible["widgets"], bible and CALL not in bible["launchers"],
str((bible or {}).get("widgets"))) str((bible or {}).get("launchers")))
# Help is not optional. A teacher who needs the manual is by
# definition one who does not know where to look, so every room has
# it — what differs is which pages it opens on.
check("but Help is still there, because every room has a manual",
bible and HELP in bible["launchers"],
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", check("and the status corner is untouched",
bible and "org.kde.plasma.volume" in bible["widgets"] bible and "org.kde.plasma.volume" in bible["widgets"]
and "org.kde.plasma.digitalclock" in bible["widgets"] and "org.kde.plasma.digitalclock" in bible["widgets"]
@@ -227,12 +289,28 @@ print("7. a disabled application does not start either")
# talked into running it by a stale desktop entry or a technician's # talked into running it by a stale desktop entry or a technician's
# muscle memory. # muscle memory.
for script, setting in (("classroom-wonderink", "WONDERINK_ENABLED"), 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() text = (root / "scripts" / script).read_text()
check(f"{script} refuses when {setting} is false", check(f"{script} refuses when {setting} is false",
setting in text and "not starting" in text) setting in text and "not starting" in text)
check(f"{script} defaults to starting, so existing rooms are unaffected", check(f"{script} asks the shared helper rather than reading the file",
'default=True' in text or 'feature(' in text) "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()
print(f"{passed} passed, {failed} failed") print(f"{passed} passed, {failed} failed")
+263
View File
@@ -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
+9 -1
View File
@@ -166,7 +166,15 @@ check("it refuses to start when it could not undo itself",
"shutil.which" in wifi "shutil.which" in wifi
and "leaving the network exactly as it is" 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 # 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 # the once it is not, and the machine it would strand is a laptop in a
# locked room. # locked room.
+263
View File
@@ -0,0 +1,263 @@
#!/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 shutil
import sys
import tempfile
import time
from datetime import timedelta
from pathlib import Path
root = Path(sys.argv[1])
sys.path.insert(0, str(root / "lib"))
import classroom_presentations as library
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 remembers a download after its presentation is gone")
mark = library.fingerprint(done)
library.remember(mark, "some-id", shelf)
check("a fingerprint is path, size and time",
mark is not None and str(done.resolve()) in mark, str(mark))
check("it is written down", library.ledger(shelf).get(mark) == "some-id")
check("so Remove means removed, not removed until the next scan",
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")
shutil.rmtree(work, ignore_errors=True)
print()
print(f"{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)
PY
+411
View File
@@ -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
+22 -2
View File
@@ -207,8 +207,28 @@ check("there is no minimise button", "ButtonsOnRight=FX" in
print() print()
print("5. bounded Trixie validation") print("5. bounded Trixie validation")
check("container has an outer deadline", "720s" in validation_script) # THE PROPERTY, NOT THE NUMBER.
check("package installs have deadlines", "candidate-install 360" in validation_script) #
# 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) check("container service startup is suppressed", "/usr/sbin/policy-rc.d" in validation_script and "exit 101" in validation_script)
print() print()
+736
View File
@@ -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 &middot; {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())
+59 -3
View File
@@ -52,6 +52,15 @@ done
find "$ROOT/scripts" "$ROOT/lib" "$ROOT/tests" \ find "$ROOT/scripts" "$ROOT/lib" "$ROOT/tests" \
-name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true -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 for test in "$ROOT"/tests/test-*.sh; do
[[ -x "$test" ]] || continue [[ -x "$test" ]] || continue
@@ -348,9 +357,10 @@ install -Dm644 \
"$ROOT/config/dbus/org.kde.LogoutPrompt.service" \ "$ROOT/config/dbus/org.kde.LogoutPrompt.service" \
"$STAGE/usr/local/share/dbus-1/services/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 # Three marks the classroom draws itself, because nothing in Breeze says
# either thing: a lesson, and fetching a child's grown-up. # any of them: a lesson, fetching a child's grown-up, and tonight's
for icon in ekklesia-wonderink ekklesia-parent-call; do # slides.
for icon in ekklesia-wonderink ekklesia-parent-call ekklesia-presentations; do
install -Dm644 \ install -Dm644 \
"$ROOT/config/icons/${icon}.svg" \ "$ROOT/config/icons/${icon}.svg" \
"$STAGE/usr/share/icons/hicolor/scalable/apps/${icon}.svg" "$STAGE/usr/share/icons/hicolor/scalable/apps/${icon}.svg"
@@ -374,6 +384,50 @@ install -Dm755 \
"$ROOT/scripts/classroom-wonderink" \ "$ROOT/scripts/classroom-wonderink" \
"$STAGE/usr/local/bin/classroom-wonderink" "$STAGE/usr/local/bin/classroom-wonderink"
# 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 \ install -Dm755 \
"$ROOT/scripts/classroom-audio" \ "$ROOT/scripts/classroom-audio" \
"$STAGE/usr/local/bin/classroom-audio" "$STAGE/usr/local/bin/classroom-audio"
@@ -482,6 +536,8 @@ install -Dm644 \
"$ROOT/lib/classroom_icons.py" \ "$ROOT/lib/classroom_icons.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/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 \ install -Dm644 \
"$ROOT/lib/classroom_style.py" \ "$ROOT/lib/classroom_style.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_style.py" "$STAGE/usr/local/lib/ekklesia-classroom/classroom_style.py"
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Build a presentation to test the classroom's library with.
Representative test material, NOT the pastor's own files. The shape is
copied from the real Bible study decks and nothing else about them is:
slide 1 a generic opening, the same on every session
slide 2 the one that names the session
slide 3+ the session's content
That first pair is the whole reason Quick Look lets a teacher choose
which slide becomes the library thumbnail. A library that assumed slide
one would show every session under the same picture.
Writes a flat ODF presentation, which LibreOffice then converts to
whatever the test needs:
tools/lab/make-presentation --slides 12 --session 7 out.fodp
soffice --headless --convert-to pptx out.fodp
soffice --headless --convert-to pdf out.fodp
"""
import argparse
import html
import sys
HEADER = """<?xml version="1.0" encoding="UTF-8"?>
<office:document
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
office:version="1.3"
office:mimetype="application/vnd.oasis.opendocument.presentation">
<office:automatic-styles>
<style:style style:name="title" style:family="presentation">
<style:graphic-properties draw:fill="none" draw:stroke="none"/>
<style:paragraph-properties fo:text-align="center"/>
<style:text-properties fo:font-size="40pt" fo:font-weight="bold"
style:font-name-complex="Liberation Sans"/>
</style:style>
<style:style style:name="body" style:family="presentation">
<style:graphic-properties draw:fill="none" draw:stroke="none"/>
<style:text-properties fo:font-size="24pt"/>
</style:style>
</office:automatic-styles>
<office:body>
<office:presentation>
"""
FOOTER = """ </office:presentation>
</office:body>
</office:document>
"""
PAGE = """ <draw:page draw:name="{name}" draw:master-page-name="Default">
<draw:frame draw:style-name="title" draw:layer="layout"
svg:width="24cm" svg:height="4cm" svg:x="1.4cm" svg:y="3cm">
<draw:text-box>{title}</draw:text-box>
</draw:frame>
<draw:frame draw:style-name="body" draw:layer="layout"
svg:width="24cm" svg:height="8cm" svg:x="1.4cm" svg:y="8cm">
<draw:text-box>{body}</draw:text-box>
</draw:frame>
</draw:page>
"""
# Deliberately ordinary words. Nothing here is anybody's lesson.
POINTS = (
"Read the passage aloud together",
"What does this tell us about God?",
"Where do you see this in your own week?",
"One thing to carry into Sunday",
)
def paragraphs(lines):
return "".join(
f"<text:p>{html.escape(line)}</text:p>" for line in lines
)
def build(slides, session):
pages = []
# Slide 1 — the generic opening every session shares.
pages.append(PAGE.format(
name="page1",
title=paragraphs(["Wednesday Bible Study"]),
body=paragraphs(["River of Glory", "Welcome"]),
))
# Slide 2 — the one a teacher has to be able to pick as the thumbnail.
pages.append(PAGE.format(
name="page2",
title=paragraphs([f"Session {session}"]),
body=paragraphs([f"Session {session} of the autumn study",
"Tonight's passage and questions"]),
))
for number in range(3, slides + 1):
pages.append(PAGE.format(
name=f"page{number}",
title=paragraphs([f"Point {number - 2}"]),
body=paragraphs([
POINTS[(number - 3) % len(POINTS)],
f"(slide {number} of {slides})",
]),
))
return HEADER + "".join(pages) + FOOTER
def main():
parser = argparse.ArgumentParser(
description="Build representative Bible study test material.")
parser.add_argument("output", help="where to write the .fodp")
parser.add_argument("--slides", type=int, default=10,
help="how many slides in total (minimum 2)")
parser.add_argument("--session", type=int, default=7,
help="the session number slide 2 announces")
arguments = parser.parse_args()
if arguments.slides < 2:
print("a test deck needs at least the opening and the session slide",
file=sys.stderr)
return 2
with open(arguments.output, "w", encoding="utf-8") as handle:
handle.write(build(arguments.slides, arguments.session))
print(f"{arguments.output}: {arguments.slides} slides, session "
f"{arguments.session}")
return 0
if __name__ == "__main__":
sys.exit(main())
+198 -5
View File
@@ -29,7 +29,17 @@ if [[ -n "$previous" ]]; then
docker_args+=( -v "$previous:/packages/previous.deb:ro" ) docker_args+=( -v "$previous:/packages/previous.deb:ro" )
fi fi
timeout --signal=TERM --kill-after=15s 720s \ # 720s was enough while this package's heaviest dependency was Plasma.
# 0.2.6 adds LibreOffice and a dozen font packages for Presentations, and
# the run installs the PREVIOUS package as well as the candidate, so the
# container now does two full desktop installs. Measured: the previous
# install alone ran past 360s on the development VM and the whole run was
# killed before it reached a single assertion.
#
# Generous rather than tight, on purpose. This is the gate that has to
# say "the package is wrong", and a gate that says "the machine was busy"
# instead teaches everybody to re-run it until it passes.
timeout --signal=TERM --kill-after=15s 2700s \
docker "${docker_args[@]}" debian:trixie-slim \ docker "${docker_args[@]}" debian:trixie-slim \
bash -s -- "${previous:+/packages/previous.deb}" <<'CONTAINER' bash -s -- "${previous:+/packages/previous.deb}" <<'CONTAINER'
set -Eeuo pipefail set -Eeuo pipefail
@@ -74,12 +84,12 @@ run_logged() {
fi fi
} }
run_logged apt-update 120 apt-get update -qq run_logged apt-update 300 apt-get update -qq
previous="${1:-}" previous="${1:-}"
if [[ -n "$previous" ]]; then if [[ -n "$previous" ]]; then
run_logged previous-install 360 apt-get install -y -qq "$previous" run_logged previous-install 1200 apt-get install -y -qq "$previous"
cat > /etc/ekklesia-classroom/device.conf <<'CONFIG' cat > /etc/ekklesia-classroom/device.conf <<'CONFIG'
# Configuration carried forward from 0.1.36 # Configuration carried forward from 0.1.36
@@ -96,7 +106,7 @@ CUSTOM_SETTING=preserve-me-too
CONFIG CONFIG
fi fi
run_logged candidate-install 360 apt-get install -y -qq /packages/candidate.deb run_logged candidate-install 1200 apt-get install -y -qq /packages/candidate.deb
command -v nmtui >/dev/null command -v nmtui >/dev/null
command -v lpadmin >/dev/null command -v lpadmin >/dev/null
@@ -143,6 +153,189 @@ grep -q "classroom-wifi --check" /usr/local/bin/classroom-migrate
PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 -c \ PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 -c \
'import classroom_network; classroom_network.parse("")' 'import classroom_network; classroom_network.parse("")'
# THE 0.2.5 WI-FI REGRESSION, ASSERTED ON A REAL INSTALL.
#
# The prayer-room laptop's network is hidden and its name has a space in
# it. The migration dropped the hidden flag, so the connection it built
# could never associate. Checked against the module the package actually
# installed, not the one in a checkout.
PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 <<'WIFI_CHECK'
import classroom_network as n
stanza = n.wireless(n.parse(
'allow-hotplug wlp2s0\n'
'iface wlp2s0 inet dhcp\n'
'\twpa-ssid "ROGC Classroom"\n'
'\twpa-psk se#cret\n'
'\twpa-scan-ssid 1\n'
), lambda device: True)[0]
found = n.profile(stanza)
assert found["ssid"] == "ROGC Classroom", found
assert found["hidden"] is True, "the hidden flag was dropped again"
assert found["psk"] == "se#cret", "a # in the password was truncated again"
assert found["key_mgmt"] == "wpa-psk", found
# A rollback puts back exactly what it commented out, and nothing else.
before = ("auto enp1s0\niface enp1s0 inet static\n\taddress 10.40.0.9/24\n\n"
"allow-hotplug wlp2s0\niface wlp2s0 inet dhcp\n\twpa-ssid Sanctuary\n")
after = n.comment_out(before, n.wireless(n.parse(before),
lambda d: d == "wlp2s0")[0])
restored, count = n.uncomment(after)
assert count, "nothing was marked, so a rollback would have nothing to undo"
assert restored == before, "the rollback did not restore the file exactly"
WIFI_CHECK
# And the command itself carries both halves of the fix.
grep -q '802-11-wireless.hidden' /usr/local/bin/classroom-wifi
grep -q 'managed", "yes"' /usr/local/bin/classroom-wifi
# PRESENTATIONS.
#
# Off by default, with everything it needs present on every machine, so
# enabling it on a device is one line in device.conf and a reboot.
command -v classroom-presentations >/dev/null
command -v classroom-presentation-import >/dev/null
command -v soffice >/dev/null
command -v pdftoppm >/dev/null
command -v pdfinfo >/dev/null
[[ -f /etc/systemd/user/ekklesia-classroom-presentations.service ]]
# Enabled the same way the audio policy is, so it actually reaches a
# session rather than merely being installed. A unit file nobody starts
# is a teacher whose download never appears.
[[ -L /etc/systemd/user/plasma-workspace.target.wants/ekklesia-classroom-presentations.service ]]
[[ -f /usr/share/applications/ekklesia-presentations.desktop ]]
[[ -f /usr/share/icons/hicolor/scalable/apps/ekklesia-presentations.svg ]]
# A classroom that has never heard of the setting comes up without it.
PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 -c \
'from classroom_config import feature; assert feature({}, "PRESENTATIONS_ENABLED") is False'
# Disabled is the default, so both commands decline to run and say why.
classroom-presentations 2>&1 | grep -q 'PRESENTATIONS_ENABLED is false'
classroom-presentation-import --once 2>&1 | grep -q 'PRESENTATIONS_ENABLED is false'
# THE CONVERSION THIS WHOLE FEATURE RESTS ON.
#
# On a real Debian, with the fonts this package installs. No window, no
# splash, no first-run dialog, no editor — measured rather than assumed,
# because "LibreOffice is installed" is not the same claim as "a PPTX
# becomes slides a teacher can be shown".
mkdir -p /tmp/deck
python3 <<'MAKE_DECK'
page = ('<draw:page draw:name="p%d"><draw:frame svg:width="20cm" '
'svg:height="3cm" svg:x="1cm" svg:y="2cm"><draw:text-box>'
'<text:p>%s</text:p></draw:text-box></draw:frame></draw:page>')
open("/tmp/deck/deck.fodp", "w").write(
'<?xml version="1.0" encoding="UTF-8"?>'
'<office:document '
'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" '
'xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" '
'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" '
'xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" '
'xmlns:presentation='
'"urn:oasis:names:tc:opendocument:xmlns:presentation:1.0" '
'office:version="1.3" '
'office:mimetype="application/vnd.oasis.opendocument.presentation">'
'<office:body><office:presentation>'
+ (page % (1, "Wednesday Bible Study"))
+ (page % (2, "Session 7"))
+ '</office:presentation></office:body></office:document>')
MAKE_DECK
HOME=/tmp/deck timeout 240 soffice --headless --norestore --nologo \
--convert-to pptx --outdir /tmp/deck /tmp/deck/deck.fodp >/dev/null 2>&1
[[ -f /tmp/deck/deck.pptx ]]
PYTHONPATH=/usr/local/lib/ekklesia-classroom HOME=/tmp/deck python3 <<'CONVERT_CHECK'
import pathlib
import classroom_presentations as library
ok, error = library.convert_to_pdf("/tmp/deck/deck.pptx", "/tmp/deck/slides.pdf")
assert ok, f"PPTX could not be converted: {error}"
pages = library.page_count("/tmp/deck/slides.pdf")
assert pages == 2, f"expected 2 slides, got {pages}"
made, error = library.render_pages("/tmp/deck/slides.pdf", "/tmp/deck/pages")
assert made == 2, f"expected 2 rendered pages, got {made}: {error}"
# The library asks for page-001.png whatever the page count's width is.
for number in (1, 2):
path = pathlib.Path(f"/tmp/deck/pages/page-{number:03d}.png")
assert path.exists(), f"{path} was not rendered"
assert path.stat().st_size > 0, f"{path} is empty"
# Slide 2 is the one a teacher picks as the card picture, so its text has
# to have survived the conversion.
text = library.first_text("/tmp/deck/slides.pdf", pages=2)
assert "Session 7" in text, f"text did not survive the conversion: {text!r}"
CONVERT_CHECK
# The fonts a Google Slides export asks for, as files on disk.
#
# Asserted by path rather than through fc-list: fc-list belongs to the
# fontconfig package, which nothing here pulls in, so the check passed
# vacuously until it didn't and then failed for a reason that had nothing
# to do with the fonts. What LibreOffice actually needs is the file.
[[ -f /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf ]]
[[ -f /usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf ]]
[[ -f /usr/share/fonts/truetype/liberation/LiberationSansNarrow-Regular.ttf ]]
[[ -f /usr/share/fonts/truetype/crosextra/Carlito-Regular.ttf ]]
[[ -f /usr/share/fonts/truetype/crosextra/Caladea-Regular.ttf ]]
[[ -f /usr/share/fonts/truetype/croscore/Arimo-Regular.ttf ]]
# And the one that must NOT be here, because it prompts for a licence.
! dpkg -l ttf-mscorefonts-installer 2>/dev/null | grep -q '^ii'
# THE MANUAL.
#
# Installed on every machine, works with no network at all, and shows
# only the features this device has.
[[ -f /usr/local/share/ekklesia-classroom/manual/classroom-manual.html ]]
[[ -f /usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf ]]
command -v classroom-manual >/dev/null
[[ -f /usr/share/applications/ekklesia-manual.desktop ]]
head -c 5 /usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf \
| grep -q '%PDF-'
grep -aq 'Revision' /usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf
! grep -qE 'https?://' \
/usr/local/share/ekklesia-classroom/manual/classroom-manual.html
# The manifest the manual reads carries feature NAMES and nothing else:
# no broker password, no addresses, nothing else out of device.conf.
printf 'PRESENTATIONS_ENABLED=true\nWONDERINK_ENABLED=false\nMQTT_PASSWORD=never-a-teacher\n' \
>> /etc/ekklesia-classroom/device.conf
classroom-desktop-layout >/dev/null 2>&1 || true
[[ -f /run/ekklesia-classroom/features.json ]]
grep -q 'PRESENTATIONS_ENABLED' /run/ekklesia-classroom/features.json
! grep -q 'WONDERINK_ENABLED' /run/ekklesia-classroom/features.json
! grep -q 'never-a-teacher' /run/ekklesia-classroom/features.json
! grep -q '10.40.0.2' /run/ekklesia-classroom/features.json
classroom-manual --features | grep -qE 'PRESENTATIONS_ENABLED +shown'
classroom-manual --features | grep -qE 'WONDERINK_ENABLED +hidden'
# The panel offers Presentations only where it is enabled, and the three
# applications that were already on the bar keep their order.
layout=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js
grep -q 'var PRESENTATIONS = true;' "$layout"
grep -q 'ekklesia-presentations.desktop' "$layout"
grep -q 'ekklesia-manual.desktop' "$layout"
python3 - "$layout" <<'ORDER_CHECK'
import re
import sys
text = open(sys.argv[1]).read()
order = re.findall(r'launchers\.push\("applications:([^"]+)"\)', text)
expected = ["ekklesia-wonderink.desktop", "google-chrome.desktop",
"ekklesia-dvd.desktop", "ekklesia-presentations.desktop"]
assert order == expected, f"the dock order changed: {order}"
ORDER_CHECK
sed -i '/^PRESENTATIONS_ENABLED=true$/d;/^WONDERINK_ENABLED=false$/d;/^MQTT_PASSWORD=never-a-teacher$/d' \
/etc/ekklesia-classroom/device.conf
# The policy module and the daemon agree on what they import, on a machine # The policy module and the daemon agree on what they import, on a machine
# rather than in a checkout. # rather than in a checkout.
PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 -c \ PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 -c \
@@ -212,7 +405,7 @@ if [[ -n "$previous" ]]; then
[[ "$(grep -c "^${setting}=" /etc/ekklesia-classroom/device.conf)" -eq 1 ]] [[ "$(grep -c "^${setting}=" /etc/ekklesia-classroom/device.conf)" -eq 1 ]]
done done
run_logged candidate-reinstall 360 \ run_logged candidate-reinstall 900 \
apt-get install --reinstall -y -qq /packages/candidate.deb apt-get install --reinstall -y -qq /packages/candidate.deb
# An install that changes nothing must change nothing. # An install that changes nothing must change nothing.