Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec1e0d1cd7 | ||
|
|
32ee0b544a | ||
|
|
9c864a1e44 | ||
|
|
89b24acff0 | ||
|
|
95ae054f6f | ||
|
|
f2942a2946 | ||
|
|
55f4c183c7 | ||
|
|
30d2d99230 | ||
|
|
fe08363534 | ||
|
|
b8688d00a7 |
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" width="22" height="22">
|
||||||
|
<!-- The comment lives inside <svg>, not above it. gdk-pixbuf
|
||||||
|
recognises an SVG by sniffing the first bytes of the file, and a
|
||||||
|
comment before the opening tag pushes it out of that window:
|
||||||
|
every GTK program then reports "couldn't recognize the image file
|
||||||
|
format" while Qt renders it happily. Measured on both of the
|
||||||
|
classroom's other icons, and not learned again here. -->
|
||||||
|
<!-- Presentations.
|
||||||
|
|
||||||
|
Breeze's own view-presentation is a bar chart on a board, which in
|
||||||
|
a church reads as a business meeting rather than as tonight's
|
||||||
|
Bible study. x-office-presentation is a projector screen and comes
|
||||||
|
closest, but it is a filetype mark: it says "a file of this kind"
|
||||||
|
where the panel needs "the place your slides are".
|
||||||
|
|
||||||
|
So: a screen on a stand with a slide showing on it — a title line
|
||||||
|
and two lines of text, which is the shape of every one of these
|
||||||
|
sessions. Same 22-unit grid, same single-path style and the same
|
||||||
|
ColorScheme-Text class as Parent Call, so it recolours with the
|
||||||
|
session and needs no second file for the light theme. -->
|
||||||
|
<defs>
|
||||||
|
<style type="text/css">.ColorScheme-Text { color: #fcfcfc; }</style>
|
||||||
|
</defs>
|
||||||
|
<g class="ColorScheme-Text" style="fill:currentColor;fill-opacity:1;stroke:none">
|
||||||
|
<!-- The screen: an outline, so the slide inside it reads as content
|
||||||
|
rather than the whole mark being one solid block at 22px. -->
|
||||||
|
<path d="M2 3h18a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm1 2v8h16V5z"/>
|
||||||
|
<!-- The slide on it: a title, and two lines under it. -->
|
||||||
|
<path d="M5 7h7v1.5H5zm0 3h12v1H5zm0 2.2h9v1H5z" style="fill-opacity:0.85"/>
|
||||||
|
<!-- The stand. A presentation is a thing set up in front of a room,
|
||||||
|
and the two feet are what say so at this size. -->
|
||||||
|
<path d="M10.25 16h1.5v3.2l2.9 1.45-.67 1.34-2.98-1.49-2.98 1.49-.67-1.34 2.9-1.45z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Help
|
||||||
|
GenericName=Classroom manual
|
||||||
|
Comment=How to use this classroom computer
|
||||||
|
# Breeze's own. A question mark in a circle is the one mark that needs no
|
||||||
|
# explaining, and this is the one place in the classroom where borrowing
|
||||||
|
# from Breeze is better than drawing something of our own.
|
||||||
|
Icon=help-contents
|
||||||
|
Exec=/usr/local/bin/classroom-manual
|
||||||
|
Terminal=false
|
||||||
|
Categories=Documentation;
|
||||||
|
StartupNotify=true
|
||||||
|
# Chrome in application mode reports the app id Chrome derives from the
|
||||||
|
# --app URL, not this entry, so the panel cannot match the manual window
|
||||||
|
# to this launcher. That is deliberate and harmless: Help is a Quick
|
||||||
|
# Launch button, which is a launcher and never a task, so there is no
|
||||||
|
# button here waiting to be matched.
|
||||||
|
NoDisplay=false
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Presentations
|
||||||
|
GenericName=Slides
|
||||||
|
Comment=Show a presentation that was downloaded in the browser
|
||||||
|
# Drawn for this: a screen with a slide on it and a corner turned, which
|
||||||
|
# is a presentation rather than a document or a photograph. Breeze's
|
||||||
|
# view-presentation is a chart on a board, which in a church reads as a
|
||||||
|
# business meeting. Uses ColorScheme-Text like the classroom's other two
|
||||||
|
# marks, so it recolours with the session and needs no light-theme twin.
|
||||||
|
Icon=ekklesia-presentations
|
||||||
|
Exec=/usr/local/bin/classroom-presentations
|
||||||
|
Terminal=false
|
||||||
|
Categories=Office;Presentation;
|
||||||
|
StartupNotify=true
|
||||||
|
# How the panel matches the window on screen to this launcher. GTK sets
|
||||||
|
# the Wayland app id from GLib.set_prgname, which classroom-presentations
|
||||||
|
# sets to exactly this. A mismatch here is not an error anywhere — it is
|
||||||
|
# silently two buttons on the panel instead of one.
|
||||||
|
StartupWMClass=ekklesia-presentations
|
||||||
|
# One window. Pressing the panel button again brings the library forward
|
||||||
|
# rather than opening a second copy of it.
|
||||||
|
X-GNOME-SingleWindow=true
|
||||||
|
# Deliberately visible to the applications index, like the disc player and
|
||||||
|
# Parent Call: the panel resolves its launcher through that index, and an
|
||||||
|
# entry hidden from it gives the bar a blank page instead of the mark.
|
||||||
|
# There is no application menu in this classroom for it to clutter.
|
||||||
|
NoDisplay=false
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
title: Your classroom computer
|
||||||
|
order: 0
|
||||||
|
---
|
||||||
|
|
||||||
|
This computer is set up for one thing: running your room. It switches
|
||||||
|
itself on into the classroom, and there is nothing on it to break.
|
||||||
|
|
||||||
|
You do not need a password. You do not need to sign in. You do not need
|
||||||
|
to shut it down at the end — leave it on.
|
||||||
|
|
||||||
|
## The bar along the bottom
|
||||||
|
|
||||||
|
Everything you need is on the bar at the bottom of the screen.
|
||||||
|
|
||||||
|
The buttons in the middle are the things you can open. Which ones are
|
||||||
|
there depends on what your room is set up for, so your bar may have
|
||||||
|
fewer buttons than another room's.
|
||||||
|
|
||||||
|
The right-hand end shows the network, the volume, any messages and the
|
||||||
|
clock.
|
||||||
|
|
||||||
|
To open something, click its button once. To come back to it later,
|
||||||
|
click the same button again.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
title: The Browser
|
||||||
|
order: 10
|
||||||
|
---
|
||||||
|
|
||||||
|
The **Browser** button opens the web: YouTube, a song, a video clip, a
|
||||||
|
website a lesson sends you to.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
1. Click **Browser** on the bar.
|
||||||
|
2. Type where you want to go in the bar at the top, or click **Home** to
|
||||||
|
go back to YouTube.
|
||||||
|
|
||||||
|
The room stays signed in to YouTube, so you do not have to.
|
||||||
|
|
||||||
|
## Playing a video on the television
|
||||||
|
|
||||||
|
Click the full-screen button in the corner of the video, the same as you
|
||||||
|
would at home. Press **Esc** to come back out of full screen.
|
||||||
|
|
||||||
|
## When you have finished
|
||||||
|
|
||||||
|
Leave it open, or close the window. The next person can open it again
|
||||||
|
from the bar.
|
||||||
|
|
||||||
|
Anything you sign in to yourself — a personal account, an email — sign
|
||||||
|
out of before you leave. The computer is shared.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
title: Sound and volume
|
||||||
|
order: 20
|
||||||
|
---
|
||||||
|
|
||||||
|
Sound goes to the television when one is connected, and to the
|
||||||
|
computer's own speakers when one is not. You do not have to choose.
|
||||||
|
|
||||||
|
## Changing the volume
|
||||||
|
|
||||||
|
Use the volume keys on the keyboard, or click the speaker at the
|
||||||
|
right-hand end of the bar and drag the slider.
|
||||||
|
|
||||||
|
A large volume bar appears on the screen while you are changing it, so
|
||||||
|
you can see it from across the room.
|
||||||
|
|
||||||
|
## If there is no sound
|
||||||
|
|
||||||
|
1. Check the volume on the **television** as well as on the computer.
|
||||||
|
Both have to be up.
|
||||||
|
2. Check the television is showing this computer, and not something else
|
||||||
|
plugged into it.
|
||||||
|
3. If the television was switched on after the computer, the sound may
|
||||||
|
still be going to the computer's own speakers. Turn the volume down
|
||||||
|
and back up once; that is enough to move it across.
|
||||||
|
|
||||||
|
If none of that works, tell the office. Do not unplug anything.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
title: Presentations
|
||||||
|
order: 40
|
||||||
|
feature: PRESENTATIONS_ENABLED
|
||||||
|
printed-if: If your computer has a Presentations button on the bar
|
||||||
|
---
|
||||||
|
|
||||||
|
**Presentations** holds the slides for your sessions. You download a
|
||||||
|
presentation in the browser once, and from then on it is on this screen,
|
||||||
|
ready to show on the television.
|
||||||
|
|
||||||
|
## Getting a presentation onto the computer
|
||||||
|
|
||||||
|
1. Click **Browser** and go to wherever the presentation is — your
|
||||||
|
email, a shared folder, wherever it was sent to you.
|
||||||
|
2. Download it the way you normally would.
|
||||||
|
3. Wait until the download has finished.
|
||||||
|
|
||||||
|
That is all. It appears in **Presentations** by itself a few seconds
|
||||||
|
later. You do not have to save it anywhere special, move it, or open it.
|
||||||
|
|
||||||
|
PowerPoint files and PDF files both work.
|
||||||
|
|
||||||
|
Your download stays in the browser exactly as it was. Nothing here
|
||||||
|
changes it, moves it or deletes it.
|
||||||
|
|
||||||
|
## Opening the library
|
||||||
|
|
||||||
|
Click **Presentations** on the bar.
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
* **Next** and **Previous** move through the slides.
|
||||||
|
* **Back to Presentations** returns to the library.
|
||||||
|
* **Present** starts the slideshow straight away.
|
||||||
|
|
||||||
|
Looking through a session in Quick Look does not count as having
|
||||||
|
presented it.
|
||||||
|
|
||||||
|
## Changing the picture on the card
|
||||||
|
|
||||||
|
The first slide of a session is often the same opening slide as every
|
||||||
|
other session, which makes the cards hard to tell apart. Use the slide
|
||||||
|
that names the session instead — usually the second one.
|
||||||
|
|
||||||
|
1. Click **Quick Look**.
|
||||||
|
2. Press **Next** until you reach the slide you want.
|
||||||
|
3. Click **Use this slide as the picture**.
|
||||||
|
|
||||||
|
The card changes straight away, and stays that way — after you close the
|
||||||
|
library, and after the computer is switched off and on again.
|
||||||
|
|
||||||
|
Choosing the picture does not change where the slideshow starts. It
|
||||||
|
always starts at the first slide.
|
||||||
|
|
||||||
|
## Showing a presentation
|
||||||
|
|
||||||
|
Click **Present**, on the card or inside Quick Look.
|
||||||
|
|
||||||
|
The slides fill the whole screen. Nothing else is on it.
|
||||||
|
|
||||||
|
| To do this | Press |
|
||||||
|
| --- | --- |
|
||||||
|
| Next slide | **Space**, the **right arrow**, the **down arrow** or **Page Down** |
|
||||||
|
| Previous slide | The **left arrow**, the **up arrow** or **Page Up** |
|
||||||
|
| First slide | **Home** |
|
||||||
|
| Last slide | **End** |
|
||||||
|
| Finish | **Esc** |
|
||||||
|
|
||||||
|
A presentation remote works too — the forward and back buttons on the
|
||||||
|
clicker are the same as Page Down and Page Up.
|
||||||
|
|
||||||
|
## Finishing
|
||||||
|
|
||||||
|
Press **Esc**.
|
||||||
|
|
||||||
|
You come straight back to the library. Going past the last slide does
|
||||||
|
the same thing.
|
||||||
|
|
||||||
|
## Removing a presentation
|
||||||
|
|
||||||
|
Click **Remove** on its card, and confirm.
|
||||||
|
|
||||||
|
That takes it off this screen. Your download in the browser is not
|
||||||
|
touched, so you can always get it back by downloading it again.
|
||||||
|
|
||||||
|
## Presentations you no longer need
|
||||||
|
|
||||||
|
The computer tidies up after itself, so the library does not slowly fill
|
||||||
|
up with old sessions.
|
||||||
|
|
||||||
|
A session you have shown is kept for a month after the last time you
|
||||||
|
showed it. Showing it again starts that month over.
|
||||||
|
|
||||||
|
A session you downloaded but never showed is kept for six months.
|
||||||
|
|
||||||
|
You do not have to do anything about this, and nothing disappears while
|
||||||
|
you are using it.
|
||||||
|
|
||||||
|
## If a presentation will not open
|
||||||
|
|
||||||
|
Its card says so, and offers only **Remove**. Usually the file did not
|
||||||
|
download properly.
|
||||||
|
|
||||||
|
Download it again. Your original download is still there either way.
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
title: Parent Call
|
||||||
|
order: 60
|
||||||
|
feature: PARENT_CALL_ENABLED
|
||||||
|
printed-if: If your computer has a Parent Call button in the bottom-left corner
|
||||||
|
---
|
||||||
|
|
||||||
|
**Parent Call** asks the sanctuary to fetch a child's grown-up, without
|
||||||
|
you leaving the room.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
1. Click **Parent Call**, in the bottom-left corner of the screen.
|
||||||
|
2. Type the child's name.
|
||||||
|
3. Click **Send**.
|
||||||
|
|
||||||
|
The card tells you what is happening. **Sent to sanctuary** means
|
||||||
|
somebody there has it.
|
||||||
|
|
||||||
|
## What it does and does not promise
|
||||||
|
|
||||||
|
**Sent to sanctuary** means the message arrived. It does not mean it has
|
||||||
|
been read out yet — somebody there decides when.
|
||||||
|
|
||||||
|
If it cannot reach the sanctuary it says so plainly. It will never tell
|
||||||
|
you a call went through when it did not.
|
||||||
|
|
||||||
|
If that happens, send somebody.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
title: Playing a DVD
|
||||||
|
order: 70
|
||||||
|
feature: DVD_ENABLED
|
||||||
|
printed-if: If your computer has a DVD button and a disc drive
|
||||||
|
---
|
||||||
|
|
||||||
|
## Playing a disc
|
||||||
|
|
||||||
|
1. Put the disc in the drive.
|
||||||
|
2. Wait for **DVD Ready** to appear.
|
||||||
|
3. Click **DVD** on the bar.
|
||||||
|
|
||||||
|
The disc fills the screen and plays on the television. Discs never start
|
||||||
|
on their own — it always takes a button press.
|
||||||
|
|
||||||
|
## While it is playing
|
||||||
|
|
||||||
|
Move the mouse to the very bottom of the screen and a row of controls
|
||||||
|
slides up: previous chapter, play and pause, next chapter, the disc
|
||||||
|
menu, eject, and close.
|
||||||
|
|
||||||
|
**Space** also plays and pauses, and the arrow keys move around the disc
|
||||||
|
menu.
|
||||||
|
|
||||||
|
## Finishing
|
||||||
|
|
||||||
|
Click **✕** on that row to stop, or **⏏** to stop and eject the disc.
|
||||||
|
|
||||||
|
Take the disc with you.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
title: Printing
|
||||||
|
order: 80
|
||||||
|
---
|
||||||
|
|
||||||
|
Printing goes to the church printer. There is nothing to choose and
|
||||||
|
nothing to set up.
|
||||||
|
|
||||||
|
## Printing something
|
||||||
|
|
||||||
|
1. Press **Ctrl+P**, or use the **Print** button on the page you are
|
||||||
|
looking at.
|
||||||
|
2. Check the number of copies.
|
||||||
|
3. Click **Print**.
|
||||||
|
|
||||||
|
## If nothing comes out
|
||||||
|
|
||||||
|
The printer may be off, out of paper, or somebody else's job may be
|
||||||
|
ahead of yours. Your job waits in the queue and prints when the printer
|
||||||
|
is ready again — you do not need to send it twice.
|
||||||
|
|
||||||
|
Sending it again two or three times is how ten copies of the same
|
||||||
|
worksheet appear later.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
title: If something goes wrong
|
||||||
|
order: 90
|
||||||
|
---
|
||||||
|
|
||||||
|
Almost nothing here needs a technical person, and nothing you can do
|
||||||
|
from the keyboard will break the computer.
|
||||||
|
|
||||||
|
## The screen is black
|
||||||
|
|
||||||
|
Move the mouse or press a key. If it stays black, check the television
|
||||||
|
is on and showing this computer.
|
||||||
|
|
||||||
|
## A window will not close
|
||||||
|
|
||||||
|
Click its button on the bar to bring it back, and close it normally.
|
||||||
|
|
||||||
|
## The bar has gone
|
||||||
|
|
||||||
|
Wait a few seconds; it puts itself back.
|
||||||
|
|
||||||
|
If it does not, switch the computer off at the power button, wait ten
|
||||||
|
seconds, and switch it on again. It comes back to the same screen it
|
||||||
|
always does.
|
||||||
|
|
||||||
|
## Something asks for a password
|
||||||
|
|
||||||
|
Nothing a teacher needs to do asks for a password. If a password box
|
||||||
|
appears, you have reached something you did not mean to — press **Esc**
|
||||||
|
or click **Cancel**, and carry on.
|
||||||
|
|
||||||
|
## It is still wrong
|
||||||
|
|
||||||
|
Tell the office, and say **which room** and **what you were doing when
|
||||||
|
it happened**. That second part is the one that saves everybody time.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# The teacher's manual
|
||||||
|
|
||||||
|
**This directory is the only place teacher instructions are written.**
|
||||||
|
|
||||||
|
Everything a teacher reads comes out of the `.md` files beside this one:
|
||||||
|
|
||||||
|
* the **digital manual** on each classroom computer, which shows only the
|
||||||
|
features that computer actually has; and
|
||||||
|
* the **printable PDF**, which covers every feature with wording that
|
||||||
|
stays true in a room that does not have all of them.
|
||||||
|
|
||||||
|
Both are built by `tools/build-manual` from these files. There is no
|
||||||
|
second copy to keep in step, and there is no per-room manual: a room
|
||||||
|
differs from another room by its `device.conf`, and the manual reads the
|
||||||
|
same setting the panel does.
|
||||||
|
|
||||||
|
## Writing a page
|
||||||
|
|
||||||
|
Each page is Markdown with a small header:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: Presentations
|
||||||
|
order: 40
|
||||||
|
feature: PRESENTATIONS_ENABLED
|
||||||
|
printed-if: If your computer has a Presentations button
|
||||||
|
---
|
||||||
|
|
||||||
|
## Downloading a presentation
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| `title` | The heading, and the entry in the contents |
|
||||||
|
| `order` | Where it comes. Common pages are 00–39, optional ones 40+ |
|
||||||
|
| `feature` | The `device.conf` setting that decides whether a computer shows this page. Leave it out for a page every room gets |
|
||||||
|
| `printed-if` | The conditional sentence the printed manual puts above the page. Required whenever `feature` is set |
|
||||||
|
|
||||||
|
Only `##` and `###` headings, ordinary paragraphs, `*` bullets, `1.`
|
||||||
|
numbered steps, simple `|` tables, `` pictures
|
||||||
|
and **bold** are supported. That is deliberate: the same text has to come
|
||||||
|
out well as a web page and as print, and a manual for teachers does not
|
||||||
|
need anything else.
|
||||||
|
|
||||||
|
## Writing style
|
||||||
|
|
||||||
|
The existing pages set it, and it matters more than the format:
|
||||||
|
|
||||||
|
* Say what the teacher does, not what the computer does.
|
||||||
|
* One instruction per step, in the order they happen.
|
||||||
|
* Name things the way the screen names them — **Present**, **Quick
|
||||||
|
Look**, the **bar** along the bottom.
|
||||||
|
* No paths, no package names, no settings files, no version numbers.
|
||||||
|
* If something can go wrong, say what to do about it in the same breath.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```
|
||||||
|
tools/build-manual # both, into docs/manual/build/
|
||||||
|
tools/build-manual --check # verify only; what the release runs
|
||||||
|
```
|
||||||
|
|
||||||
|
`tools/build-release.sh` builds and checks the manual on every release, so
|
||||||
|
the digital and printed versions cannot drift apart. The check fails the
|
||||||
|
build if a page declares a `feature` with no `printed-if`, if it names a
|
||||||
|
feature `device.conf` does not have, or if the built files are missing.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
Pictures live in `docs/manual/images/`. They are captured from the real
|
||||||
|
interface with `tools/lab/lab shot`, never drawn or mocked up. A manual
|
||||||
|
showing a screen that does not exist is worse than one with no pictures.
|
||||||
|
|
||||||
|
They are **embedded** into the digital manual as data URIs, so it stays a
|
||||||
|
single file that works wherever it is copied and with the network
|
||||||
|
unplugged. A picture named by a page that is not in `images/` fails the
|
||||||
|
build, as does one with no caption.
|
||||||
|
|
||||||
|
**The printed manual prints the caption, not the picture** — as
|
||||||
|
`[ Picture: ... — see the computer's own Help ]`. Embedding a PNG in a
|
||||||
|
PDF means decoding it and writing an `/XObject`, and this builder exists
|
||||||
|
so that a release needs nothing but Python. If the printed manual ever
|
||||||
|
needs real pictures, that is the thing to add, and it is a contained
|
||||||
|
change: `render_pdf` is the only function that would move.
|
||||||
@@ -0,0 +1,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
|
||||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 67 KiB |
@@ -154,6 +154,74 @@ that would be a one-way door.
|
|||||||
|
|
||||||
The original is kept at `/etc/network/interfaces.before-ekklesia-classroom`.
|
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.
|
||||||
|
|||||||
@@ -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".
|
||||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Open this computer's own manual.
|
||||||
|
|
||||||
|
classroom-manual open it
|
||||||
|
classroom-manual --features say what this device would show
|
||||||
|
|
||||||
|
One manual is installed on every machine, carrying every page. What makes
|
||||||
|
it THIS room's manual is that the pages for features this device does not
|
||||||
|
have are removed before a teacher ever sees them: a Bible study laptop
|
||||||
|
opens it and reads about the Browser, Presentations and the volume, with
|
||||||
|
no mention of Wonder Ink or Parent Call to wonder about.
|
||||||
|
|
||||||
|
WHAT REACHES THE CLASSROOM ACCOUNT, AND WHAT DOES NOT.
|
||||||
|
|
||||||
|
Only a list of setting NAMES — ["WONDERINK_ENABLED", ...] — and only
|
||||||
|
those the package already defines as optional applications. No values
|
||||||
|
from device.conf, no broker address, no broker password, no update URL,
|
||||||
|
no Wi-Fi anything, no other key an administrator has put in that file.
|
||||||
|
|
||||||
|
That list is prepared by classroom-desktop-layout, which is already the
|
||||||
|
one place that decides what this device offers, and left in the runtime
|
||||||
|
directory where it is readable and carries nothing worth protecting. If
|
||||||
|
it is not there, every page is shown — which is exactly the printed
|
||||||
|
manual, and is never wrong, only less specific.
|
||||||
|
|
||||||
|
Shown through Chrome in application mode: no address bar, no tabs, one
|
||||||
|
window that closes. Chrome is on every classroom machine already, the
|
||||||
|
manual is a local file, and none of it needs the network.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
||||||
|
sys.path.insert(
|
||||||
|
0,
|
||||||
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
|
||||||
|
)
|
||||||
|
|
||||||
|
from classroom_config import FEATURES, feature, read_device_config # noqa: E402
|
||||||
|
|
||||||
|
MANUAL = "/usr/local/share/ekklesia-classroom/manual/classroom-manual.html"
|
||||||
|
|
||||||
|
# Written by classroom-desktop-layout at boot. See the module docstring
|
||||||
|
# for what is and is not in it.
|
||||||
|
MANIFEST = "/run/ekklesia-classroom/features.json"
|
||||||
|
|
||||||
|
|
||||||
|
def say(message):
|
||||||
|
print(f"classroom-manual: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def enabled_features():
|
||||||
|
"""The optional applications this device offers, by setting name."""
|
||||||
|
try:
|
||||||
|
with open(MANIFEST, "r", encoding="utf-8") as handle:
|
||||||
|
listed = json.load(handle)
|
||||||
|
|
||||||
|
if isinstance(listed, list):
|
||||||
|
# Filtered against what this package knows, so a stale or
|
||||||
|
# hand-edited manifest can only ever name a real feature.
|
||||||
|
return [name for name in listed if name in FEATURES]
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# No manifest. Fall back to asking device.conf directly for the
|
||||||
|
# feature keys and nothing else — the same question, one step less
|
||||||
|
# removed. A root-run --features does this; a classroom session
|
||||||
|
# normally has the manifest.
|
||||||
|
config = read_device_config()
|
||||||
|
|
||||||
|
return [name for name in sorted(FEATURES) if feature(config, name)]
|
||||||
|
|
||||||
|
|
||||||
|
def page(features):
|
||||||
|
"""The installed manual, with this device's features declared in it."""
|
||||||
|
with open(MANUAL, "r", encoding="utf-8") as handle:
|
||||||
|
text = handle.read()
|
||||||
|
|
||||||
|
declaration = (
|
||||||
|
"<script>window.CLASSROOM_FEATURES = "
|
||||||
|
+ json.dumps(sorted(features))
|
||||||
|
+ ";</script>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Before the viewer script, which reads it. Nothing else in the file
|
||||||
|
# is touched, so what a teacher reads is the file the package
|
||||||
|
# installed.
|
||||||
|
return text.replace("<body>", "<body>" + declaration, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Open this computer's manual.")
|
||||||
|
parser.add_argument("--features", action="store_true",
|
||||||
|
help="say what this device would show; opens nothing")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
|
||||||
|
features = enabled_features()
|
||||||
|
|
||||||
|
if arguments.features:
|
||||||
|
for name in sorted(FEATURES):
|
||||||
|
print(f" {name:<24} {'shown' if name in features else 'hidden'}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not os.path.exists(MANUAL):
|
||||||
|
say(f"the manual is not installed at {MANUAL}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Composed into the teacher's own cache rather than beside the
|
||||||
|
# installed file, which is root's and read-only.
|
||||||
|
cache = os.path.join(
|
||||||
|
os.environ.get("XDG_CACHE_HOME")
|
||||||
|
or os.path.expanduser("~/.cache"),
|
||||||
|
"ekklesia-classroom")
|
||||||
|
os.makedirs(cache, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
composed = os.path.join(cache, "manual.html")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
"w", encoding="utf-8", dir=cache, delete=False,
|
||||||
|
suffix=".html") as handle:
|
||||||
|
handle.write(page(features))
|
||||||
|
temporary = handle.name
|
||||||
|
|
||||||
|
os.replace(temporary, composed)
|
||||||
|
except OSError as error:
|
||||||
|
say(f"could not prepare the manual: {error}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
scale = "1"
|
||||||
|
|
||||||
|
try:
|
||||||
|
scale = subprocess.run(
|
||||||
|
["/usr/local/bin/classroom-ui-scale"], capture_output=True,
|
||||||
|
text=True, timeout=10, check=False).stdout.strip() or "1"
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Application mode: the manual in a window of its own, with no
|
||||||
|
# address bar for a teacher to end up somewhere else in. The
|
||||||
|
# room's ordinary browser profile is deliberately not used, so
|
||||||
|
# opening Help cannot disturb a lesson that is already open in
|
||||||
|
# Chrome.
|
||||||
|
os.execvp("google-chrome", [
|
||||||
|
"google-chrome",
|
||||||
|
"--ozone-platform=wayland",
|
||||||
|
f"--force-device-scale-factor={scale}",
|
||||||
|
f"--user-data-dir={os.path.join(cache, 'manual-profile')}",
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--password-store=basic",
|
||||||
|
"--hide-crash-restore-bubble",
|
||||||
|
f"--app=file://{composed}",
|
||||||
|
])
|
||||||
|
except OSError as error:
|
||||||
|
say(f"could not open the browser: {error}")
|
||||||
|
say(f"the manual itself is at {composed}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -105,95 +105,10 @@ OVERALL_TIMEOUT = 210.0
|
|||||||
CLOSE_AFTER_SUCCESS = 4.0
|
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 = """
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# The teacher's manual: one source, two versions, and no drift.
|
||||||
|
#
|
||||||
|
# Two halves, because the manual has two failure modes and they are not
|
||||||
|
# alike. The source can be wrong — a page for an optional feature with
|
||||||
|
# no conditional sentence makes the PRINTED manual state it
|
||||||
|
# unconditionally, which is wrong in every room that does not have it.
|
||||||
|
# And the built page can be wrong — it can show a teacher a feature
|
||||||
|
# their computer does not have, or hide one it does, or carry something
|
||||||
|
# out of device.conf that the classroom account has no business seeing.
|
||||||
|
#
|
||||||
|
# The second half runs the manual's own viewer script in node against a
|
||||||
|
# stub DOM, the same way test-classroom-network.sh evaluates the polkit
|
||||||
|
# rule: it is the actual shipped code, not a description of it.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
echo "A. the source, and the release gate"
|
||||||
|
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
root = Path(sys.argv[1])
|
||||||
|
sys.path.insert(0, str(root / "lib"))
|
||||||
|
|
||||||
|
from classroom_config import FEATURES
|
||||||
|
|
||||||
|
passed = failed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(label, condition, detail=""):
|
||||||
|
global passed, failed
|
||||||
|
if condition:
|
||||||
|
print(f" ok {label}")
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
print(f" FAIL {label}")
|
||||||
|
if detail:
|
||||||
|
print(f" {detail}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
|
||||||
|
source = root / "docs" / "manual"
|
||||||
|
build = source / "build"
|
||||||
|
pages = sorted(p for p in source.glob("*.md") if p.name != "README.md")
|
||||||
|
|
||||||
|
check("there is a manual source directory", source.is_dir())
|
||||||
|
check("with pages in it", len(pages) >= 5, str(len(pages)))
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
for path in pages:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
header = {}
|
||||||
|
|
||||||
|
if text.startswith("---\n"):
|
||||||
|
end = text.index("\n---\n", 3)
|
||||||
|
for line in text[4:end].splitlines():
|
||||||
|
if ":" in line:
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
header[key.strip()] = value.strip()
|
||||||
|
|
||||||
|
headers[path.name] = header
|
||||||
|
|
||||||
|
for name, header in headers.items():
|
||||||
|
check(f"{name} has a title", bool(header.get("title")))
|
||||||
|
|
||||||
|
# THE RULE THAT KEEPS THE PRINTED MANUAL TRUE.
|
||||||
|
for name, header in headers.items():
|
||||||
|
if header.get("feature"):
|
||||||
|
check(f"{name} says when it applies, for the printed manual",
|
||||||
|
bool(header.get("printed-if")),
|
||||||
|
"without it the printed manual states an optional feature "
|
||||||
|
"as though every room had it")
|
||||||
|
check(f"{name} names a real device.conf feature",
|
||||||
|
header["feature"] in FEATURES,
|
||||||
|
f"{header.get('feature')} is not one of "
|
||||||
|
f"{', '.join(sorted(FEATURES))}")
|
||||||
|
|
||||||
|
documented = {h.get("feature") for h in headers.values() if h.get("feature")}
|
||||||
|
|
||||||
|
for name in FEATURES:
|
||||||
|
check(f"{name} has a manual page", name in documented,
|
||||||
|
"a room would get a button the manual never mentions")
|
||||||
|
|
||||||
|
orders = [h.get("order") for h in headers.values()]
|
||||||
|
check("no two pages claim the same place", len(orders) == len(set(orders)),
|
||||||
|
str(sorted(orders)))
|
||||||
|
|
||||||
|
print(" -- the builder itself")
|
||||||
|
builder = root / "tools" / "build-manual"
|
||||||
|
check("it exists and is runnable", builder.exists()
|
||||||
|
and builder.stat().st_mode & 0o111)
|
||||||
|
|
||||||
|
result = subprocess.run([str(builder)], capture_output=True, text=True,
|
||||||
|
timeout=120)
|
||||||
|
check("it builds without complaint", result.returncode == 0,
|
||||||
|
result.stderr.strip())
|
||||||
|
check("it produced the digital manual",
|
||||||
|
(build / "classroom-manual.html").exists())
|
||||||
|
check("and the printable one", (build / "classroom-manual.pdf").exists())
|
||||||
|
|
||||||
|
pdf = (build / "classroom-manual.pdf").read_bytes()
|
||||||
|
check("the PDF is a real PDF", pdf.startswith(b"%PDF-")
|
||||||
|
and pdf.rstrip().endswith(b"%%EOF"), str(pdf[:8]))
|
||||||
|
check("with more than one page", pdf.count(b"/Type /Page ") > 1,
|
||||||
|
str(pdf.count(b"/Type /Page ")))
|
||||||
|
check("and a revision date on it, so an old printout can be spotted",
|
||||||
|
b"Revision" in pdf)
|
||||||
|
|
||||||
|
# Every conditional sentence has to actually reach the printed manual.
|
||||||
|
for name, header in headers.items():
|
||||||
|
if header.get("printed-if"):
|
||||||
|
words = header["printed-if"].encode("latin-1", "replace")
|
||||||
|
check(f"{name}'s conditional sentence is in the PDF",
|
||||||
|
words in pdf, header["printed-if"])
|
||||||
|
|
||||||
|
check("the check mode passes on what was just built",
|
||||||
|
subprocess.run([str(builder), "--check"], capture_output=True,
|
||||||
|
timeout=120).returncode == 0)
|
||||||
|
|
||||||
|
html = (build / "classroom-manual.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
for name, header in headers.items():
|
||||||
|
if header.get("feature"):
|
||||||
|
check(f"{name} is marked in the HTML with its feature",
|
||||||
|
f'data-feature="{header["feature"]}"' in html)
|
||||||
|
|
||||||
|
check("the digital manual needs nothing from the network",
|
||||||
|
"http://" not in html and "https://" not in html,
|
||||||
|
"it has to work in a building with no internet")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"{passed} passed, {failed} failed")
|
||||||
|
raise SystemExit(1 if failed else 0)
|
||||||
|
PY
|
||||||
|
|
||||||
|
status=$?
|
||||||
|
[[ $status -ne 0 ]] && exit $status
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "B. what a device actually shows"
|
||||||
|
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
echo " SKIP node is not installed; the viewer script was not evaluated"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
node - "$ROOT" <<'JS'
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const root = process.argv[2];
|
||||||
|
|
||||||
|
let passed = 0, failed = 0;
|
||||||
|
function check(label, condition, detail) {
|
||||||
|
if (condition) { console.log(" ok " + label); passed++; }
|
||||||
|
else { console.log(" FAIL " + label); if (detail) console.log(" " + detail); failed++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = fs.readFileSync(
|
||||||
|
path.join(root, "docs/manual/build/classroom-manual.html"), "utf8");
|
||||||
|
|
||||||
|
// The shipped viewer, pulled out of the built page and run for real.
|
||||||
|
const script = html.slice(html.lastIndexOf("<script>") + 8,
|
||||||
|
html.lastIndexOf("</script>"));
|
||||||
|
|
||||||
|
// Enough DOM for it. Sections carry the feature they need, exactly as
|
||||||
|
// the builder wrote them.
|
||||||
|
function sectionsOf(text) {
|
||||||
|
const found = [];
|
||||||
|
const re = /<section id="([^"]+)"(?: data-feature="([^"]+)")?>/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(text)) !== null) {
|
||||||
|
found.push({id: m[1], feature: m[2] || null});
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(features) {
|
||||||
|
const sections = sectionsOf(html).map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
removed: false,
|
||||||
|
getAttribute: function (name) {
|
||||||
|
return name === "data-feature" ? s.feature : null;
|
||||||
|
},
|
||||||
|
remove: function () { this.removed = true; },
|
||||||
|
querySelector: function () { return {textContent: s.id}; },
|
||||||
|
}));
|
||||||
|
const contents = {children: [], appendChild: function (c) { this.children.push(c); }};
|
||||||
|
const window = {CLASSROOM_FEATURES: features};
|
||||||
|
const document = {
|
||||||
|
querySelectorAll: () => sections,
|
||||||
|
getElementById: () => contents,
|
||||||
|
createElement: () => ({}),
|
||||||
|
};
|
||||||
|
new Function("window", "document", script)(window, document);
|
||||||
|
return sections.filter(s => !s.removed).map(s => s.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = sectionsOf(html).map(s => s.id);
|
||||||
|
const optional = sectionsOf(html).filter(s => s.feature);
|
||||||
|
check("the built manual carries every page",
|
||||||
|
all.length >= 5, all.join(", "));
|
||||||
|
check("and some of them are optional", optional.length >= 3,
|
||||||
|
optional.map(s => s.feature).join(", "));
|
||||||
|
|
||||||
|
console.log(" -- the Bible study laptop: Presentations, and nothing else optional");
|
||||||
|
const laptop = show(["PRESENTATIONS_ENABLED"]);
|
||||||
|
check("the Presentations page is shown",
|
||||||
|
laptop.some(id => id.includes("presentations")), laptop.join(", "));
|
||||||
|
check("the Browser page is shown, because every room has one",
|
||||||
|
laptop.some(id => id.includes("browser")));
|
||||||
|
check("sound and volume are shown", laptop.some(id => id.includes("sound")));
|
||||||
|
check("Wonder Ink is NOT shown",
|
||||||
|
!laptop.some(id => id.includes("wonderink")), laptop.join(", "));
|
||||||
|
check("Parent Call is NOT shown",
|
||||||
|
!laptop.some(id => id.includes("parent-call")), laptop.join(", "));
|
||||||
|
check("the DVD page is NOT shown",
|
||||||
|
!laptop.some(id => id.includes("dvd")), laptop.join(", "));
|
||||||
|
|
||||||
|
console.log(" -- a children's classroom: everything but Presentations");
|
||||||
|
const classroom = show(["WONDERINK_ENABLED", "PARENT_CALL_ENABLED", "DVD_ENABLED"]);
|
||||||
|
check("Wonder Ink is shown", classroom.some(id => id.includes("wonderink")));
|
||||||
|
check("Parent Call is shown", classroom.some(id => id.includes("parent-call")));
|
||||||
|
check("the DVD page is shown", classroom.some(id => id.includes("dvd")));
|
||||||
|
check("Presentations is NOT shown",
|
||||||
|
!classroom.some(id => id.includes("presentations")), classroom.join(", "));
|
||||||
|
|
||||||
|
console.log(" -- a device that could not say what it has");
|
||||||
|
// No manifest: show everything, which is the printed manual and is never
|
||||||
|
// wrong, only less specific.
|
||||||
|
const unknown = show(null);
|
||||||
|
check("every page is shown rather than none",
|
||||||
|
unknown.length === all.length, unknown.length + " of " + all.length);
|
||||||
|
|
||||||
|
console.log(" -- nothing optional at all");
|
||||||
|
const bare = show([]);
|
||||||
|
check("only the common pages remain",
|
||||||
|
bare.length === all.length - optional.length,
|
||||||
|
bare.join(", "));
|
||||||
|
|
||||||
|
console.log(" -- what the page is allowed to know about the device");
|
||||||
|
const manual = fs.readFileSync(path.join(root, "scripts/classroom-manual"), "utf8");
|
||||||
|
check("the manual is handed a list of setting NAMES, not device.conf",
|
||||||
|
/window\.CLASSROOM_FEATURES/.test(manual)
|
||||||
|
&& /json\.dumps\(sorted\(features\)\)/.test(manual));
|
||||||
|
check("and the names are filtered against the features the package knows",
|
||||||
|
/name for name in listed if name in FEATURES/.test(manual),
|
||||||
|
"a stale manifest must not be able to name anything else");
|
||||||
|
const layout = fs.readFileSync(path.join(root, "scripts/classroom-desktop-layout"), "utf8");
|
||||||
|
check("the manifest is written from the enabled features alone",
|
||||||
|
/setting for variable, setting in FEATURE_LINES if answers\.get\(variable\)/.test(layout));
|
||||||
|
check("no value from device.conf is written into it",
|
||||||
|
!/read_device_config\(\)\[/.test(layout));
|
||||||
|
check("it lives in the runtime directory, so it cannot go stale",
|
||||||
|
/\/run\/ekklesia-classroom\/features\.json/.test(layout));
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log(passed + " passed, " + failed + " failed");
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
|
JS
|
||||||
@@ -166,7 +166,15 @@ check("it refuses to start when it could not undo itself",
|
|||||||
"shutil.which" in wifi
|
"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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,736 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Build the teacher's manual, both of it, from docs/manual.
|
||||||
|
|
||||||
|
tools/build-manual build everything into docs/manual/build
|
||||||
|
tools/build-manual --check verify only; what the release runs
|
||||||
|
|
||||||
|
TWO OUTPUTS, ONE SOURCE.
|
||||||
|
|
||||||
|
classroom-manual.html installed on every classroom computer. It
|
||||||
|
carries every page, each marked with the
|
||||||
|
device.conf setting that decides whether that
|
||||||
|
computer shows it, and hides the ones this
|
||||||
|
device does not have when it is opened.
|
||||||
|
|
||||||
|
classroom-manual.pdf the universal printed manual. Every page,
|
||||||
|
every feature, with a conditional sentence
|
||||||
|
above the optional ones so that it stays true
|
||||||
|
in a room that does not have them all.
|
||||||
|
|
||||||
|
They cannot drift apart because neither is written: both are generated
|
||||||
|
from the same Markdown, and --check fails the release if a page is
|
||||||
|
missing what the printed version needs.
|
||||||
|
|
||||||
|
WHY THE PDF IS WRITTEN HERE RATHER THAN CONVERTED.
|
||||||
|
|
||||||
|
A manual is headings, paragraphs, bullets, numbered steps and one small
|
||||||
|
table. Writing that into a PDF directly is a couple of hundred lines with
|
||||||
|
the standard fonts, no embedding and no dependency — and it means a
|
||||||
|
release can be built on any machine with Python. Converting the HTML
|
||||||
|
through LibreOffice was the alternative: it needs LibreOffice on whatever
|
||||||
|
builds the release, it lays a web page out like a web page, and its
|
||||||
|
output changes with the version installed. This does not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import html
|
||||||
|
import mimetypes
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SOURCE = ROOT / "docs" / "manual"
|
||||||
|
BUILD = SOURCE / "build"
|
||||||
|
|
||||||
|
sys.path.insert(0, str(ROOT / "lib"))
|
||||||
|
|
||||||
|
from classroom_config import FEATURES # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ reading
|
||||||
|
|
||||||
|
def parse(path):
|
||||||
|
"""One manual page: its header, and its body as a list of blocks."""
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
header = {}
|
||||||
|
|
||||||
|
if text.startswith("---\n"):
|
||||||
|
end = text.index("\n---\n", 3)
|
||||||
|
for line in text[4:end].splitlines():
|
||||||
|
if ":" in line:
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
header[key.strip()] = value.strip()
|
||||||
|
text = text[end + 5:]
|
||||||
|
|
||||||
|
return header, blocks(text)
|
||||||
|
|
||||||
|
|
||||||
|
_IMAGE = re.compile(r"^!\[([^\]]*)\]\(([^)]+)\)\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def starts_block(line):
|
||||||
|
"""Whether this line begins something that is not a paragraph.
|
||||||
|
|
||||||
|
One test, used by every branch below AND by the paragraph loop, so
|
||||||
|
the two cannot disagree about a line. They did: the paragraph loop
|
||||||
|
stopped at any line beginning with "*", while the bullet rule needed
|
||||||
|
"* " — so a paragraph opening with a bold word consumed nothing,
|
||||||
|
advanced nothing, and the build hung. Every page whose first line is
|
||||||
|
bold hit it.
|
||||||
|
"""
|
||||||
|
return (line.startswith(("## ", "### ", "* ", "|"))
|
||||||
|
or bool(re.match(r"^\d+\. ", line))
|
||||||
|
or bool(_IMAGE.match(line)))
|
||||||
|
|
||||||
|
|
||||||
|
def gather(lines, index, marker):
|
||||||
|
"""A whole list, with each item's wrapped continuation lines folded in.
|
||||||
|
|
||||||
|
A step written across two lines in the source is one step, not a step
|
||||||
|
and a paragraph. Without this the list ended at the first wrapped
|
||||||
|
item and the next marker started a NEW list — so a three-step
|
||||||
|
procedure printed as three separate steps all numbered 1, which is
|
||||||
|
the worst possible way to get an instruction wrong.
|
||||||
|
"""
|
||||||
|
items = []
|
||||||
|
pattern = re.compile(marker)
|
||||||
|
|
||||||
|
while index < len(lines):
|
||||||
|
line = lines[index]
|
||||||
|
|
||||||
|
if pattern.match(line):
|
||||||
|
items.append(re.sub(marker, "", line).strip())
|
||||||
|
index += 1
|
||||||
|
elif items and line.strip() and line[0] in " \t":
|
||||||
|
# An indented line under an item belongs to that item.
|
||||||
|
items[-1] += " " + line.strip()
|
||||||
|
index += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return items, index
|
||||||
|
|
||||||
|
|
||||||
|
def blocks(text):
|
||||||
|
"""Markdown, as much of it as a teacher's manual needs.
|
||||||
|
|
||||||
|
Headings, paragraphs, bullets, numbered steps and a simple table.
|
||||||
|
Deliberately not a Markdown library: the same text has to come out
|
||||||
|
right as a web page and as typeset print, and the small fixed set
|
||||||
|
that does is easier to keep honest than a general one.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
lines = text.splitlines()
|
||||||
|
index = 0
|
||||||
|
|
||||||
|
while index < len(lines):
|
||||||
|
line = lines[index].rstrip()
|
||||||
|
|
||||||
|
if not line.strip():
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _IMAGE.match(line):
|
||||||
|
match = _IMAGE.match(line)
|
||||||
|
out.append(("image", (match.group(1), match.group(2))))
|
||||||
|
index += 1
|
||||||
|
elif line.startswith("### "):
|
||||||
|
out.append(("h3", line[4:].strip()))
|
||||||
|
index += 1
|
||||||
|
elif line.startswith("## "):
|
||||||
|
out.append(("h2", line[3:].strip()))
|
||||||
|
index += 1
|
||||||
|
elif line.startswith("| "):
|
||||||
|
rows = []
|
||||||
|
while index < len(lines) and lines[index].startswith("|"):
|
||||||
|
cells = [c.strip() for c in lines[index].strip().strip("|").split("|")]
|
||||||
|
# The |---|---| rule under a header row is not data.
|
||||||
|
if not all(set(c) <= set("-: ") for c in cells):
|
||||||
|
rows.append(cells)
|
||||||
|
index += 1
|
||||||
|
out.append(("table", rows))
|
||||||
|
elif line.startswith("* "):
|
||||||
|
items, index = gather(lines, index, r"^\* ")
|
||||||
|
out.append(("bullets", items))
|
||||||
|
elif re.match(r"^\d+\. ", line):
|
||||||
|
items, index = gather(lines, index, r"^\d+\. ")
|
||||||
|
out.append(("steps", items))
|
||||||
|
else:
|
||||||
|
# Always consumes the line it was given, whatever it is, so
|
||||||
|
# this loop can never fail to advance.
|
||||||
|
paragraph = [line.strip()]
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
while index < len(lines) and lines[index].strip() and \
|
||||||
|
not starts_block(lines[index]):
|
||||||
|
paragraph.append(lines[index].strip())
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
out.append(("p", " ".join(paragraph)))
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def pages():
|
||||||
|
found = []
|
||||||
|
|
||||||
|
for path in sorted(SOURCE.glob("*.md")):
|
||||||
|
if path.name == "README.md":
|
||||||
|
continue
|
||||||
|
|
||||||
|
header, body = parse(path)
|
||||||
|
header["path"] = path
|
||||||
|
found.append((header, body))
|
||||||
|
|
||||||
|
found.sort(key=lambda page: int(page[0].get("order", "99")))
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ marking
|
||||||
|
|
||||||
|
_BOLD = re.compile(r"\*\*(.+?)\*\*")
|
||||||
|
|
||||||
|
|
||||||
|
def inline_html(text):
|
||||||
|
return _BOLD.sub(r"<strong>\1</strong>", html.escape(text))
|
||||||
|
|
||||||
|
|
||||||
|
def plain(text):
|
||||||
|
"""The same text with its marking removed, for the PDF."""
|
||||||
|
return _BOLD.sub(r"\1", text)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- HTML
|
||||||
|
|
||||||
|
STYLE = """
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 0 24px 80px;
|
||||||
|
background: #141c20; color: #e6edef;
|
||||||
|
font-family: "Noto Sans", "DejaVu Sans", sans-serif;
|
||||||
|
font-size: 19px; line-height: 1.65;
|
||||||
|
}
|
||||||
|
.sheet { max-width: 46rem; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2.1rem; margin: 2rem 0 0.2rem; }
|
||||||
|
h2 { font-size: 1.5rem; margin: 2.4rem 0 0.4rem;
|
||||||
|
border-bottom: 1px solid #25333a; padding-bottom: .3rem; }
|
||||||
|
h3 { font-size: 1.15rem; margin: 1.6rem 0 0.3rem; color: #9fd8d1; }
|
||||||
|
p, li { margin: .5rem 0; }
|
||||||
|
ol, ul { padding-left: 1.4rem; }
|
||||||
|
strong { color: #ffffff; }
|
||||||
|
table { border-collapse: collapse; margin: 1rem 0; width: 100%; }
|
||||||
|
td, th { border: 1px solid #25333a; padding: .5rem .7rem; text-align: left; }
|
||||||
|
th { background: #0f171a; }
|
||||||
|
nav { margin: 1.5rem 0 2.5rem; padding: 1rem 1.2rem;
|
||||||
|
background: #0f171a; border: 1px solid #25333a; border-radius: 14px; }
|
||||||
|
nav a { color: #7fd0c6; text-decoration: none; display: block; padding: .2rem 0; }
|
||||||
|
nav a:hover { text-decoration: underline; }
|
||||||
|
figure { margin: 1.2rem 0; }
|
||||||
|
figure img { max-width: 100%; height: auto; display: block;
|
||||||
|
border: 1px solid #25333a; border-radius: 12px; }
|
||||||
|
figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
|
||||||
|
.revision { color: #93a5aa; font-size: .95rem; }
|
||||||
|
.top { color: #7fd0c6; text-decoration: none; font-size: .95rem; }
|
||||||
|
@media print {
|
||||||
|
body { background: #fff; color: #000; font-size: 11pt; }
|
||||||
|
nav, .top { display: none; }
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def embedded(source):
|
||||||
|
"""A picture as a data: URI, so the manual stays ONE file.
|
||||||
|
|
||||||
|
The installed manual is composed into the teacher's cache with this
|
||||||
|
device's features declared in it, so a relative path would point at a
|
||||||
|
directory that is not beside the copy being opened. Embedding means
|
||||||
|
the file works wherever it is put, with nothing to install alongside
|
||||||
|
it and nothing to go missing — which is also what makes it work with
|
||||||
|
the network unplugged.
|
||||||
|
"""
|
||||||
|
path = SOURCE / source
|
||||||
|
kind = mimetypes.guess_type(path.name)[0] or "image/png"
|
||||||
|
data = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||||
|
|
||||||
|
return f"data:{kind};base64,{data}"
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(all_pages, revision):
|
||||||
|
parts = [
|
||||||
|
"<!DOCTYPE html>", '<html lang="en">', "<head>",
|
||||||
|
'<meta charset="utf-8">',
|
||||||
|
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||||
|
"<title>Classroom manual</title>",
|
||||||
|
f"<style>{STYLE}</style>", "</head>", "<body>", '<div class="sheet">',
|
||||||
|
"<h1>Classroom manual</h1>",
|
||||||
|
f'<p class="revision">River of Glory · {revision}</p>',
|
||||||
|
"<nav id='contents'></nav>",
|
||||||
|
]
|
||||||
|
|
||||||
|
for header, body in all_pages:
|
||||||
|
feature = header.get("feature", "")
|
||||||
|
anchor = header["path"].stem
|
||||||
|
# The one thing the page carries into the browser: which setting
|
||||||
|
# decides whether this device shows it. No value, no other
|
||||||
|
# setting, and nothing about the machine.
|
||||||
|
attribute = f' data-feature="{html.escape(feature)}"' if feature else ""
|
||||||
|
parts.append(f'<section id="{anchor}"{attribute}>')
|
||||||
|
parts.append(f'<h2>{inline_html(header["title"])}</h2>')
|
||||||
|
|
||||||
|
for kind, content in body:
|
||||||
|
if kind == "h2":
|
||||||
|
parts.append(f"<h3>{inline_html(content)}</h3>")
|
||||||
|
elif kind == "h3":
|
||||||
|
parts.append(f"<h4>{inline_html(content)}</h4>")
|
||||||
|
elif kind == "p":
|
||||||
|
parts.append(f"<p>{inline_html(content)}</p>")
|
||||||
|
elif kind == "bullets":
|
||||||
|
parts.append("<ul>" + "".join(
|
||||||
|
f"<li>{inline_html(i)}</li>" for i in content) + "</ul>")
|
||||||
|
elif kind == "steps":
|
||||||
|
parts.append("<ol>" + "".join(
|
||||||
|
f"<li>{inline_html(i)}</li>" for i in content) + "</ol>")
|
||||||
|
elif kind == "image":
|
||||||
|
alt, source = content
|
||||||
|
parts.append(
|
||||||
|
f'<figure><img src="{embedded(source)}" '
|
||||||
|
f'alt="{html.escape(alt)}">'
|
||||||
|
f'<figcaption>{inline_html(alt)}</figcaption></figure>')
|
||||||
|
elif kind == "table":
|
||||||
|
rows = []
|
||||||
|
for number, row in enumerate(content):
|
||||||
|
tag = "th" if number == 0 else "td"
|
||||||
|
rows.append("<tr>" + "".join(
|
||||||
|
f"<{tag}>{inline_html(c)}</{tag}>" for c in row) +
|
||||||
|
"</tr>")
|
||||||
|
parts.append("<table>" + "".join(rows) + "</table>")
|
||||||
|
|
||||||
|
parts.append('<p><a class="top" href="#contents">Back to the top</a></p>')
|
||||||
|
parts.append("</section>")
|
||||||
|
|
||||||
|
parts.append("</div>")
|
||||||
|
parts.append(f"<script>{VIEWER}</script>")
|
||||||
|
parts.append("</body></html>")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# What the manual does when a teacher opens it.
|
||||||
|
#
|
||||||
|
# classroom-manual writes the device's enabled features into
|
||||||
|
# window.CLASSROOM_FEATURES before this runs. It is a list of setting
|
||||||
|
# names and nothing else — no values from device.conf, no broker
|
||||||
|
# password, no addresses, nothing an administrator set. With no list at
|
||||||
|
# all, every page is shown, which is the printed manual's behaviour and
|
||||||
|
# is always true, just less specific.
|
||||||
|
VIEWER = """
|
||||||
|
(function () {
|
||||||
|
var have = window.CLASSROOM_FEATURES || null;
|
||||||
|
var sections = document.querySelectorAll('section');
|
||||||
|
var contents = document.getElementById('contents');
|
||||||
|
|
||||||
|
for (var i = 0; i < sections.length; i++) {
|
||||||
|
var needs = sections[i].getAttribute('data-feature');
|
||||||
|
|
||||||
|
if (needs && have && have.indexOf(needs) === -1) {
|
||||||
|
sections[i].remove();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var link = document.createElement('a');
|
||||||
|
link.href = '#' + sections[i].id;
|
||||||
|
link.textContent = sections[i].querySelector('h2').textContent;
|
||||||
|
contents.appendChild(link);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- PDF
|
||||||
|
|
||||||
|
# The 14 fonts every PDF reader has. Nothing is embedded, so the file is
|
||||||
|
# small and identical everywhere.
|
||||||
|
BODY_FONT = "Helvetica"
|
||||||
|
BOLD_FONT = "Helvetica-Bold"
|
||||||
|
|
||||||
|
PAGE_WIDTH, PAGE_HEIGHT = 595, 842 # A4 in points
|
||||||
|
MARGIN_X, MARGIN_TOP, MARGIN_BOTTOM = 62, 780, 64
|
||||||
|
|
||||||
|
# Helvetica's own widths, per 1000 units, for the printable ASCII range.
|
||||||
|
# Wrapping by character count puts a line of capitals off the page and
|
||||||
|
# leaves a line of commas half empty, and this is a manual people print.
|
||||||
|
_WIDTHS = {
|
||||||
|
" ": 278, "!": 278, '"': 355, "#": 556, "$": 556, "%": 889, "&": 667,
|
||||||
|
"'": 191, "(": 333, ")": 333, "*": 389, "+": 584, ",": 278, "-": 333,
|
||||||
|
".": 278, "/": 278, ":": 278, ";": 278, "<": 584, "=": 584, ">": 584,
|
||||||
|
"?": 556, "@": 1015, "[": 278, "\\": 278, "]": 278, "^": 469, "_": 556,
|
||||||
|
"`": 333, "{": 334, "|": 260, "}": 334, "~": 584,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def width_of(text, size, bold=False):
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
for character in text:
|
||||||
|
if character.isdigit():
|
||||||
|
total += 556
|
||||||
|
elif character.isupper():
|
||||||
|
total += 722 if bold else 667
|
||||||
|
elif character.islower():
|
||||||
|
total += 556 if bold else 500
|
||||||
|
else:
|
||||||
|
total += _WIDTHS.get(character, 556)
|
||||||
|
|
||||||
|
return total * size / 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
def wrap(text, size, available, bold=False):
|
||||||
|
lines = []
|
||||||
|
current = ""
|
||||||
|
|
||||||
|
for word in text.split():
|
||||||
|
candidate = f"{current} {word}".strip()
|
||||||
|
|
||||||
|
if current and width_of(candidate, size, bold) > available:
|
||||||
|
lines.append(current)
|
||||||
|
current = word
|
||||||
|
else:
|
||||||
|
current = candidate
|
||||||
|
|
||||||
|
if current:
|
||||||
|
lines.append(current)
|
||||||
|
|
||||||
|
return lines or [""]
|
||||||
|
|
||||||
|
|
||||||
|
def escape_pdf(text):
|
||||||
|
out = []
|
||||||
|
|
||||||
|
for character in text:
|
||||||
|
if character in "()\\":
|
||||||
|
out.append("\\" + character)
|
||||||
|
elif ord(character) < 128:
|
||||||
|
out.append(character)
|
||||||
|
else:
|
||||||
|
# The standard encoding has no place for a curly quote or a
|
||||||
|
# dash a writer typed without thinking. Replaced rather than
|
||||||
|
# dropped, so a printed line never loses a word.
|
||||||
|
out.append({
|
||||||
|
"’": "'", "‘": "'", "“": '"', "”": '"',
|
||||||
|
"—": "-", "–": "-", " ": " ", "·": "-",
|
||||||
|
"→": "->", "←": "<-", "↑": "up",
|
||||||
|
"↓": "down", "×": "x", "⏏": "eject",
|
||||||
|
"✕": "X", "◀": "<", "▶": ">",
|
||||||
|
}.get(character, "?"))
|
||||||
|
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
class Pdf:
|
||||||
|
"""A very small PDF writer: text, in two weights, on A4 pages."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.pages = []
|
||||||
|
self.current = []
|
||||||
|
self.y = MARGIN_TOP
|
||||||
|
|
||||||
|
def _show(self, text, x, y, size, bold):
|
||||||
|
font = "F2" if bold else "F1"
|
||||||
|
self.current.append(
|
||||||
|
f"BT /{font} {size} Tf 1 0 0 1 {x:.1f} {y:.1f} Tm "
|
||||||
|
f"({escape_pdf(text)}) Tj ET")
|
||||||
|
|
||||||
|
def break_page(self):
|
||||||
|
if self.current:
|
||||||
|
self.pages.append(self.current)
|
||||||
|
|
||||||
|
self.current = []
|
||||||
|
self.y = MARGIN_TOP
|
||||||
|
|
||||||
|
def space(self, amount):
|
||||||
|
self.y -= amount
|
||||||
|
|
||||||
|
def need(self, amount):
|
||||||
|
if self.y - amount < MARGIN_BOTTOM:
|
||||||
|
self.break_page()
|
||||||
|
|
||||||
|
def text(self, content, size=10.5, bold=False, indent=0, leading=None):
|
||||||
|
leading = leading or size * 1.42
|
||||||
|
available = PAGE_WIDTH - 2 * MARGIN_X - indent
|
||||||
|
|
||||||
|
for line in wrap(content, size, available, bold):
|
||||||
|
self.need(leading)
|
||||||
|
self._show(line, MARGIN_X + indent, self.y, size, bold)
|
||||||
|
self.y -= leading
|
||||||
|
|
||||||
|
def bullet(self, mark, content, size=10.5, indent=16):
|
||||||
|
leading = size * 1.42
|
||||||
|
available = PAGE_WIDTH - 2 * MARGIN_X - indent - 14
|
||||||
|
lines = wrap(content, size, available)
|
||||||
|
|
||||||
|
self.need(leading * len(lines))
|
||||||
|
self._show(mark, MARGIN_X + indent, self.y, size, False)
|
||||||
|
|
||||||
|
for number, line in enumerate(lines):
|
||||||
|
if number:
|
||||||
|
self.need(leading)
|
||||||
|
self._show(line, MARGIN_X + indent + 16, self.y, size, False)
|
||||||
|
self.y -= leading
|
||||||
|
|
||||||
|
def rule(self):
|
||||||
|
self.need(10)
|
||||||
|
self.current.append(
|
||||||
|
f"0.75 w 0.6 0.6 0.6 RG {MARGIN_X} {self.y + 4:.1f} m "
|
||||||
|
f"{PAGE_WIDTH - MARGIN_X} {self.y + 4:.1f} l S")
|
||||||
|
self.y -= 8
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
self.break_page()
|
||||||
|
|
||||||
|
objects = []
|
||||||
|
count = len(self.pages)
|
||||||
|
# 1 catalog, 2 pages, 3..(2+n) page objects, then the contents,
|
||||||
|
# then the two fonts.
|
||||||
|
kids = " ".join(f"{3 + i} 0 R" for i in range(count))
|
||||||
|
|
||||||
|
objects.append("<< /Type /Catalog /Pages 2 0 R >>")
|
||||||
|
objects.append(
|
||||||
|
f"<< /Type /Pages /Kids [{kids}] /Count {count} >>")
|
||||||
|
|
||||||
|
for index in range(count):
|
||||||
|
contents = 3 + count + index
|
||||||
|
objects.append(
|
||||||
|
f"<< /Type /Page /Parent 2 0 R "
|
||||||
|
f"/MediaBox [0 0 {PAGE_WIDTH} {PAGE_HEIGHT}] "
|
||||||
|
f"/Resources << /Font << /F1 {3 + 2 * count} 0 R "
|
||||||
|
f"/F2 {4 + 2 * count} 0 R >> >> "
|
||||||
|
f"/Contents {contents} 0 R >>")
|
||||||
|
|
||||||
|
for page in self.pages:
|
||||||
|
stream = "\n".join(page)
|
||||||
|
objects.append(
|
||||||
|
f"<< /Length {len(stream.encode('latin-1', 'replace'))} >>\n"
|
||||||
|
f"stream\n{stream}\nendstream")
|
||||||
|
|
||||||
|
for font in (BODY_FONT, BOLD_FONT):
|
||||||
|
objects.append(
|
||||||
|
f"<< /Type /Font /Subtype /Type1 /BaseFont /{font} "
|
||||||
|
f"/Encoding /WinAnsiEncoding >>")
|
||||||
|
|
||||||
|
out = ["%PDF-1.4"]
|
||||||
|
offsets = []
|
||||||
|
position = len(out[0]) + 1
|
||||||
|
|
||||||
|
for number, body in enumerate(objects, start=1):
|
||||||
|
chunk = f"{number} 0 obj\n{body}\nendobj"
|
||||||
|
offsets.append(position)
|
||||||
|
out.append(chunk)
|
||||||
|
position += len(chunk.encode("latin-1", "replace")) + 1
|
||||||
|
|
||||||
|
table = [f"xref\n0 {len(objects) + 1}", "0000000000 65535 f "]
|
||||||
|
table += [f"{offset:010d} 00000 n " for offset in offsets]
|
||||||
|
out.append("\n".join(table))
|
||||||
|
out.append(
|
||||||
|
f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
|
||||||
|
f"startxref\n{position}\n%%EOF")
|
||||||
|
|
||||||
|
return ("\n".join(out)).encode("latin-1", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def render_pdf(all_pages, revision):
|
||||||
|
pdf = Pdf()
|
||||||
|
|
||||||
|
pdf.space(60)
|
||||||
|
pdf.text("Classroom manual", size=26, bold=True)
|
||||||
|
pdf.space(6)
|
||||||
|
pdf.text("River of Glory", size=13)
|
||||||
|
pdf.space(2)
|
||||||
|
pdf.text(revision, size=10.5)
|
||||||
|
pdf.space(18)
|
||||||
|
pdf.text(
|
||||||
|
"This manual covers every classroom computer in the church. Not "
|
||||||
|
"every room has every button, so some pages begin by saying when "
|
||||||
|
"they apply. If your computer does not have what a page "
|
||||||
|
"describes, that page is not for your room.", size=10.5)
|
||||||
|
pdf.space(10)
|
||||||
|
pdf.text(
|
||||||
|
"Keep this copy in the room. If the date above is older than the "
|
||||||
|
"one on the computer's own Help, ask the office for a new one.",
|
||||||
|
size=10.5)
|
||||||
|
pdf.break_page()
|
||||||
|
|
||||||
|
for header, body in all_pages:
|
||||||
|
pdf.space(6)
|
||||||
|
pdf.text(plain(header["title"]), size=17, bold=True)
|
||||||
|
pdf.space(2)
|
||||||
|
pdf.rule()
|
||||||
|
pdf.space(6)
|
||||||
|
|
||||||
|
# THE CONDITIONAL SENTENCE.
|
||||||
|
#
|
||||||
|
# This is what lets one printed manual be true in every room. A
|
||||||
|
# page for something optional says so before it says anything
|
||||||
|
# else, so a teacher in a room without it knows within one line.
|
||||||
|
if header.get("printed-if"):
|
||||||
|
pdf.text(plain(header["printed-if"]) + ".", size=10.5, bold=True)
|
||||||
|
pdf.space(6)
|
||||||
|
|
||||||
|
for kind, content in body:
|
||||||
|
if kind == "h2":
|
||||||
|
pdf.space(8)
|
||||||
|
pdf.text(plain(content), size=12.5, bold=True)
|
||||||
|
pdf.space(3)
|
||||||
|
elif kind == "h3":
|
||||||
|
pdf.space(5)
|
||||||
|
pdf.text(plain(content), size=11, bold=True)
|
||||||
|
elif kind == "p":
|
||||||
|
pdf.text(plain(content))
|
||||||
|
pdf.space(5)
|
||||||
|
elif kind == "bullets":
|
||||||
|
for item in content:
|
||||||
|
pdf.bullet("-", plain(item))
|
||||||
|
pdf.space(5)
|
||||||
|
elif kind == "steps":
|
||||||
|
for number, item in enumerate(content, start=1):
|
||||||
|
pdf.bullet(f"{number}.", plain(item))
|
||||||
|
pdf.space(5)
|
||||||
|
elif kind == "image":
|
||||||
|
pdf.text(f"[ Picture: {plain(content[0])} — see the "
|
||||||
|
"computer's own Help ]", size=9.5)
|
||||||
|
pdf.space(5)
|
||||||
|
elif kind == "table":
|
||||||
|
for number, row in enumerate(content):
|
||||||
|
pdf.bullet(
|
||||||
|
" ", " ".join(plain(c) for c in row)
|
||||||
|
if number else
|
||||||
|
" ".join(plain(c).upper() for c in row))
|
||||||
|
pdf.space(5)
|
||||||
|
|
||||||
|
pdf.break_page()
|
||||||
|
|
||||||
|
return pdf.build()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- check
|
||||||
|
|
||||||
|
def check(all_pages):
|
||||||
|
"""Everything that would let the two versions drift apart."""
|
||||||
|
problems = []
|
||||||
|
|
||||||
|
if not all_pages:
|
||||||
|
problems.append("there are no manual pages at all")
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for header, body in all_pages:
|
||||||
|
name = header["path"].name
|
||||||
|
|
||||||
|
for required in ("title", "order"):
|
||||||
|
if not header.get(required):
|
||||||
|
problems.append(f"{name}: no {required}")
|
||||||
|
|
||||||
|
if header.get("order") in seen:
|
||||||
|
problems.append(f"{name}: another page already has order "
|
||||||
|
f"{header.get('order')}")
|
||||||
|
|
||||||
|
seen.add(header.get("order"))
|
||||||
|
|
||||||
|
feature = header.get("feature")
|
||||||
|
|
||||||
|
if feature:
|
||||||
|
# A page for an optional feature MUST carry the sentence the
|
||||||
|
# printed manual puts above it, or the printed manual quietly
|
||||||
|
# becomes wrong for every room without that feature.
|
||||||
|
if not header.get("printed-if"):
|
||||||
|
problems.append(
|
||||||
|
f"{name}: declares feature {feature} but has no "
|
||||||
|
"printed-if, so the printed manual would state it "
|
||||||
|
"unconditionally")
|
||||||
|
|
||||||
|
if feature not in FEATURES:
|
||||||
|
problems.append(
|
||||||
|
f"{name}: {feature} is not a device.conf feature "
|
||||||
|
f"(known: {', '.join(sorted(FEATURES))})")
|
||||||
|
|
||||||
|
if not body:
|
||||||
|
problems.append(f"{name}: no content")
|
||||||
|
|
||||||
|
for kind, content in body:
|
||||||
|
if kind != "image":
|
||||||
|
continue
|
||||||
|
|
||||||
|
picture = SOURCE / content[1]
|
||||||
|
|
||||||
|
if not picture.exists():
|
||||||
|
problems.append(f"{name}: {content[1]} is not there")
|
||||||
|
elif not content[0].strip():
|
||||||
|
# The printed manual prints the alt text as the caption,
|
||||||
|
# so a picture with none leaves a bare "[ Picture: ]".
|
||||||
|
problems.append(
|
||||||
|
f"{name}: {content[1]} has no caption, which the "
|
||||||
|
"printed manual needs")
|
||||||
|
|
||||||
|
# Every optional feature the package has should be documented, or a
|
||||||
|
# room gets a button the manual never mentions.
|
||||||
|
documented = {h.get("feature") for h, _ in all_pages if h.get("feature")}
|
||||||
|
|
||||||
|
for feature in FEATURES:
|
||||||
|
if feature not in documented:
|
||||||
|
problems.append(
|
||||||
|
f"{feature} is an optional feature with no manual page")
|
||||||
|
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Build the teacher's manual, digital and printed.")
|
||||||
|
parser.add_argument("--check", action="store_true",
|
||||||
|
help="verify the source and the built files only")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
|
||||||
|
all_pages = pages()
|
||||||
|
problems = check(all_pages)
|
||||||
|
|
||||||
|
for problem in problems:
|
||||||
|
print(f"build-manual: {problem}", file=sys.stderr)
|
||||||
|
|
||||||
|
if problems:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
revision = f"Revision {date.today():%d %B %Y}"
|
||||||
|
|
||||||
|
if arguments.check:
|
||||||
|
for name in ("classroom-manual.html", "classroom-manual.pdf"):
|
||||||
|
if not (BUILD / name).exists():
|
||||||
|
print(f"build-manual: {name} has not been built; run "
|
||||||
|
"tools/build-manual", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"build-manual: {len(all_pages)} pages, "
|
||||||
|
f"{len([h for h, _ in all_pages if h.get('feature')])} of them "
|
||||||
|
"optional, and both versions are built")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
BUILD.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
(BUILD / "classroom-manual.html").write_text(
|
||||||
|
render_html(all_pages, revision), encoding="utf-8")
|
||||||
|
(BUILD / "classroom-manual.pdf").write_bytes(
|
||||||
|
render_pdf(all_pages, revision))
|
||||||
|
|
||||||
|
print(f"build-manual: {BUILD / 'classroom-manual.html'}")
|
||||||
|
print(f"build-manual: {BUILD / 'classroom-manual.pdf'}")
|
||||||
|
print(f"build-manual: {revision}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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.
|
||||||
|
|||||||